实现语义
这一课你将能做到
- 审查 Service 层代码时识别出空指针、边界条件、并发竞态三类最常见的逻辑缺陷
- 判断事务、缓存、消息队列的使用是否与业务语义匹配
- 用「如果输入是 X 会怎样」的提问法,系统性暴露实现漏洞
为什么要学
代码能编译、测试能跑过,不代表逻辑对。实现语义审查关注的是:这段代码做的事情,和它应该做的事情,是不是同一件事。
人话:API 审查看「合同写没写对」,实现审查看「干活干没干对」。一个方法叫 transferMoney,结果没加锁、没校验余额、没记流水——编译过、单测过(如果单测也是糊弄的话)、上线就出钱灾。
先建立图像
典型的「看起来对、其实错」的实现模式:
┌──────────────────────────────────────────┐
│ 读缓存 → 缓存没有 → 查数据库 → 写缓存 │
│ │
│ 看着很标准?想想这几个问题: │
│ 1. 数据库里也没有怎么办(缓存穿透) │
│ 2. 两个人同时查同一个 key 都 miss 会怎样 │
│ 3. 数据库更新后缓存多久失效 │
│ 4. 缓存挂了是直接打 DB 还是降级 │
└──────────────────────────────────────────┘
实现语义审查的本质:对每一行代码问「然后呢」「如果失败呢」「如果并发呢」「如果是边界值呢」。
跟着做
步骤 1:审空值与边界
❌ 反例(本站课程模块常见的坏味道):
public CourseDetailVO getDetail(Long courseId) {
Course course = courseMapper.selectById(courseId);
User author = userMapper.selectById(course.getAuthorId()); // course 可能是 null
List<Chapter> chapters = chapterMapper.listByCourse(courseId);
return CourseDetailVO.builder()
.title(course.getTitle())
.authorName(author.getNickname()) // author 可能是 null
.chapterCount(chapters.size()) // chapters 可能是 null
.build();
}
✅ 正例:
public CourseDetailVO getDetail(Long courseId) {
Assert.notNull(courseId, "courseId 不能为空");
Course course = courseMapper.selectById(courseId);
if (course == null) {
throw new BizException(ErrorCode.COURSE_NOT_FOUND);
}
User author = userMapper.selectById(course.getAuthorId());
String authorName = author != null ? author.getNickname() : "已注销用户";
List<Chapter> chapters = chapterMapper.listByCourse(courseId);
int chapterCount = chapters == null ? 0 : chapters.size();
return CourseDetailVO.builder()
.title(course.getTitle())
.authorName(authorName)
.chapterCount(chapterCount)
.build();
}
审查动作:对每个外部依赖返回值(DB、RPC、缓存)问「能不能是 null?是 null 时业务应该报错还是降级?」
步骤 2:审并发与竞态
❌ 反例:经典 check-then-act 竞态
public void enroll(Long courseId, Long userId) {
Integer count = enrollMapper.countByCourse(courseId);
if (count >= MAX_CAPACITY) {
throw new BizException("名额已满");
}
// 两个请求同时通过校验 → 超卖
enrollMapper.insert(new Enroll(courseId, userId));
}
✅ 正例:数据库唯一约束兜底 + Redis 预扣
public void enroll(Long courseId, Long userId) {
String lockKey = "enroll:lock:" + courseId + ":" + userId;
// 1. Redis 防重(同一用户重复点击)
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(acquired)) {
throw new BizException("请勿重复提交");
}
try {
// 2. 数据库唯一索引(course_id, user_id)防重复报名
// 3. 用带条件的 UPDATE 原子扣减名额
int rows = courseMapper.decreaseQuota(courseId);
if (rows == 0) {
throw new BizException("名额已满");
}
enrollMapper.insert(new Enroll(courseId, userId));
} finally {
redisTemplate.delete(lockKey);
}
}
-- decreaseQuota 对应的 SQL,原子操作
UPDATE course SET quota = quota - 1
WHERE id = #{courseId} AND quota > 0
步骤 3:审事务边界
@Transactional
public void publishCourse(Long courseId) {
courseMapper.updateStatus(courseId, PUBLISHED);
searchClient.index(courseId); // ES 调用包在事务里
mqProducer.send(new CoursePublishedEvent(courseId)); // MQ 也包在事务里
// ES 或 MQ 抖动 → 事务超长 → 连接耗尽
}
✅ 正例:事务内只写 DB,事务外做外部调用;或者用事务消息 / Outbox 模式
@Transactional
public void publishCourse(Long courseId) {
courseMapper.updateStatus(courseId, PUBLISHED);
outboxMapper.insert(new OutboxEvent("course.published", courseId));
// 事务提交后由定时任务/CDC 把 outbox 事件投递到 MQ
}
审查动作:看 @Transactional 方法里有没有 RPC/HTTP/MQ/ES 等外部调用。有,就是定时炸弹。
步骤 4:审缓存语义
@Cacheable(value = "course", key = "#courseId")
public CourseVO get(Long courseId) { ... }
@CacheEvict(value = "course", key = "#courseId")
public void update(Long courseId, CourseUpdateReq req) {
courseMapper.update(...);
// 问题:update 失败时缓存已经被清了?
// 实际:CacheEvict 默认在方法成功后才清,OK
// 但真正的问题是:更新 DB 成功 → 清缓存 → 另一线程在清缓存前读了旧缓存?
}
审查点:Cache-Aside 的「先更新 DB 再删缓存」顺序对不对;有没有考虑延迟双删;缓存 TTL 和业务容忍度是否匹配。
步骤 5:审异常吞噬
try {
courseService.publish(id);
} catch (Exception e) {
log.error("发布失败"); // 堆栈丢了,还假装没事继续走
}
return Result.ok(); // 前端看到成功
✅ 正例:要么处理,要么抛给全局异常处理器,日志必须带堆栈和上下文
try {
courseService.publish(id);
} catch (BizException e) {
throw e; // 业务异常直接抛
} catch (Exception e) {
log.error("课程发布失败 courseId={}", id, e); // 带参数 + 完整堆栈
throw new SystemException("发布失败,请稍后重试", e);
}
讲清楚要点
- 实现语义 = 代码行为与业务意图的匹配度:命名、注释、文档说「扣减库存」,代码就必须原子地扣减,而不是「先查再减」。
- 所有外部返回值都不可信:DB 查询、RPC、缓存、文件——任何一个都可能返回 null 或抛异常。审查时逐行问「这里失败会怎样」。
- check-then-act 必有竞态:先查再改的两步操作,在并发下一定有问题。要么数据库原子操作(条件 UPDATE / 唯一索引),要么分布式锁,要么队列串行化。
- 事务里只做 DB 操作:任何网络调用(HTTP/MQ/ES/Redis Cluster 跨节点)都不该放进数据库事务。
- 异常要么处理要么抛,没有第三条路:吞异常等于埋雷;log 不带堆栈等于白 log。
你可能会懵的地方
-
怎么判断该不该加分布式锁?
先问自己能不能用数据库原子操作解决(条件 UPDATE、唯一索引、乐观锁 version)。能,就别上锁。分布式锁是最后手段,因为引入锁就引入死锁、锁续期、锁误删一堆新问题。
-
缓存穿透/击穿/雪崩,审查时怎么区分?
穿透 = 查不存在的数据(用布隆过滤器或缓存空值解决);击穿 = 热点 key 过期瞬间被打爆(用互斥重建或逻辑过期);雪崩 = 大量 key 同时过期(TTL 加随机抖动)。审查时看到缓存代码就过一遍这三问。
-
@Transactional 失效有哪些常见场景?
自调用(同类内部方法互调)、private 方法、异常被 catch 吞了、数据库引擎不支持(MyISAM)、多数据源没指定 transactionManager。审查时看到事务先检查这几点。
-
日志该打多少?
关键路径的入口、出口、异常分支必打;循环里不打;敏感字段(密码、身份证、完整手机号)脱敏;用参数化日志 log.info("x={}", x) 而不是字符串拼接。
-
** reviewer 怎么验证 reviewer 自己没想到的边界?**
用「极端值清单」:null、空集合、空字符串、0、-1、Integer.MAX_VALUE、并发 100 线程、网络超时 30s、磁盘满。对着这份清单问代码「你能扛住吗」。
错法对照
| 错法 | 问题 | 正解 |
|---|
list.get(0) 不判空 | IndexOutOfBounds | CollectionUtils.isEmpty 先判 |
| 先 count 再 insert 防超卖 | check-then-act 竞态 | 条件 UPDATE / 唯一索引 |
| 事务里调 MQ | 事务超长、消息先于提交发出 | Outbox / 事务消息 |
catch (Exception e) { log.error("fail"); } | 堆栈丢失 | log.error("...", e) 带堆栈 |
| 缓存只加不删 | 数据永远不一致 | 更新 DB 后主动 evict + 合理 TTL |
卡点急救
- 怀疑有并发问题但本地复现不了:写一个 CountDownLatch 起 50 个线程同时调同一接口,数据库开 general log 看 SQL 顺序;或用 JMeter 压测。
- 事务边界看不懂:在方法入口和 exit 各打一行日志,带上
TransactionSynchronizationManager.isActualTransactionActive(),跑一遍看哪些方法实际在事务里。
- 缓存数据和 DB 不一致找不到原因:开启 Redis Monitor 或慢查询日志,看缓存删除/写入的时序;重点查「先删缓存再更新 DB」这种错误顺序。
面试常问
-
怎么保证缓存和数据库的一致性?
Cache-Aside 标准答案:读 miss 时查 DB 写缓存;写时先更新 DB 再删缓存。要接受短暂不一致(最终一致)。强一致场景用分布式锁或 Canal 订阅 binlog 同步,但代价是复杂度。面试要说出「为什么这么选」和「妥协在哪」。
-
@Transactional 什么情况下会失效?
自调用、private/final 方法、异常被 catch 吞掉、rollbackFor 没指定受检异常、数据库引擎不支持、未被 Spring 管理。每一点都配一个场景例子。
-
秒杀超卖怎么防?
三层防线:前端按钮防抖;Redis 原子预扣(DECR 或 Lua 脚本)挡大部分流量;数据库条件 UPDATE WHERE stock > 0 兜底。只靠任何一层都不够。
自测三问
- 为什么「先查库存再下单」的写法在并发下会超卖?正确姿势是什么?
- 事务里为什么不能调外部 HTTP 接口?
catch (Exception e) { log.error("失败"); } 这一行丢了什么关键信息?
小结一句话
实现语义审查的本质是对每行代码问「失败了呢、并发了呢、边界值呢」,用怀疑一切的态度验证代码和业务意图是否一致。
延伸