导读:教培机构按课时计费,学员每签到一次就要消一次课。老师重复点击、网络重试、多端同时操作、请假后补课,任何一个环节重复执行,都可能把课时多扣、扣成负数,最后和家长对不上账。本文给一套可直接落地的课时幂等账本:账户与流水分离、唯一键消课、乐观锁扣减、红冲处理请假,看完能照抄表结构与核心代码。
消课本质上是一次“额度账户扣减”,它天然会遇到重复请求,来源通常有四类:
只在前端把按钮置灰挡不住后三类,幂等必须做在服务端,并且要落到数据库约束上。
很多系统直接在学员表上放一个“剩余课时”字段,消课就 remain = remain - 1。这样余额会变,但“哪一节课、什么时候、谁操作扣的”没有留痕,请假退课和家长对账时根本查不清。
正确做法是账户表管余额、流水表管每一次变动,流水只追加、不做物理删除,余额可由流水重算。
-- 课时账户:一个学员购买的一个课时包一行
CREATE TABLE lesson_account (
id BIGINT PRIMARY KEY,
student_id BIGINT NOT NULL,
package_id BIGINT NOT NULL,
total_lesson INT NOT NULL,
remain_lesson INT NOT NULL,
version INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_student_package (student_id, package_id),
CONSTRAINT chk_remain CHECK (remain_lesson >= 0)
);
-- 消课流水:只追加,请假用红冲流水,不删除原记录
CREATE TABLE lesson_consume_flow (
id BIGINT PRIMARY KEY,
biz_id VARCHAR(64) NOT NULL,
lesson_id BIGINT NOT NULL,
student_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
cost INT NOT NULL,
flow_type TINYINT NOT NULL, -- 1消课 2红冲 3补课
status TINYINT NOT NULL, -- 0处理中 1成功 2已撤销
create_time DATETIME NOT NULL,
UNIQUE KEY uk_biz_type (biz_id, flow_type)
);幂等键 biz_id 由“课节ID_学员ID”拼成:同一节课给同一名学员消课,无论请求多少次,只会有一条消课流水。
消课分四步,且放在同一个事务里:先查流水做幂等返回,再校验课节状态,插入流水(唯一索引兜底并发),最后带条件扣减余额。
@Transactional(rollbackFor = Exception.class)
public ConsumeResult consumeLesson(ConsumeCmd cmd) {
String bizId = cmd.getLessonId() + "_" + cmd.getStudentId();
LessonConsume exist = flowMapper.selectByBizIdAndType(bizId, FLOW_CONSUME);
if (exist != null) {
return ConsumeResult.idempotent(exist.getStatus()); // 已处理过,原样返回
}
Lesson lesson = lessonMapper.selectByIdForUpdate(cmd.getLessonId());
if (lesson == null || !lesson.canConsume()) {
throw new BizException("课节状态不允许消课");
}
try {
flowMapper.insert(buildConsumeFlow(cmd, bizId));
} catch (DuplicateKeyException dup) {
LessonConsume again = flowMapper.selectByBizIdAndType(bizId, FLOW_CONSUME);
return ConsumeResult.idempotent(again.getStatus()); // 并发重复,被唯一索引拦下
}
int rows = accountMapper.deduct(cmd.getAccountId(), cmd.getCost(), cmd.getVersion());
if (rows == 0) {
throw new BizException("课时不足或数据已变更,请刷新重试");
}
return ConsumeResult.ok();
}扣减语句同时带“余额足够”和“版本号”两个条件,余额不足或已被其它事务改过,影响行数就是 0,事务回滚,从 SQL 层杜绝扣成负数:
UPDATE lesson_account
SET remain_lesson = remain_lesson - #{cost},
version = version + 1
WHERE id = #{accountId}
AND remain_lesson >= #{cost}
AND version = #{version};课节状态建议用明确状态机:待上课 → 已签到 → 已消课;请假走 已请假(不消课);只有已消课允许红冲。状态流转在代码里收敛成一个方法,不要在各处随意 set。
班课一次要给几十名学员消课,把每个学员的消课放进同一个事务,逐条调用上面的幂等方法,任一失败整体回滚,避免出现“消了一半”的课。
@Transactional(rollbackFor = Exception.class)
public void consumeClass(Long lessonId, List<Long> studentIds) {
List<Long> failed = new ArrayList<>();
for (Long sid : studentIds) {
try {
consumeLesson(new ConsumeCmd(lessonId, sid));
} catch (BizException e) {
failed.add(sid); // 课时不足等业务异常记录下来
}
}
if (!failed.isEmpty()) {
throw new BizException("以下学员消课失败:" + failed);
}
}人数特别多的大课,单事务持锁过久会拖慢接口,可改为按批提交、失败清单落库后异步重试,由于每条都有幂等键,重试不会重复扣,最终再用流水重算对账即可。
请假退课不能物理删除原消课流水,否则审计断链、补课时还可能重复扣。正确做法是追加一条红冲流水把课时加回,补课时再生成一条新的消课流水:
@Transactional(rollbackFor = Exception.class)
public void revokeConsume(String consumeBizId) {
LessonConsume origin = flowMapper.selectByBizIdAndType(consumeBizId, FLOW_CONSUME);
if (origin == null || origin.getStatus() != SUCCESS) {
throw new BizException("原消课不存在或不可撤销");
}
flowMapper.insert(buildReverseFlow(origin)); // uk 兜底,重复红冲只生效一次
accountMapper.refund(origin.getAccountId(), origin.getCost());
flowMapper.markStatus(origin.getId(), REVOKED);
}这样一名学员的课时来龙去脉在流水表里完整可查:购课增加、消课扣减、请假红冲加回、补课再扣,每一笔都有对应的课节和类型。
remain = remain - 1:没有 remain >= cost 和版本号,并发下会多扣、扣成负数。中小机构规模用关系型数据库单表、唯一索引加乐观锁即可承载,不必默认上分布式锁;只有统一时间大规模签到形成热点时,再考虑队列削峰与串行化。无论自研还是选型,都建议配一个每日对账任务:按流水重算每个账户余额,与账户表比对,差异即告警。这类排课、签到消课与课时账本能力,在面向教培机构的商业 SaaS 产品(如乔拓云的教育系统)中通常作为内置模块提供;自研则需要持续维护课节状态机、幂等账本与对账任务。同一套“账户 + 幂等流水 + 乐观锁”模型,把课时换成次卡、券、积分等额度型资源同样适用。
remain >= 0 约束做最后兜底A:不会。每次消课以“课节ID+学员ID”为幂等键并建唯一索引,重复请求要么命中已有流水直接返回原结果,要么被唯一索引拦下,扣减只执行一次。
A:请假课节不做消课;若已误消,不删除记录,而是追加一条红冲流水把课时加回,实际补课时再生成新的消课流水,全程留痕可查。
A:扣减语句带 remain_lesson >= cost 条件并配合版本号,余额不足时更新影响行数为 0、事务回滚,数据库层还有 CHECK 约束兜底,两层保证不会为负。
A:放在同一事务内逐条幂等消课,任一失败整体回滚;人数很多时按批提交、记录失败清单并重试,最终用流水重算余额完成对账。
以上是教培消课场景下幂等账本的一套落地写法,仅作技术分享,表结构与状态机请结合自身班型、课时包规则取舍,具体功能以所用系统官方实时信息为准。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。