CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
代码审查入门/课时/实现语义
RawBlame
课时目录12 节

代码审查入门

  1. 01API 语义审查
  2. 02实现语义
  3. 03文档审查
  4. 04测试审查
  5. 05代码风格
  6. 06Review 流程
  7. 07审查清单
  8. 08安全审查
  9. 09AI 辅助审查
  10. 10反馈技巧
  11. 11团队实践
  12. 12综合练习

实现语义

逻辑与边界条件。

30 分钟

实现语义

这一课你将能做到

  1. 审查 Service 层代码时识别出空指针、边界条件、并发竞态三类最常见的逻辑缺陷
  2. 判断事务、缓存、消息队列的使用是否与业务语义匹配
  3. 用「如果输入是 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);
}

讲清楚要点

  1. 实现语义 = 代码行为与业务意图的匹配度:命名、注释、文档说「扣减库存」,代码就必须原子地扣减,而不是「先查再减」。
  2. 所有外部返回值都不可信:DB 查询、RPC、缓存、文件——任何一个都可能返回 null 或抛异常。审查时逐行问「这里失败会怎样」。
  3. check-then-act 必有竞态:先查再改的两步操作,在并发下一定有问题。要么数据库原子操作(条件 UPDATE / 唯一索引),要么分布式锁,要么队列串行化。
  4. 事务里只做 DB 操作:任何网络调用(HTTP/MQ/ES/Redis Cluster 跨节点)都不该放进数据库事务。
  5. 异常要么处理要么抛,没有第三条路:吞异常等于埋雷;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) 不判空IndexOutOfBoundsCollectionUtils.isEmpty 先判
先 count 再 insert 防超卖check-then-act 竞态条件 UPDATE / 唯一索引
事务里调 MQ事务超长、消息先于提交发出Outbox / 事务消息
catch (Exception e) { log.error("fail"); }堆栈丢失log.error("...", e) 带堆栈
缓存只加不删数据永远不一致更新 DB 后主动 evict + 合理 TTL

卡点急救

  1. 怀疑有并发问题但本地复现不了:写一个 CountDownLatch 起 50 个线程同时调同一接口,数据库开 general log 看 SQL 顺序;或用 JMeter 压测。
  2. 事务边界看不懂:在方法入口和 exit 各打一行日志,带上 TransactionSynchronizationManager.isActualTransactionActive(),跑一遍看哪些方法实际在事务里。
  3. 缓存数据和 DB 不一致找不到原因:开启 Redis Monitor 或慢查询日志,看缓存删除/写入的时序;重点查「先删缓存再更新 DB」这种错误顺序。

面试常问

  • 怎么保证缓存和数据库的一致性? Cache-Aside 标准答案:读 miss 时查 DB 写缓存;写时先更新 DB 再删缓存。要接受短暂不一致(最终一致)。强一致场景用分布式锁或 Canal 订阅 binlog 同步,但代价是复杂度。面试要说出「为什么这么选」和「妥协在哪」。

  • @Transactional 什么情况下会失效? 自调用、private/final 方法、异常被 catch 吞掉、rollbackFor 没指定受检异常、数据库引擎不支持、未被 Spring 管理。每一点都配一个场景例子。

  • 秒杀超卖怎么防? 三层防线:前端按钮防抖;Redis 原子预扣(DECR 或 Lua 脚本)挡大部分流量;数据库条件 UPDATE WHERE stock > 0 兜底。只靠任何一层都不够。

自测三问

  1. 为什么「先查库存再下单」的写法在并发下会超卖?正确姿势是什么?
  2. 事务里为什么不能调外部 HTTP 接口?
  3. catch (Exception e) { log.error("失败"); } 这一行丢了什么关键信息?

小结一句话

实现语义审查的本质是对每行代码问「失败了呢、并发了呢、边界值呢」,用怀疑一切的态度验证代码和业务意图是否一致。

延伸

  • 《Effective Java》第 3 版(Joshua Bloch)第 11 章并发
  • Spring Transaction 官方文档
  • 下一课:文档审查——README、注释与变更说明。
加载本节练习…
← 上一课API 语义审查下一课 →文档审查