从并发超卖到数据库事务:社团活动报名系统开发实战 项目实训开发日志这个系列已经写到第三篇项目本身也走过了三分之一左右的周期。我们小组做的社团活动管理系统前两周把框架、数据库和基础页面搭了起来这周的目标非常明确把“用户报名活动→管理员审批→人数统计”这条完整链路跑通顺便解决掉一些藏在背后的隐患。这篇回就是本周开发过程的原原本本记录从任务梳理、接口设计到并发压力测试、代码评审再到下一步排期。如果你也正在带项目或在做小组实训应该能在里面看到不少熟悉场景也能拿去直接用。1. 第三周从哪开始前两周遗留的问题和本周任务表1.1 前两周的实际产出盘点先简单盘点一下前两周的产出。第一天到第三天主要在做需求文档和数据库设计活动表、用户表、报名表、社团表的字段就是那会儿定的。当时为了快速起步只覆盖了基础字段比如活动表里只有名称、地点、时间、人数上限和简介报名表也只有用户ID、活动ID、状态和时间。第四天到第七天搭好了前后端分离的骨架前端负责页面渲染后端提供JSON接口数据库在本地运行注册登录功能当时已经能跑通。第八天到第十二天我们做了活动列表的静态页面和接口的第一个版本但只能查询涉及用户身份处理的操作比如报名、审批全部还是虚线状态。第三周开始时我心里清楚前两周并不是白干同时也知道有几笔明显的技术债必须尽快处理。第一登录态过期没有统一拦截接口一旦报错页面就会弹出一个突兀的红色提示条。第二报名表完全没有唯一约束手动点测试看起来正常但只要出现并发请求谁也没法保证不会产生重复数据。第三活动的名额字段与报名表的实际数据没有建立一致性校验后面要写统计报表时必然越写越乱。这些问题在演示环境下几乎看不出影响但报名模块一上线就会变成硬伤所以周初的工作安排我把“还旧账”放在“做新功能”前面。1.2 本周任务拆解与分工本周一共五个工作日小组三个人各有侧重。A同学负责前端页面和交互逻辑B同学负责测试用例和文档同步我负责后端接口和数据结构调整。具体排期是这样的周一上午处理登录态拦截和报名表唯一约束周一下午开始设计报名相关接口周二到周三集中做前后端联调周四专门做并发测试和问题修复周五做模块自查和小范围用户测试。每一项任务都写在共享的任务清单里完成一个勾掉一个没有过夜积压。项目实训走到中期有一个习惯我觉得值得反复强调排期的时候一定要把“修老问题”排在“加新功能”前面。道理很简单老问题不处理后面所有依赖这套结构的代码都会跟着错。等新功能做完再回头看改动成本就是指数级上涨光是回归测试和联调返工就够让人崩溃了。2. 报名主流程的落地细节表结构、接口设计和页面交互2.1 报名表结构调整与唯一约束报名是社团活动管理系统里最关键的一条业务流程大致是用户在活动详情页看到剩余名额点击报名后提交一条报名记录管理员在后台审核通过或拒绝审核通过后该活动的已报名人数加一。这条链路看起来不复杂真正写好要处理的细节不少尤其是“同一个用户不能重复报名”和“报名人数不能超过活动名额”这两个问题。第一件事我调整了报名表给 user_id 和 activity_id 这两个字段的组合加了唯一索引。对应数据库初始化片段是这样的CREATE TABLE activity_enrollments ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, activity_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审核 1已通过 2已拒绝 3已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_activity (user_id, activity_id) );为什么加唯一索引很多人会不理解觉得后端代码里先查一遍“该用户是否已报名”就够了。实际开发中两个请求可能同时通过查询然后同时执行插入两边的查询结果都显示“还没报名”于是两条重复记录就进了数据库。唯一约束就是数据库层最后一道门哪怕代码逻辑漏了数据库也能兜住不让重复数据落地。这个设计是我们在第一轮测试后补上去的教训来得挺快也正因为来得快后面才避免了一整批更严重的问题。2.2 后端接口设计成什么样才算好用报名模块的后端接口我总共设计了三个查询活动详情、提交报名、管理员审核。提交报名的接口是重点它接收用户ID、活动ID两个核心参数流程上先校验活动是否存在再校验当前用户是否已经报名最后检查活动名额是否已满全部通过后写入报名记录并更新名额。串到一个事务里执行可以避免中间状态不一致。写代码时我给接口定了三个约定第一接口路径统一遵循“/api/资源/动作”的格式报名就是 POST /api/enrollments审核就是 PUT /api/enrollments/{id}/approve第二所有接口都要求登录后携带令牌后端从令牌解析用户身份避免前端直接传用户ID带来的伪造风险第三返回结构统一为“状态码、消息、数据”三件套成功和失败的语义都要明确。前两条是从团队协作的角度考虑的第三条是给前端同学减负否则页面联调时就要为每个接口专门写一套解析逻辑。审核接口的逻辑用到了事务和行锁伪代码如下def approve_enrollment(enrollment_id, action): with db.transaction(): enrollment select_enrollment_for_update(enrollment_id) if enrollment is None or enrollment.status ! 0: raise BusinessError(记录不存在或已处理) new_status 1 if action pass else 2 update_enrollment_status(enrollment_id, new_status) if new_status 1: update_activity_count(enrollment.activity_id, 1)这里用 select_enrollment_for_update 是为了在同一事务内锁住报名记录避免两个管理员同时对同一记录做审核时产生“都通过了”的竞态。项目实训阶段可能不一定会遇到这种极端场景但理解了这一点以后做更复杂的生产系统就能少走很多弯路。2.3 前端联调踩到的小问题后端接口第一版给到前端后A同学联调时果然撞上了问题。页面加载时同时发起了两个请求一个是活动详情的查询一个是当前用户报名状态的查询这两个请求的返回顺序不固定。有时候详情先返回页面上按钮显示的是“我要报名”过了一两秒状态接口返回“已报名”按钮又跳成了“已报名”视觉上就能看到闪变。严重的时候用户点了按钮前端已经按下后端数据里却还没有当前用户的有效报名记录状态就乱了。这个问题最后没有用前端顺序控制来解而是让后端把当前用户报名状态直接放进活动详情接口的返回结构里。页面只需要一次性请求详情就能同时拿到活动信息和当前用户的报名状态按钮的初始状态也就稳定了。这个改动看起来很小实际价值很高既减少了一次前端竞态又减少了一次网络往返为后来的操作状态管理打下了基础。3. 并发测试引发的名额超卖这是本周最值钱的坑3.1 现象100个名额反而通过了117个报名周四上午B同学跑完了我们之前准备好的测试用例随手加了一个并发脚本模拟100个名额、120个用户同时发起报名。第一次跑完我们都不敢相信结果报名成功记录居然有117条超过了活动设置的人数上限。这不是手工操作时能发现的交互问题而是需要在并发场景下才暴露的严重数据一致性问题。如果这个bug没有被发现后面真实场景里出现名额超卖后果会非常尴尬活动方以为是120人报名到现场发现容量只有100而报名成功的同学又已经收到了确认消息。所以这个坑对我们来说反而是本周最有价值的一次发现它让我们意识到写业务代码时不能只盯着正常流程还要考虑并发和数据边界。3.2 排查过程从日志到数据库一行一行找原因排查过程我们是严格按步骤来的。第一步打开后端日志发现大量请求几乎同一秒通过了剩余名额的判断多个线程在同一个时间点读到了同一个“还差1个名额”的值。第二步查询数据库报名表里有不少同一用户ID加上同一活动ID的重复记录说明唯一约束当时还没有生效或者当时表里根本没有唯一索引。第三步阅读报名接口的 service 层代码发现流程是先查询活动表拿到剩余名额判断剩余名额大于0然后插入报名记录最后再更新活动表的已报名人数。这三个动作没有放在一个事务里中间任何时刻的并发都会导致结果被绕开。再往深处看这个问题本质是一个“先检查后写入”的竞态。检查剩余名额和写入报名记录是两个独立的操作数据库层面又没有唯一约束兜底只要时间差足够小多个请求就可以同时通过检查然后各自写入数据。每一行代码单看都有道理组合在一起就出现了口子这也提醒我们代码评审时不能只看单点实现还要关注几个操作之间的顺序和约束。3.3 修复方案数据库锁加唯一约束双保险修复方案我经过两轮思考最终决定用数据库事务加唯一约束双保险的办法。具体是这样做的第一报名表加上了前面提到的唯一索引第二报名接口中先插入报名记录再执行活动名额的更新语句活动名额更新时带上条件判断第三整个流程放到事务中任何一步失败都回滚。名额更新的那条 SQL 长这样UPDATE activities SET enrolled_count enrolled_count 1 WHERE id ? AND enrolled_count max_count;这条语句的巧妙之处在于它把“名额是否已满”的判断放进了数据库层面。如果更新成功影响的行数是0说明名额已满事务回滚用户会收到“名额已满”的提示如果更新成功影响1行再插入报名记录做到事务性一致。再加上唯一索引同一个用户重复报名时数据库会直接抛唯一冲突代码捕获后返回“您已报名”。二者各自挡住一类问题并行执行时也能互相兜底。前端这边也同步做了两个调整提交报名后按钮立即进入“提交中”状态并禁用防止用户连续点击页面初始化时如果活动已经结束或名额已满按钮直接置灰不发起无效请求。这些调整不改变后端逻辑的正确性但能在用户体验层挡住大部分误操作。3.4 并发脚本验证结果修复完成后我们重新跑了B同学写好的并发脚本同样是100个名额、120个用户同时发起请求。这次的结果稳定在100条成功记录多出来的20条全部收到“名额已满”或“请勿重复报名”的提示。连续跑了三遍结果一致没有出现超卖也没有出现重复报名。我们还额外测试了管理员并发审核同一个人的场景结果也只有一个审核请求能成功另一个会收到“该记录已被处理”的提示。这个验证过程最大的收获不是这一模块好了而是小组几个人都形成了一种意识测试用例里必须包含并发场景哪怕是一个简单的循环脚本也比只用手工点按钮的覆盖范围大得多。4. 团队协作中的隐形支出代码评审和分支管理4.1 第一次正式代码评审发现的共性问题周五做模块自查之前我们安排了一场正式代码评审把之前所有提交的代码过了一遍。不看不知道一看问题还挺集中。比如接口路径命名不统一有人用“/api/getActivityDetail”风格有人用“/api/activity/detail”风格重构时全靠肉眼找。再比如时间字段格式不统一有的返回时间戳有的返回格式化好的字符串前端为了兼容得写两种解析逻辑。还有一批明显的注释代码块留着不删既影响阅读也很容易误导后来的同学。针对这些问题我们做了一份很短的代码约定接口路径统一为“/api/资源/动作”动作名使用简洁的语义词时间字段统一返回时间戳由前端自行格式化不再保留注释掉的代码块需要恢复的功能去 git 历史里找提交信息要求写清楚改动目的比如“修复报名接口并发重复插入问题”而不是单纯的“update”。这份约定的价值不在于多正式而在于它让大家有了一个共同的标准后续代码统一性和可读性都明显提升。4.2 分支合并从互相覆盖到规范流程团队协作的另一个隐形支出是 git 分支管理。前两周我们为了图省事三个人都在同一分支上开发结果经常出现“我改了A同学刚改过的文件”这种情况模块之间的互相覆盖成了家常便饭。第三周我们把工作流程改成了规范的分支管理主分支始终保持可以运行的状态每个新任务开一个独立功能分支开发完成后通过合并请求提交至少一位同学 review 后合入主分支。这个过程刚开始不顺畅冲突不可避免。遇到冲突时我们的处理方式是先在本地切换到主分支拉取最新代码再切回功能分支执行 rebase把冲突文件打开逐段判断保留哪边。合并完成后功能分支删除一条避免长期残留。前前后后处理了三次冲突之后大家已经能很流畅地操作了这也算项目实训里额外学到的一项硬技能。5. 进度复盘做完的功能、欠下的技术债、下一步排期5.1 本周完成的功能清单给本周贴一个明确的答案我整理出来的清单是活动列表页、活动详情页已经支持真实数据渲染报名提交接口完成了事务改造和唯一约束管理员审核接口可以审批通过或拒绝活动名额在报名和审核通过时自动更新登录态过期提示改为统一弹窗并跳转登录页前端报名按钮对已报名、名额已满、活动结束三种状态做了区分展示。可以说报名这条主流程在“功能可跑”的层面上已经完整了剩下的是细节打磨。5.2 仍然挂着的问题和潜在风险容易忽视的问题还有几个。第一活动状态的自动更新还没有实现报名截止时间到了之后活动不会自动关闭需要管理员手动操作。第二管理员端统计报表还没有做目前只能看原始数据无法直接给出清晰的参与人数趋势。第三名单导出功能没排进本周计划表格导出在后两周必须补上。第四部分历史接口的错误码不够语义化前端只能依赖后端返回的提示文本做展示后续需要抽时间统一定义。这些都是合理的技术债不影响下周演示但一定会影响交付质量所以我已经把它们写进了下周任务列表按照优先级排好顺序。5.3 接下来三天的执行计划下一周前两天我准备先把活动状态自动更新补上用一个定时任务周期检查活动表中的结束时间到点自动将状态置为“已截止”。第三天开始做数据统计和名单导出前端展示一个简单的人数柱状图后端提供按时间分组的统计数据接口。管理员审批页面也要顺手优化一下把通过和拒绝的操作反馈做明确化避免点了按钮之后不知道有没有生效。至于我自己的感受这一周最明显的变化是大家开始真正用工程师思维想事情了。以前拿到需求只会写代码实现单个页面现在会主动问边界条件、考虑并发影响、做代码约定。这种思维方式可能比任何框架和工具都更值钱也是项目实训最有价值的部分。如果你们也正处于开发中段遇到和名额超卖类似的数据问题不用慌先理清操作顺序再补数据库约束往往就能把线头解开。