拿了一个全省第三的奖牌,技术负责人反而失眠,这不算矫情。分数公布后,差距被压缩到了个位数:性能测试低了两分,稳定性场景丢了三分,答辩材料里缺少压测报告和回滚方案,又被扣了两分。回头翻代码,这些问题都有明确出处,不是做不出来,而是没有在交付前用工程手段把它们拦截下来。“一个全省第三的遗憾”翻译成技术语言,就是一次系统性的赛后复盘:名次只是结果,真正要处理的是结果背后的技术差距。
这篇文章围绕一个非常常见的复盘场景展开:项目在省级评选中拿到第三名,但复盘后发现失分点集中在性能、稳定性、代码质量和交付物四个方面。文章会用一套示例的在线报名系统走通完整复盘过程,覆盖压测方法、瓶颈定位、超时与熔断配置、静态检查与单元测试、发布回滚和复盘清单。读完可以直接把同一套分析思路迁移到自己的项目里,即使不是比赛项目,也能用于生产系统的性能排查和发布前检查。
1. 遗憾不是名次问题,而是评分维度里的技术差距
团队或个人的第三名遗憾,往往不是“再做多一点就能第一”的模糊感觉,而是评分表上几项可量化的差距。复盘的第一步,不是写一篇感性的总结,而是把失分点从评分语言翻译成工程语言,再定位到代码、配置、流程和交付物上。
1.1 先搞清楚评审在评什么
在省级技能竞赛、软件设计评选、项目答辩这类场景中,评分一般不会只看“功能能不能跑”。综合多个技术类评审中常见的评分口径,大致可以分成这样几类:
| 评分维度 | 常见评审关注点 | 对应工程证据 |
|---|---|---|
| 功能完整性 | 需求覆盖度、核心流程是否跑通、边界处理是否合理 | 需求清单、接口测试用例、功能演示截图或视频 |
| 性能表现 | 并发能力、响应时间、资源占用 | 压测报告、QPS、TP99、错误率、CPU/内存曲线 |
| 稳定性 | 异常输入、依赖故障、极端流量下系统是否可用 | 异常处理代码、超时与熔断配置、故障演练记录 |
| 代码质量 | 可读性、可维护性、是否存在明显坏味道 | 静态检查报告、单元测试、覆盖率报告 |
| 交付物 | 部署是否顺畅、文档是否完整、团队能否快速接手 | README、部署文档、环境说明、日志规范 |
| 现场答辩 | 讲解是否清楚、问题是否能接住 | 架构图、设计文档、复盘记录 |
这套评分逻辑和真实生产项目的验收逻辑高度一致。评审不会一台机器一台机器检查代码是否漂亮,但会看有没有证据,而证据是否严谨,直接反映了开发团队是否具备工程化交付能力。
1.2 把失分项映射到工程证据
拿到评分反馈后,常见的错误做法是把失分原因写成人人会写的结论,比如“性能优化不够”“代码质量需要提升”“文档偏少”。这些话没有任何执行价值。正确的做法是先建立一张映射表,把每个失分项对应到具体文件和可以验证的动作。
例如:
| 评分反馈 | 可能的技术根因 | 需要检查的证据 |
|---|---|---|
| 并发场景下响应慢 | 慢 SQL、N+1 查询、缓存配置缺失 | 慢查询日志、压测火焰图、SQL 执行计划 |
| 接口偶发 500 | 连接池耗尽、外部接口超时未处理 | 日志中的连接超时关键字、线程栈、监控图表 |
| 故障恢复能力不足 | 没有回滚方案、没有健康检查 | 发布脚本、actuator/health 配置、回滚演练记录 |
| 代码阅读困难 | 包结构混乱、命名不统一、缺少分层 | 代码目录、静态检查告警列表 |
| 文档不够 | README 只有启动命令,缺少架构和部署说明 | 文档目录、部署脚本、接口说明 |
完成映射之后,遗憾就被拆成了一个个可以修复的具体问题。
1.3 复盘使用的示例系统
为了让后面的分析可以落地,这篇文章使用一个示例项目“在线活动报名系统”来说明复盘过程。技术栈为 Spring Boot 3、Spring Data JPA、MySQL 8、Redis 7,压测工具使用 k6。系统包含活动查询、报名接口、报名人数统计、报名记录列表、短信通知触发五个模块。
这里要说明一点:示例项目用于展现复盘思路,不指代任何真实赛事或真实作品。如果你手头项目使用的不是 Spring Boot 或 JPA,分析思路依然适用,只需把工具和配置替换成自己技术栈里对应的一层。
2. 第一类遗憾:性能差距没有被量化
性能失分在整个遗憾里占比通常不低。更可惜的是,很多性能问题在评审前就存在,只是团队没有通过压测提前发现。没有压测基线,优化就无从谈起,因为“快”和“慢”没有数据支撑。
2.1 没有压测基线,优化就无从谈起
复现评审场景的第一步,是准备一个与预期访问量匹配的压测环境。对于在线报名系统,模拟的高峰场景是:活动开放报名后,数千人同时进入页面、提交报名、查询剩余名额。
这里使用 k6 编写一段简单的阶梯加压脚本。阶梯加压的目的是先观察低并发下是否正常,再逐步增加并发,找到系统从正常到劣化的拐点。
import http from 'k6/http'; import { check, sleep } from 'k6'; export const options = { scenarios: { load: { executor: 'ramping-vus', startVUs: 0, stages: [ { duration: '30s', target: 50 }, { duration: '1m', target: 200 }, { duration: '30s', target: 0 }, ], }, }, }; export default function () { const payload = JSON.stringify({ eventId: 1001, userId: __VU, name: '用户' + __VU, phone: '1380000' + (1000 + __VU), email: 'user' + __VU + '@example.com', }); const params = { headers: { 'Content-Type': 'application/json' }, }; const res = http.post('http://localhost:8080/api/registration', payload, params); check(res, { 'status is 200': (r) => r.status === 200, 'response time < 500ms': (r) => r.timings.duration < 500, }); sleep(1); }运行压测命令:
k6 run load-test.js运行前要确认测试数据足够接近真实场景,不能只有十几条数据。至少准备几十万条历史报名记录和几千个活动,否则数据库索引、缓存和磁盘读取都无法真实暴露。
2.2 读压测报告:QPS、错误率、TP99 分别代表什么
k6 结束时会输出一组汇总指标。最关键的是下面几个:
| 指标 | 含义 | 判读思路 |
|---|---|---|
| http_reqs | 总请求数,结合时间估算每秒请求数 | 与预期峰值对比,判断是否达标 |
| http_req_duration | 平均响应时间 | 看均值,但不能只看均值 |
| http_req_duration{...} 的 p(95) / p(99) | 95% 和 99% 请求的响应时间 | TP99 更能反映极端用户感受 |
| http_req_failed | 失败请求比例 | 正常压测应趋近于 0,出现非 0 时需要定位 |
| iterations | 完成的主流程次数 | 判断业务处理能力 |
例如压测输出中出现 p(99) 超过 2000ms、http_req_failed 大于 2%,基本可以判断系统在 200 并发附近已经不健康。
2.3 从压测里发现的典型性能问题:懒加载导致 N+1
一个非常典型的性能瓶颈,是 JPA 懒加载导致的 N+1 查询。报名记录列表接口的逻辑是:先查出某个活动下的所有报名记录,再循环读取每条记录对应的用户名称。第一次查询报名表走一条 SQL,循环读取 100 条用户信息时又产生 100 条 SQL,数据库压力瞬间放大。
最初的 Repository:
public interface RegistrationRepository extends JpaRepository<Registration, Long> { List<Registration> findByEventId(Long eventId); }Service 中的循环读取:
List<Registration> list = registrationRepository.findByEventId(eventId); List<RegistrationVO> result = new ArrayList<>(); for (Registration reg : list) { // 这里触发懒加载,每行记录查询一次 user 表 String userName = reg.getUser().getName(); result.add(new RegistrationVO(reg.getId(), reg.getEventId(), userName, reg.getCreatedAt())); }修复方式是让查询一次性把关联对象查出来。使用@EntityGraph指定需要一起加载的属性:
public interface RegistrationRepository extends JpaRepository<Registration, Long> { @EntityGraph(attributePaths = {"user"}) @Query("select r from Registration r where r.eventId = :eventId") List<Registration> findByEventIdWithUser(@Param("eventId") Long eventId); }修改后,Service 只需调用findByEventIdWithUser,数据库从 101 条 SQL 变成 1 条,接口响应时间通常能下降一个数量级。
2.4 缓存计数的正确姿势:防穿透、防击穿
报名人数的实时统计也是性能高发点。如果每次都执行count(*),高并发下会把数据库打满。常见的优化方式是引入 Redis 缓存,但缓存并不总是安全的。
先看一个“看起来正确”的缓存代码:
public long getRegisteredCount(Long eventId) { String key = "event:registration:count:" + eventId; String cached = stringRedisTemplate.opsForValue().get(key); if (cached != null) { return Long.parseLong(cached); } long count = registrationRepository.countByEventId(eventId); stringRedisTemplate.opsForValue().set(key, String.valueOf(count), Duration.ofMinutes(5)); return count; }这段代码有两个隐患:缓存穿透和缓存击穿。如果某个不存在的eventId被反复查询,每次都会打到数据库;如果缓存刚过期,大量请求同时回源数据库,数据库瞬间被压垮。
针对当前场景,可以用互斥锁避免击穿,同时借助空值缓存应对穿透:
public long getRegisteredCountSafe(Long eventId) { String key = "event:registration:count:" + eventId; String value = stringRedisTemplate.opsForValue().get(key); if (value != null) { return Long.parseLong(value); } String lockKey = "lock:" + key; Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(5)); if (Boolean.TRUE.equals(locked)) { try { value = stringRedisTemplate.opsForValue().get(key); if (value != null) { return Long.parseLong(value); } long count = registrationRepository.countByEventId(eventId); stringRedisTemplate.opsForValue().set(key, String.valueOf(count), Duration.ofMinutes(5)); return count; } finally { stringRedisTemplate.delete(lockKey); } } else { // 拿不到锁的请求先回查数据库,保证功能可用 return registrationRepository.countByEventId(eventId); } }这段代码不复杂,但它体现了生产环境里“缓存要兜底,不能放大故障”的思路。
2.5 压测后的复测要求
性能优化是否有效,不能靠“感觉变快了”来判断,必须用同一套压测脚本、同一份测试数据、同一台压测机复测。复测后要形成一张对比表:
| 指标 | 优化前 | 优化后 | 差异 |
|---|---|---|---|
| 200 并发下 QPS | 120 | 480 | 提升 300% |
| 平均响应时间 | 420ms | 110ms | 下降 73% |
| TP99 | 1900ms | 480ms | 下降 75% |
| 错误率 | 3.2% | 0% | 恢复正常 |
评审看到这样的表格,比看到“我们优化了性能”这句话有说服力得多。团队自己也获得了可以持续迭代的基线数据。
3. 第二类遗憾:稳定性在极端场景下露馅
性能之外,稳定性是另一个容易丢分的环节。很多系统在演示环境里一切正常,但评审会故意问:“如果这个接口突然变慢怎么办?”“如果 Redis 挂了怎么办?”“发布后发现问题怎么回滚?”这些问题答不上来,基本就是稳定性失分。
3.1 连接池超时:偶发 500 的排查链路
一个很常见的现象是:系统平时正常,并发一高就偶发 500,错误日志里出现类似下面的内容:
HikariPool-1 - Connection is not available, request timed out after 3000ms这段日志的意思是应用向数据库连接池请求连接时,等待超过了 3000ms。连接池里所有连接都被占用了,新的请求只能等待。
排查顺序如下:
- 确认是不是连接池配置过小。查看
spring.datasource.hikari.maximum-pool-size的当前值。 - 确认是不是存在慢 SQL 占住连接。开启 MySQL 慢查询日志:
SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1; - 检查是不是存在事务内调用外部接口的情况。事务未提交时,连接一直不释放。
- 通过线程转储观察线程状态。可以用
jstack导出线程栈,看线程阻塞在哪里。
如果确认是连接池配置问题,可以调整 Hikari 参数:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 validation-timeout: 1000 max-lifetime: 1800000参数含义如下:
| 参数 | 作用 | 设置建议 |
|---|---|---|
| maximum-pool-size | 连接池最大连接数 | 不宜过大,默认 10 在多数场景不够,建议从 20 开始压测观察 |
| minimum-idle | 空闲连接数 | 与最大连接数拉开差距,避免空闲连接占用资源 |
| connection-timeout | 获取连接的最大等待时间 | 建议 2000-3000ms,超过即快速失败 |
| max-lifetime | 连接最大存活时间 | 小于数据库 wait_timeout,默认 1800000ms 通常可用 |
连接池不是越大越好。如果数据库连接数上限是 100,应用实例有 5 个,每个配 50 就会直接打满数据库。要先确认数据库max_connections,再做压力测试验证。
3.2 外部依赖不可用时,系统要能降级
在线报名系统需要调用短信服务发送通知。在高并发或第三方服务不稳定时,如果短信接口超时且没有兜底,用户提交报名会一直卡住,甚至拖垮整个应用。
稳定的做法是对外部调用设置超时、重试和熔断。以 Resilience4j 为例,配置一段短信通知的熔断策略:
resilience4j: circuitbreaker: instances: smsNotify: slidingWindowSize: 20 minimumNumberOfCalls: 5 permittedNumberOfCallsInHalfOpenState: 3 automaticTransitionFromOpenToHalfOpenEnabled: true waitDurationInOpenState: 10s failureRateThreshold: 50 recordExceptions: - java.io.IOException timelimiter: instances: smsNotify: timeoutDuration: 2s cancelRunningFuture: true策略含义是:在 20 次调用中,如果有 50% 失败,熔断器打开 10 秒,期间直接返回失败或走降级逻辑,不再继续打爆短信服务。这样既保护了外部服务,也保护了自身线程池。
在业务代码里,短信通知不应该阻塞报名主流程。推荐做法是把通知放入消息队列,或者至少使用 Spring 的@Async异步执行,并配合降级记录:
@Async("smsExecutor") public void sendNotificationAsync(Registration reg) { try { smsClient.send(reg.getPhone(), buildMessage(reg)); } catch (Exception e) { log.warn("send sms failed, registrationId={}, phone={}", reg.getId(), reg.getPhone(), e); // 写入失败列表,后续通过补偿任务重试 } }面试或答辩中,能说清楚“短信失败不会影响报名成功”这一类设计,比背诵多个框架名词更能证明稳定性意识。
3.3 发布后的回滚能力是评审中的隐性失分点
很多项目在评审前一刻做了最后一次“小改动”,结果破坏了核心流程。这类事故的本质是发布过程没有留后路。生产环境的发布至少要有三件套:备份、健康检查、回滚方案。
一种最基础的回滚流程是:
# 1. 备份当前版本 cp /opt/app/app.jar /opt/app/backup/app.jar.$(date +%Y%m%d%H%M%S) # 2. 替换新版本 cp /tmp/new-app.jar /opt/app/app.jar # 3. 重启服务 systemctl restart registration-service # 4. 健康检查,连续 30 次,每 2 秒一次 for i in $(seq 1 30); do if curl -fsS http://localhost:8080/actuator/health; then echo "health check ok" exit 0 fi sleep 2 done # 5. 检查失败则回滚 cp /opt/app/backup/app.jar.$(date +%Y%m%d%H%M%S) /opt/app/app.jar systemctl restart registration-service echo "rolled back"健康检查接口要配置在 Spring Boot 的 actuator 里:
management: endpoints: web: exposure: include: health,info,metrics,prometheus评审时如果能现场演示“发布失败后自动回滚”,稳定性这一项的得分通常会明显好于只在 PPT 里写“我们支持回滚”的项目。
4. 第三类遗憾:代码质量和交付物没有形成证据
第三名遗憾里,还有一类失分不是因为功能弱,而是因为代码质量、测试和交付文档没有形成证据。评审或接手团队看不到你的质量保障体系,自然不敢给出高分。
4.1 评审关心的代码质量不是“看起来很规范”
单纯把类名写得规范、Controller 不分层,并不能证明代码质量。评审更关心的是:你是否用工具强制约束了代码质量,是否在提交前跑过自动化检查,是否还有可复现的质量报告。
常用的证据包括:
- 静态检查报告:SpotBugs、Checkstyle、ESLint 等产生的报告。
- 单元测试报告:JUnit 等产生的测试执行结果。
- 覆盖率报告:JaCoCo 等产生的行覆盖率和分支覆盖率。
- 代码评审记录:Merge Request 或 Pull Request 的评审记录。
4.2 用 SpotBugs 和 JaCoCo 把质量门槛自动化
以 Maven 项目为例,可以在pom.xml中同时配置 SpotBugs 和 JaCoCo,让质量检查成为mvn verify的一部分。
SpotBugs 配置:
<plugin> <groupId>com.github.spotbugs</groupId> <artifactId>spotbugs-maven-plugin</artifactId> <version>4.8.6.0</version> <configuration> <effort>Max</effort> <threshold>Low</threshold> <failOnError>true</failOnError> </configuration> </plugin>JaCoCo 配置:
<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.12</version> <configuration> <excludes> <exclude>**/dto/**</exclude> <exclude>**/entity/**</exclude> </excludes> </configuration> <executions> <execution> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>report</id> <phase>test</phase> <goals> <goal>report</goal> </goals> </execution> <execution> <id>check</id> <phase>verify</phase> <goals> <goal>check</goal> </goals> <configuration> <rules> <rule> <element>BUNDLE</element> <limits> <limit> <counter>LINE</counter> <value>COVEREDRATIO</value> <minimum>0.70</minimum> </limit> </limits> </rule> </rules> </configuration> </execution> </executions> </plugin>配置完成后,运行:
mvn clean verify如果测试覆盖率低于 70%,构建会失败。评审时直接把target/site/jacoco/index.html的报告截图放进答辩材料,代码质量就不再是空话。
4.3 日志、README、部署文档是评审最容易翻看的部分
评审或下一个维护者最先打开的文件通常是 README 和日志。如果 README 里只有“如何启动”,没有架构说明、配置说明、接口说明和部署方式,交付物得分就会被拉低。
一份合格 README 至少包含:
| 章节 | 内容 |
|---|---|
| 项目简介 | 解决什么问题,核心模块有哪些 |
| 技术栈 | 语言、框架、数据库、缓存、中间件版本 |
| 快速启动 | 环境要求、依赖服务、启动命令、默认端口 |
| 配置说明 | 关键配置项、环境变量、配置文件位置 |
| 接口文档 | 主要接口、请求响应示例、错误码 |
| 部署说明 | 打包方式、部署目录、健康检查、回滚方法 |
| 常见问题 | 端口冲突、数据库连接失败、缓存连接失败等 |
日志方面,推荐使用结构化日志。JSON 格式的日志便于采集到日志平台,也便于按字段检索:
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> <customFields>{"app":"registration-service","env":"dev"}</customFields> </encoder> </appender>日志中要保留 traceId,便于串联一次请求的完整链路。在网关或过滤器初始化 traceId,并使用 MDC 传递,排查问题时效率会高很多。
5. 通用复盘模板:把“遗憾”变成下一轮迭代清单
复盘不能只停留在口头分析,最后一定要落到清单和计划。没有输出物的复盘,几天后就会被遗忘,下一次项目又会出现同样的遗憾。
5.1 一套可复用的项目复盘清单
下面这份清单可以直接复制到团队文档中,填完即为本次复盘的交付物:
| 复盘项 | 证据来源 | 技术根因 | 改进行动 | 验证方式 | 责任人 | 是否完成 |
|---|---|---|---|---|---|---|
| 性能不达标 | k6 压测报告 | 懒加载 N+1 | 使用 EntityGraph 批量加载 | 复测 TP99 下降 | 张三 | 是 |
| 偶发 500 | 错误日志 | 连接池耗尽 | 调整 Hikari 参数 | 压测 300 并发无 500 | 李四 | 是 |
| 短信接口拖慢报名 | 线上监控 | 外部调用无超时熔断 | 配置 Resilience4j | 模拟短信超时验证降级 | 王五 | 是 |
| 发布无回滚 | 部署文档缺失 | 缺少健康检查和备份 | 补充发布脚本 | 演练一次失败回滚 | 赵六 | 是 |
| 代码质量无证据 | SpotBugs/Jacoco 报告 | 未接入自动化检查 | 配置 Maven 插件 | CI 中执行 verify | 张三 | 是 |
每一条都必须能回答“怎么证明修好了”,而不是“应该修好了”。
5.2 三个月的改进节奏建议
复盘结束后,建议把改进项排进正常的迭代计划,而不是攒到评审前集中补。一个可执行的三阶段节奏如下:
- 第 1 个月:建立基线。先解决性能问题、连接池问题、缓存穿透击穿问题,形成第一份压测报告和稳定性报告。
- 第 2 个月:加固稳定性。配置超时与熔断,补充发布回滚脚本,进行一次故障演练。
- 第 3 个月:形成质量门槛。接入静态检查、单元测试覆盖率检查、结构化日志,更新 README 和部署文档。
三个阶段完成后,再跑一次同样的压测场景,基本能拿到一份比之前好看很多的对比数据。
5.3 每次交付前要过的“防遗憾检查”
把复盘结论提炼成一份发布前检查清单,每次交付前逐项确认:
- 是否跑过压测,并记录 QPS、TP99、错误率?
- 是否检查过慢 SQL 和 N+1 查询?
- 是否设置了数据库连接池、外部接口超时和熔断?
- 是否验证过 Redis 不可用时的降级逻辑?
- 是否做过一次发布回滚演练?
- 是否执行了静态检查和单元测试覆盖率检查?
- 是否更新了 README、部署文档、接口文档?
- 是否在日志中保留了 traceId,方便线上排查?
这份清单不需要复杂工具,哪怕放在团队文档里也能减少重复踩坑。
6. 最后一件事:让遗憾成为团队改进指令
“一个全省第三的遗憾”之所以有复盘价值,是因为它证明了团队已经具备完成任务的能力,差的只是工程化兜底。性能有没有量化基线,连接池超时有没有处理,外部依赖挂了能不能降级,发布失败能不能回滚,代码质量有没有自动化证据,这些点不会决定一个项目从无到有,但会决定它在高强度评审、高并发场景和突发故障面前能走多远。
下一次再做类似项目时,不要等到结果出来再复盘。从演示环境、压测到发布流程,都应该按这篇博客里的检查清单提前跑一遍。把遗憾留在这一次复盘里,把改进动作带进下一个迭代版本,第三名才会真正成为一个转折点,而不是一句口头上的可惜。