ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从并发超卖到数据库事务:社团活动报名系统开发实战

从并发超卖到数据库事务:社团活动报名系统开发实战

项目实训开发日志这个系列已经写到第三篇,项目本身也走过了三分之一左右的周期。我们小组做的社团活动管理系统,前两周把框架、数据库和基础页面搭了起来,这周的目标非常明确:把“用户报名活动→管理员审批→人数统计”这条完整链路跑通,顺便解决掉一些藏在背后的隐患。这篇回就是本周开发过程的原原本本记录,从任务梳理、接口设计,到并发压力测试、代码评审,再到下一步排期。如果你也正在带项目或在做小组实训,应该能在里面看到不少熟悉场景,也能拿去直接用。

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 接下来三天的执行计划

下一周前两天,我准备先把活动状态自动更新补上,用一个定时任务周期检查活动表中的结束时间,到点自动将状态置为“已截止”。第三天开始做数据统计和名单导出,前端展示一个简单的人数柱状图,后端提供按时间分组的统计数据接口。管理员审批页面也要顺手优化一下,把通过和拒绝的操作反馈做明确化,避免点了按钮之后不知道有没有生效。

至于我自己的感受,这一周最明显的变化是大家开始真正用工程师思维想事情了。以前拿到需求只会写代码实现单个页面,现在会主动问边界条件、考虑并发影响、做代码约定。这种思维方式可能比任何框架和工具都更值钱,也是项目实训最有价值的部分。如果你们也正处于开发中段,遇到和名额超卖类似的数据问题,不用慌,先理清操作顺序,再补数据库约束,往往就能把线头解开。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进