开学前一周,某高校的选课系统又双叒叕卡死了。学生一边刷新一边吐槽服务器,教务老师一边重启一边催开发,这个场景十年来几乎每学期都在上演。我接手“Spring Boot选课系统”这个项目时,给自己定的目标只有一条:别让选课再变成灾难现场。
我得先纠正一个常见的理解偏差:选课系统的核心不是“课程列表+学生点击+保存记录”,它本质上是一套资源分配系统。同一个时间窗口内,全校几千名学生去抢有限的课程名额,业务形态和限时抢购非常相似,数据一致性要求高、并发窗口集中、业务规则又多又碎。Spring Boot解决的是工程化效率问题,真正决定系统上限的,是并发控制、业务规则、权限边界这些细节上你花了多少心思。
这篇文章把从需求梳理到落地的完整过程拆开讲:业务边界怎么划、技术栈怎么选、数据库怎么建模、并发选课怎么做、高并发的可靠方案是什么,最后是那些只有线上环境才会暴露的坑。适合正在做毕业设计,或者在公司里做教务、活动报名、资源预约类系统的同学参考,内容偏实战,不绕弯子。
1. 选课系统的业务本质:先搞清楚自己在做什么
1.1 一个学期选课的基本流程
选课系统再怎么包装,底层流程都是四步:教务管理员维护课程基础数据,安排教学班、定容量、定时间段;配置选课批次(预选、正选、补退选);学生在批次开放时间内查看可选课程,提交选课或退课请求;选课结束后系统生成最终名单,供教师下载和点名。
流程看着简单,但每一步背后都叠着业务规则。预选阶段通常允许多选甚至允许超容量提交,由抽签或筛选分配名额;正选阶段实时扣减剩余名额,先到先得。还要处理时间冲突(同一学生同一时间段不能选两门课)、学分上限(一学期最多选多少学分)、先修课程校验(没学基础课不能选高阶课)。这些规则什么时候查、查错了给什么反馈、多个规则同时违反时以哪个为准,都要在动手写代码前定清楚。
我在做需求梳理时做了一个重要的取舍判断:第一版不做排课引擎,不做学分替代,不做教室冲突识别。这些属于教务排课域,复杂度高,混进来会把选课模块的进度拖垮。选课系统先解决“选”的问题,排课问题留给未来的专门模块。系统边界越清晰,后面的开发和沟通都会顺畅很多。
1.2 用户角色与核心用例
系统里活跃的用户就是三类:学生、教师、教务管理员。
学生关注课表、选课状态、退课、成绩;教师关注授课班级名单、课表打印、课程信息维护;教务管理员负责维护课程库、学期开课、批次设置,还要处理异常情况,比如手动帮某同学补选。
三类角色对权限体系的要求完全不同。我一开始图省事,在用户表上加了一个 role 字符串字段,后来发现这样做太粗:教师可能也在进修选课,学生可能当助教,一个人在不同学期可能同时有多个角色身份。最终改成独立的用户表加角色中间表,用户与角色是多对多关系。这个决定的直接收益是权限判断变灵活了,要给某人加一个“临时选课管理员”角色,只需往中间表插一条记录。
1.3 三类典型选课环节的并发特征
很多人把“选课系统”笼统理解成“高并发系统”,一上来就堆Redis、MQ、分库分表,其实这是个误导。选课不是全程高并发,不同阶段压力特征完全不同。
预选阶段学生集中在晚间黄金时间操作,并发峰值最高,但这一阶段允许超容量提交,对实时扣减的准确性要求反而低,重点是“收得住”。正选阶段名额有限、实时扣减,写操作压力大,必须保证不超卖。补退选阶段流量分散,退课会触发候补队列进位,要密切关注一退一进之间的原子操作。这三个阶段的业务策略不一样,后端锁方案和缓存策略也得跟着变,用一个通用接口包打天下的做法,往往是卡死和超卖的根源。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术栈选型:Spring Boot 之外的搭档怎么定
2.1 核心组件选型对照
选型这件事,我在不同项目里做过多次对比,最终落到这套系统上的方案如下:
| 模块 | 选择 | 理由 |
|---|---|---|
| 核心框架 | Spring Boot 2.7.x / 3.x | 新项目直接上 3.x + JDK17,老环境兼容则留 2.7.x |
| ORM | MyBatis-Plus | 动态SQL灵活,复杂多条件查询写起来省事 |
| 数据库 | MySQL 8.0(InnoDB) | 开源、成熟,事务和行锁能力足够 |
| 缓存/原子扣减 | Redis 6.x | 高并发下的熔断、计数、限流、异步队列都有用 |
| 认证授权 | Spring Security + JWT | 标准方案,社区资料多,和Spring集成度最好 |
| 前端 | Vue 3 + Element Plus | 前后端分离,教务系统常见的后台管理风格 |
| 定时任务 | Spring Task / Quartz | 做批次状态流转、数据对账和订单补偿 |
Spring Boot 2.7 和 3.x 之间有一个需要特别提醒的差异:3.x 基于 Jakarta EE 命名空间,很多老代码里的 javax.* 导入要全部改成 jakarta.*。如果你的团队里有人用过旧版本,首次升级时最容易在这里翻车。
选课系统有大量多条件查询(按课程性质、星期几、节次范围筛可选课程),MyBatis-Plus 的 QueryWrapper 动态拼条件很方便,比 Spring Data JPA 在复杂查询上的表现更直观。JPA 更适合关系管理简单、以对象导航为主的场景,选课这种重查询的系统用它反而别扭。
2.2 选型背后的判断逻辑
不少团队在技术选型上纠结很久,其实问题不是哪个框架更好,而是没有从业务场景倒推。你问“选课系统用什么ORM好”没有标准答案,但如果把问题换成“选课高峰期2000个并发去扣减课程名额怎么做”,答案就具体了:数据库行锁做兜底,Redis强一致扣减做缓冲,异步落库做熔断,对账任务保证最终一致。
我的选型原则很简单:优先保证单学生操作的响应时间,保证数据库不被打垮,保证异步补偿链路能落地。ORM 顺手只是加分项,不是决定项。另外,如果系统并发量预估只有几百 QPS,完全没必要一上来就上微服务、分布式事务,一台MySQL加一个Redis已经能顶住绝大部分校园场景。技术栈越少,排障路径越短。
3. 数据库建模:表结构决定选课系统的上限
3.1 核心表清单与职责划分
数据库模型是选课系统最需要反复推敲的部分,表结构设计不合理,后面改起来极其痛苦。我最终落地的核心表如下:
| 表名 | 职责 |
|---|---|
| sys_user | 统一账号,学生教师都在这张表 |
| student | 学生扩展信息,关联 sys_user |
| teacher | 教师扩展信息,关联 sys_user |
| course | 课程基础信息:课程编号、名称、学分、性质 |
| course_offering | 开课教学班:学期、教师、时间地点、容量、已选数 |
| course_schedule | 教学班的具体上课节次(星期几、节次区间、周次) |
| selection_record | 选课记录:学生、教学班、状态、选课批次 |
| waiting_queue | 候补队列:学生、教学班、排位时间 |
| election_session | 选课批次:批次名、开放时间、截止时间、模式 |
这里有个容易被忽视的命名问题:选课记录表我取名 selection_record 而不是 course_selection,因为后面还涉及选课批次、候补队列,名字里带 course 范围太窄。新同事看表结构时,通过名字就能判断这张表属于哪个业务域,这种隐性成本很值得省。
3.2 容易踩坑的字段与索引设计
课程表与开课表拆分是必须的。一门课在不同学期由不同教师授课、安排在不同时间段、设置不同容量,所以“学期、教师、时间、地点、容量”属于开课表,不属于课程表。第一次做的时候很容易把字段全部冗余在课程表里,结果课程表每学期都要被更新一遍,历史记录全乱掉。
上课时间段的存储尤其要注意,千万不要用字符串 “周一 1-2节” 这种格式。否则做时间冲突检测时,你得写一堆字符串解析逻辑,既慢又容易出错。正确的做法是拆成结构化的行:week_day(星期几)、start_section(起始节次)、end_section(结束节次),一门课每周多节课就存多行,冲突检测直接用范围查询。
容量与已选人数是两个核心冗余字段。capacity 是开课表原始容量,selected_count 是当前已选人数。selected_count 不是为了省一次查询,而是为了在选课时做原子的行级条件更新。索引方面,选课记录表必须加唯一索引 (student_id, offering_id),这是防重复选课的最后一道防线——代码里判断存在与否存在并发窗口,数据库约束才是真正的兜底。
3.3 选课核心表建表示例
选课记录表我会给出这样一个参考 DDL:
sql复制CREATE TABLE selection_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
student_id BIGINT NOT NULL COMMENT '学生ID',
offering_id BIGINT NOT NULL COMMENT '教学班ID',
session_id BIGINT NOT NULL COMMENT '选课批次ID',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1已选 2退课 3候补转正',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_student_offering (student_id, offering_id),
KEY idx_offering_status (offering_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选课记录表';
注意唯一索引的位置:同一学生同一教学班只能有一条有效记录。“有效”这个语义是用 status 字段表达的,但唯一索引只能约束物理行唯一,所以退课时我用的是软更新(把 status 改为2,代表退课),而不是物理删除。这样既能保留历史轨迹,又不会因为退课后再选课而被唯一索引卡住。
开课表里最关键的一行更新语句对应的表结构是:capacity INT、selected_count INT,两者都不允许为负数。数据库层再加一个 CHECK 约束(MySQL 8.0.16 以上的版本支持)可以进一步增强安全性,避免程序逻辑出错时出现负数名额。
3.4 时间冲突检测的建模取舍
做时间冲突检测时,课程安排表需要包含周次信息。一门课可能第1到16周每周都上,也可能只在单周或双周上。完整支持“周次交集判断”非常麻烦,我第一版只按“星期 + 节次”维度做冲突检测,也就是不管单双周,同一时段有课就算冲突。这个简化覆盖了绝大多数课程编排场景,真正用到的边界情况很少。如果业务上必须支持单双周,就需要额外存 week_type 字段(全部/单周/双周),查询时联合判断,复杂度会上一个台阶。
4. 核心业务链路:从课程发布到选课落库
4.1 管理员维护课程与开课的流程
教务管理员的工作流分两个入口。课程基础信息走课程管理,学期开始前批量导入 Excel;开课安排走开课管理,选定课程、指定教师、设置时间和容量。
这一步涉及两个常见问题。第一是级联删除:删除一门课程时,如果已经存在选课记录,到底是删掉课程还是保留历史?我的处理是课程目录永不物理删除,只做下架状态标记;删除开课教学班时,必须检查是否已有学生选课,有记录就只能“取消开课”,不能删行。第二是批次配置:每个学期要根据校历设置预选、正选、补退选三个批次,每个批次有自己的开放时间和截止时间。批次状态用定时任务自动流转,但是务必要留一个人工干预入口,因为开学时间调整、系统临时维护这类情况随时可能发生。
4.2 学生选课主流程:哪些步骤不可省略
学生提交选课请求后,服务端的核心方法应该按固定顺序执行以下步骤:
- 校验选课批次是否处于开放窗口内
- 从安全上下文中取当前登录用户,确认学生身份
- 校验基本规则:是否重复选课、是否已达学分上限、是否满足先修条件
- 校验时间冲突:与已选课程比对星期和节次
- 原子扣减教学班容量
- 插入选课记录(状态为已选)
- 返回选课结果
第5步是整个流程里最关键的。扣减容量的 SQL 长这样:
java复制// Mapper接口
int decreaseRemain(@Param("offeringId") Long offeringId);
// 对应SQL
// UPDATE course_offering
// SET selected_count = selected_count + 1
// WHERE id = #{offeringId} AND selected_count < capacity
这里的关键点是:把“容量是否已满”的判断直接放进 UPDATE 语句的 WHERE 条件里。执行 UPDATE 后,MySQL 的 InnoDB 会对这一行加行锁,返回值是影响的行数。返回 0 就说明容量已满,返回 1 说明扣减成功。这个原子操作既完成了容量判断,又完成了扣减,比先 SELECT 再 UPDATE 安全得多。
4.3 并发扣减容量的锁顺序
在整个选课事务中,我刻意让“扣减容量”这个 UPDATE 成为事务内对课程行的第一次加锁动作。这样做的目的是:让所有并发选课请求在课程行锁上排队,后续的时间冲突校验、插入选课记录都在容量扣减成功之后执行,天然串行化。
有人会问,事务一开始不先查一下课程信息吗?查一下也不影响,但要注意查询默认是快照读,不加行锁。真正决定吞吐量和正确性的是第一个加锁操作的位置。如果把课程信息的普通查询放在事务最前面,两个并发事务可能同时读到容量还有余,然后在后面 UPDATE 时竞争,虽然最终 UPDATE 条件也能兜住不超卖,但多出来的是无效的重试和更长的锁等待时间。
事务边界也要控制好:事务内只保留数据库操作,把缓存查询、发送通知、调用外部接口这类耗时操作全部挪到事务外面。我在项目里见过连接池被打满的现场,根源就是事务里嵌了一个耗时的 HTTP 通知调用,数据库连接被长时间占用不释放。
4.4 退课与候补进位的原子性
退课流程和选课一样需要事务:把选课记录状态改为“退课”,课程容量减一,然后扫描候补表中排在最前面的学生,尝试为其补选。
这里最容易出现的问题叫“退课后空出的名额没有被候补利用”。解决方式是把退课和候补转正放到同一个事务里:退课→名额+1→取出候补第一名→重复选课的容量扣减逻辑→把候补状态改为已选。如果转正的学生碰巧此时又有时间冲突(他可能已经选了另一门课),则跳过这条候补记录,继续看下一条,直到没有可用候补或者候补队列为空。
候补队列第一版用数据库表实现即可,按 student_id、offering_id、created_at 排序,取最早的一条。等并发量大到需要更快的排队结构时,再考虑 Redis 的 ZSet。对一个选课系统来说,数据库表实现的候补队列在常见规模下完全够用,提前上 Redis 只会增加维护成本。
5. 权限模型与接口安全:防的就是手动改请求的行为
5.1 三种角色与 Spring Security 的整合
基于 Spring Security + JWT 是常见组合:用户登录成功后拿到包含用户ID和角色列表的令牌,后续请求在 header 里带上令牌,过滤器解析后把用户信息放到 SecurityContext 中。
角色区分用接口注解来做:
java复制@PostMapping("/selection")
@PreAuthorize("hasRole('student')")
public Result createSelection(@RequestBody SelectionRequest req) {
// 处理选课逻辑
}
@PreAuthorize 会把角色校验交给 Spring Security 的决策器,比自己在代码里写 if(user.getRole().equals("student")) 更干净,也更方便扩展。角色命名统一用 ROLE_student、ROLE_teacher、ROLE_admin。
还有个容易被忽略的点:JWT 是无状态的,如果你需要封禁某用户或者强制他下线,单靠 JWT 做不到。我在系统里用 Redis 存了一份令牌白名单,登录成功后写入,每次请求校验白名单是否存在;用户修改密码或管理员封禁账号时删除该令牌。这样既保留了 JWT 的好处,又获得了可控的登出能力。
5.2 越权防范:服务端不信任前端任何 ID
水平越权是这类系统的经典漏洞。攻击场景很简单:某A同学抓包看到选课接口的请求体是 {"studentId": 123, "offeringId": 456},于是把 studentId 改成另一个人的学号,直接替人选课。
这个坑我见过不止一次,解决办法也很明确:选课接口里 studentId 根本不接收前端传参,服务端从 SecurityContextHolder 里取当前登录用户对应的学生ID。请求体只保留 offeringId。这样无论前端怎么改参数,服务端永远操作的是“当前登录用户”的数据。
查询接口同理,学生查自己的选课列表时,接口不带学生ID参数,服务端直接根据登录态过滤。写接口的时候要形成一种条件反射:凡是用到用户身份的,一律从上下文中取,而不是从请求参数里取。
5.3 幂等与限流:拦下重复点击和刷接口
前端防抖只能防手滑,拦不住脚本。同一学生同一门课连点了十次,第一次成功后,后续并发进来的请求怎么办?大部分时候,唯一索引 (student_id, offering_id) 会直接拒绝物理上的重复插入,数据库会抛 DuplicateKeyException。服务端要做的是捕获这个异常,把它转成友好提示“你已经选过这门课了”,而不是返回一个500。
高峰期在选课接口上做限流也很必要。我基于 Redis 做了一个简单的固定窗口限流,对单个学生限制为每分钟最多20次选课请求,防止某个脚本跑起来把后端资源打满。对正常学生来说,20次/分钟的操作余量完全足够,但如果是脚本在刷,这个限制能显著降低系统压力。
6. 高并发选课方案:本质上是一次限时抢购
6.1 并发选课的设计目标与分级
正选开放时间一到,几十个教学班的名额可能在几秒内被抢完,这个流量特征和电商秒杀几乎一致。这种情况下,设计目标要分优先级:第一不超卖,数据不能被破坏;第二快速返回,学生点击后几秒内要知道结果;第三高可用,系统不能整个挂掉。
我评估过校园场景常见的并发量:一个年级几千人同时选课,几百个教学班,瞬时 QPS 大约在几百到一千这个量级。这个量级下,既不需要分库分表,也不需要微服务,但确确实实需要合理的缓存和异步策略,以及一个能兜底的数据库原子操作。
6.2 三种主流实现方案对比
针对“抢名额”这个动作,我整理三种实现方案:
| 方案 | 核心思路 | 适用规模 | 复杂度 |
|---|---|---|---|
| A:纯数据库原子更新 | UPDATE 条件判断容量,事务内完成一切 | QPS 几百以内 | 低 |
| B:Redis Lua 预扣减 + 异步落库 | Redis 原子扣减,MQ/任务表异步写库 | QPS 数千 | 高 |
| C:消息表 + 定时补偿 | 事务中写本地事件表,定时扫描执行 | 中小规模无MQ环境 | 中 |
方案A适合绝大多数高校校内选课,代码量最小,维护成本最低。它的问题是在数据库行锁上排队,容量扣减串行化处理,QPS 超过一定阈值后连接池容易撑不住。方案B能扛更大流量,但需要处理Redis与数据库的一致性补偿,复杂度上一大截。方案C是方案B的一种简化替代:没有现成消息队列时,用数据库本地事件表暂存异步任务,由定时任务轮询执行,避免额外部署中间件。
6.3 Redis Lua 预扣减的补偿与对账
如果你想用方案B,选课请求到达时,先在 Redis 中对课程名额做原子扣减,扣减成功后再把“选课事件”写入消息队列或本地事件表,由异步消费者真正落库。
Redis 扣减的核心脚本如下:
lua复制local remain = tonumber(redis.call('HGET', KEYS[1], 'remain'))
if remain == nil or remain <= 0 then
return -1
end
redis.call('HINCRBY', KEYS[1], 'remain', -1)
return remain - 1
这段脚本在 Redis 中原子执行,判断和扣减不分离,不会出现两个并发请求都读到剩余名额为1的情况。Java 代码里通过 Spring Data Redis 的 DefaultRedisScript 调用这段 Lua,返回值小于0就说明名额已满,立刻返回失败;大于等于0则继续走异步落库流程。
异步写库失败会把Redis里已经扣掉的名额留在那里,形成“幽灵名额”。所以必须设计补偿:写库失败时发一条回补消息,把 Redis 中的名额加回来。同时需要一个定时对账任务,定期比对 Redis 剩余名额和数据库真实已选人数的差值,差值超过阈值就告警。这个对账任务不一定要很频繁,每5分钟跑一次,在不影响性能的前提下把两个存储之间的偏差控制在可接受范围。
6.4 容量展示与真实扣减的取舍
学生端页面显示“剩余名额”时,我一直坚持用缓存里的近似值,而不是每次刷新都去查数据库。选课页面的活跃用户太多,如果每个人都实时读数据库课程行的容量字段,压力会集中在同一批热点数据上。
页面展示层允许有几秒的延迟,学生真正提交请求时,服务端扣减的永远是基于 Redis 或数据库行的原子值。这样用户的感知是“页面显示还剩20个名额,我点提交的时候告诉我已满”,虽然有点遗憾,但数据一致性没有被破坏。这个体验上的小瑕疵,要比超卖导致的后续调课纠纷轻太多。
7. 选课系统开发中踩过的坑与排查链路
7.1 死锁:锁顺序不一致导致的事务互相等待
第一次压测时,选课接口时不时抛 MySQL 错误1213死锁。现象是:事务A先 UPDATE 课程容量行,再 INSERT 选课记录;事务B先 INSERT 选课记录(可能来自候补转正路径),再 UPDATE 课程容量行。两个事务获取锁的顺序不一致,在并发环境下就死锁了。
排查方法很简单,打开 MySQL 的锁日志和死锁检测,观察死锁发生时两个事务各自持有和等待的锁。修复方式就是统一所有路径上的加锁顺序:先更新课程容量行,再插入或更新选课记录。候补转正、退课回滚、管理员手动补选,所有入口都必须遵守同样的顺序。这个坑靠经验能躲过,但最好在设计阶段就定下来,代码评审时专门检查。
7.2 事务悄悄失效:自调用与吞异常
某次测试发现,选课方法里故意抛了一个业务异常,数据却没有回滚,课程容量和选课记录都留下了。排查下来是两个原因叠加:第一个是同一个 Service 类里方法自调用,走了 this.method() 而不是 Spring 代理对象,@Transactional 注解完全没生效;第二个是方法内 try-catch 捕获了异常但没有重新抛出,事务管理器根本不知道有异常发生。
解决方案有两类:把需要事务的方法拆到独立 Service 中调用,通过 Spring 容器代理让切面生效;或者在同一类中通过 AopContext.currentProxy() 获取代理对象再调用。同时,事务方法内部不要自己吞异常,要么不捕获,要么捕获后重新抛出 RuntimeException。另有项目还遇到过仅声明 @Transactional 但没指定 rollbackFor 的情况,默认只回滚 RuntimeException,检查异常如果不显式声明,一样不会回滚。
7.3 连接池被打满:锁等待拖垮了线程
压测到100并发时,接口突然大面积超时,日志里出现 HikariPool-1 Exception is slow operation. pool is full. 这类报错。顺着线程栈看下去,大量线程阻塞在同一个 SQL 的锁等待上,每个线程都持有一个数据库连接,等前面的连接释放锁,连接池就这样被耗尽。
这个问题的根源是:事务内部做了耗时的缓存检查或外部通知,连接长时间被事务持有;并发请求在课程行锁上又互相排队,队列越长,连接释放越慢。解决的组合拳是:事务内只保留数据库操作,把缓存查询、消息通知挪到事务外;给数据库配 innodb_lock_wait_timeout=10,让锁等待超过10秒的请求快速失败而不是无限挂着;HikariCP 的 maximumPoolSize 设置成20就够,不需要跟着并发数盲目调大,连接池过大反而会在锁等待时放大积压。
7.4 超卖:把容量判断放进了代码里
这是最经典也最容易犯的错。代码逻辑是先 SELECT 判断容量是否大于0,再 INSERT 选课记录,最后 UPDATE 容量减一。两个并发请求同时读到容量还剩1,都判断可以选,然后都插入成功,课程就超卖了。
修复方法前面已经反复强调过:容量判断和扣减必须合并在同一条 UPDATE 语句中完成,WHERE 条件里带 selected_count < capacity,再配合唯一索引,从机制上杜绝超卖。这个坑之所以反复出现,是因为人下单意识里总觉得 SELECT 完再做判断是安全的,但在并发环境下,SELECT 读到的数据在一瞬间之后就可能是旧的了。只靠事务隔离级别也救不了,因为默认的 REPEATABLE READ 下快照读之间是互不影响的。
最后说点个人体会。做完这套系统,我最深刻的感受是:业务规则是写在纸面上时觉得很简单的逻辑,一旦放进并发场景就会被无限放大。时间冲突、容量判断、唯一索引这些设计,每一个单独看都不复杂,但它们叠加在一起时,任何一个环节的疏忽最终都会变成选课高峰期的线上事故。
如果你也在做 Spring Boot 选课系统,或者类似的资源抢占型项目,我建议把一半以上的设计精力放在数据库约束和并发控制上,而不是纠结用什么框架、要不要上微服务。先把唯一索引、原子扣减、锁顺序这些地基打扎实,再把候补队列、异步补偿、对账任务逐步加进来。给压测和线上问题排查留足时间,这往往比写业务代码更能看出一个项目真实水平。
