直播大班课开课时,几百名学员在同一两秒内点“签到”,线下课几十人同时扫一个码,这类瞬时高写入是教育系统很典型的场景。如果每个请求都立刻逐条 insert、再实时 count 一次出勤人数,峰值一到就会出现请求变慢、同一条签到写两遍、人数和明细对不上。这篇把签到链路从语义到落库讲清楚,给一套不丢、不重、还能抗峰值的写法,文末附可照抄的踩坑清单。
先看最朴素的实现:收到签到请求就往签到表插一行,同时把出勤人数加一。峰值来临时它会同时暴露四类问题:
问题的根源是把“每次点击”都当成一次必须立刻落库的独立写操作,而签到这种场景天然允许很短的延迟聚合。
动手前先把验收标准写成三句可检验的话,后面所有设计都围绕它:
1)同一学员、同一节课,签到明细有且只有一条;2)允许请求在很短时间内排队,但每条有效签到最终都要落库,不允许静默丢弃;3)出勤人数一定等于去重后的明细条数,不允许出现两个对不上的数字。
这三句分别对应去重、可靠写入、计数一致,缺一个都会在线上被学员或老师发现。
去重不能只靠前端按钮置灰,也不能只靠缓存判断,最终一定要在数据库层有一条硬约束。签到的业务唯一键是“课节 + 学员”:
CREATE TABLE lesson_checkin (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
lesson_id BIGINT NOT NULL,
student_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL COMMENT 'NORMAL正常/MAKEUP补签',
checked_at DATETIME NOT NULL,
client_req_id VARCHAR(64) NOT NULL COMMENT '客户端幂等键',
UNIQUE KEY uk_lesson_student (lesson_id, student_id),
UNIQUE KEY uk_req (client_req_id)
);有了 uk_lesson_student,即使应用层判断失效、两个并发请求同时进来,数据库也只会让一条成功。写入用“冲突即转为已存在”的写法,而不是让接口报错:
INSERT INTO lesson_checkin (lesson_id, student_id, status, checked_at, client_req_id)
VALUES (:lesson_id, :student_id, 'NORMAL', NOW(), :req_id)
ON DUPLICATE KEY UPDATE id = id;ON DUPLICATE KEY UPDATE id = id 命中唯一键时不改动任何数据,接口仍返回“签到成功”,对重复重试天然幂等。再加一个 client_req_id 唯一键,是为了兜住同一客户端的自动重试。应用层校验负责快速失败和友好提示,数据库唯一约束负责绝不出错,两层都要有。
去重解决“不重”,批量缓冲解决“不被峰值打垮”。思路是:签到请求先进一个内存聚合区,由后台协程按“凑够一批或到了时间阈值”统一落库,把成百上千次单条写合并成少数几次批量写。
BUFFER = [] # 待写入缓冲
FLUSH_SIZE = 200 # 凑够 200 条写一次
FLUSH_MS = 500 # 或满 500ms 必写一次
async def on_checkin(req):
# 先在内存按 课节+学员 去重,重复请求直接返回成功
key = (req.lesson_id, req.student_id)
BUFFER.setdefault(key, req)
if len(BUFFER) >= FLUSH_SIZE:
await flush()
return {"ok": True}
async def flush():
batch = list(BUFFER.values())
BUFFER.clear()
# 一条 INSERT 多行,配合唯一索引去重
await db.executemany(UPSERT_SQL, batch)批量写入用一条多值 INSERT 配合上一节的唯一索引,数据库往返次数从“每人一次”降到“每批一次”。要让“不丢”成立,必须补两个机制:一是进程关闭或定时阈值到点时强制 flush,避免请求长时间停在内存;二是缓冲去重时保留第一次签到时间,不能被后来的重试覆盖。如果部署了多个服务实例,可以让同一节课的请求按 lesson_id 哈希到同一实例聚合,或借助消息队列分区消费,避免跨实例重复聚合。
高峰期不要反复 COUNT 明细表,也不要让独立计数器长期累加(一定会漂移),分两层:
实时数字允许短暂近似,但最终口径永远是
SELECT COUNT(DISTINCT student_id) …,两者出现差异时以明细校正计数,而不是反过来。
这样既保住了开课瞬间的流畅,又保证课后导出的出勤表一定准确。
client_req_id,重试沿用同一个键,服务端按它幂等;(lesson_id, student_id) 定位原记录后走状态更新,并记录补签时间和操作人;建议把签到沉淀成一个独立的“高写入采集”模块:接入层负责幂等键校验和缓冲聚合,落库层只认批量 upsert 和唯一约束,统计层区分实时计数与权威明细。缓冲大小、时间阈值、批量条数做成可配置项,按课堂规模压测后取值;大课可以按课节分区消费,让一节课的写入落到同一分区,简化去重。同一套骨架也适用于课堂答题、作业提交、问卷回收等瞬时高写入场景。
Q:用了批量缓冲,签到会不会有明显延迟?
A:设置“凑够一批或满 500 毫秒必写”的双阈值后,落库延迟通常控制在一秒以内,学员侧表现为点击即成功,真正批量写库在后台完成。
Q:唯一索引和幂等键是不是留一个就够?
A:不够。(课节,学员) 唯一约束保证业务上不重复,client_req_id 保证同一客户端重试不产生副作用,两者分别兜住业务重复和请求重复。
Q:实时出勤人数和课后明细以哪个为准?
A:实时人数来自增量计数、允许短暂近似;课后考勤以去重明细统计为唯一权威值,并用它回写校准实时计数。
(课节,学员) 唯一索引 + upsert 做了数据库兜底?班课签到的难点不在插入一条记录,而在开课瞬间同时守住不丢、不重、计数一致三条线:去重下沉到唯一约束、峰值交给批量缓冲、最终口径交给明细。本文为后端性能实践分享,具体实现以所用数据库、中间件与业务规则为准。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。