Java后端代码评审实录

从CR中看技术成长 — 7个真实Pull Request评审场景全解析

Spring Boot MyBatis Code Review 并发安全 事务边界 幂等性

为什么需要Code Review?

类比:代码体检 × 机场安检
Code Review 就像给代码做一次"全身体检"。你写的代码可能"能跑",但就像一个人看似健康,体检却能查出潜在的血压高、脂肪肝。同理,代码中隐藏的 NPE、竞态条件、SQL 注入,在测试环境可能一切正常,到了线上高并发场景就会爆发。

又像机场的安检X光机——一件行李看起来没什么问题,但X光扫一扫,可能就发现了违禁品。Reviewer 就是那台X光机,用"另一双眼睛"帮你发现你自己看不到的盲区。

CR 的三大核心价值

1
发现缺陷(Bug Prevention) — 根据 IBM 的研究,代码评审能在测试前发现 60% 以上的缺陷,而修复一个线上 Bug 的成本是设计阶段修复的 100 倍。
2
知识共享(Knowledge Transfer) — 团队成员通过 CR 了解彼此的代码,避免"只有一个人懂某块代码"的巴士因子(Bus Factor)风险。
3
统一规范(Consistency) — 十个人写代码有十种风格,CR 是把"个人作品"变成"团队工程"的关键环节。
真实数据:Google 每天发生成千上万次 Code Review,一个 PR 平均要经过 2~3 轮评审才能合并。
在 Google,代码必须至少经过一个人的 review 才能入库——这是写入 Google 工程文化中的铁律。
最佳实践: CR 不是"找茬",而是"互助"。好的 Reviewer 会说"这里这样改会不会更好?"而不是"你这写的不对"。语气对了,团队才会真正接受 CR 文化。

Review #01 — 命名规范问题

PR #142  |  feat: 订单查询接口  |  由 张伟(后端) 提交
Open Changes Requested 3 comments

新增订单查询接口,支持按用户和状态筛选订单列表。

src/.../OrderService.java +28 −0
src/.../OrderController.java +15 −0

提交的代码(有问题)

OrderService.java — review-demo
OrderService.java
OrderController.java
📦 com.shop
📁 service
📄 OrderService
📁 controller
📁 model
1public interface OrderService {
2
3 // 查数据
4 List<Map<String,Object>> getData(int a, String b);
5
6 // 处理
7 Object doProcess(Object obj);
8}
UTF-8LFJava3 issues

Reviewer 的评审意见

Code Review Comments — 李明(技术Leader)
@张伟 commented on line 4 · 2 hours ago

这个方法名 getData() 完全看不出意图——取什么数据?订单?用户?商品?参数 ab 更是毫无语义。三个月后你自己都不记得这是什么。

另外返回 List<Map<String,Object>> 是反模式。调用方拿到 Map 后,根本不知道里面有哪些 key,只能靠猜或翻 SQL。请定义 DTO。

建议: 方法名要"动词+宾语+条件",参数名要见名知意,返回值用强类型 DTO。

@张伟 commented on line 7 · 2 hours ago

doProcess(Object obj) — 入参和返回值都是 Object?这等于没有类型约束。OOP 的精髓在于类型表达意图,Object 退回到了 C 时代的 void*。

问题分析

问题点为什么不好后果
getData()方法名太泛,不表达意图调用者必须读实现才能理解
int a, String b参数名无意义传参顺序容易搞错,IDE 提示无帮助
List<Map>弱类型返回值调用方 map.get("order_id") 拼错字符串编译期发现不了
Object万能类型 = 无类型丧失编译期检查,运行时才发现错误

修正后的代码

diff --git a/src/.../OrderService.java index a1b2c3d..e4f5g6h 100644 --- a/src/.../OrderService.java +++ b/src/.../OrderService.java - // 查数据 - List<Map<String,Object>> getData(int a, String b); + /** + * 根据用户ID和订单状态查询订单列表 + * @param userId 用户ID,不能为null + * @param status 订单状态,null则查全部 + */ + List<OrderDTO> findOrdersByUserId(@NotNull Long userId, OrderStatus status);
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 dto
📄 OrderDTO.java
📄 UserDTO.java
📄 ProductDTO.java
// ====== 修正后的 DTO 定义 ====== public class OrderDTO { private Long orderId; private String orderNo; // 订单编号,见名知意 private BigDecimal amount; private OrderStatus status; private LocalDateTime createTime; // getter/setter 省略 } // ====== Controller 层调用 —— 清晰明了 ====== @GetMapping("/orders") public Result<List<OrderDTO>> queryOrders( @RequestParam Long userId, @RequestParam(required = false) OrderStatus status) { List<OrderDTO> orders = orderService.findOrdersByUserId(userId, status); return Result.success(orders); }
专业定义 DTO (Data Transfer Object) = 用于层间数据传输的 POJO,本质是"数据容器"。面试要点:DTO 隔离了内部领域模型与外部接口,避免实体类直接暴露给前端。使用 MapStruct(编译期生成)而非 BeanUtils(运行时反射)做转换,性能差距 10-50 倍。
命名三原则: 1. 方法名 = 动词 + 宾语 + 条件(findOrdersByUserId
2. 参数名见名知意(userId 而非 a
3. 返回值用强类型 DTO(OrderDTO 而非 Map
思考:当你在 IDE 里输入 orderService. 然后按 Ctrl+Space,弹出的方法提示是 getData(int a, String b) 还是 findOrdersByUserId(Long userId, OrderStatus status)?前者让你一头雾水,后者一眼就懂。好的命名是最好的文档。

Review #02 — 空指针防御(NPE)

PR #143  |  fix: 用户信息展示页空白问题  |  由 王芳(后端) 提交
Open Changes Requested

修复用户昵称为空时页面不展示的问题,增加 trim 处理。

提交的代码(NPE 风险)

UserProfileService.java — UserServiceImpl.java
📦 com.shop
📁 service
📄 UserServiceImpl
📁 model
📁 dto
18@Service
19public class UserServiceImpl implements UserService {
20
21 public UserProfileVO buildProfile(User user) {
22 UserProfileVO vo = new UserProfileVO();
23 // 问题1:user 可能为 null
24 String name = user.getName().trim(); // NPE!
25 vo.setDisplayName(name);
26
27 // 问题2:链式调用,getAddress() 可能为 null
28 String city = user.getAddress().getCity(); // NPE!
29 vo.setCity(city);
30 return vo;
31 }
Variables Watches
user = {id:1001, name:null, address:null}
Caused by: java.lang.NullPointerException at line 24

线上 NPE 是什么体验?

// 生产环境日志 —— 凌晨 2:30 的告警 ERROR [http-nio-8080-exec-15] c.s.c.GlobalExceptionHandler - 系统异常 java.lang.NullPointerException: Cannot invoke "String.trim()" because the return value of "User.getName()" is null at com.shop.service.UserServiceImpl.buildProfile(UserServiceImpl.java:24) at com.shop.controller.UserController.profile(UserController.java:45) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(...) at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1071) ... 47 common frames omitted // 影响:500错误,用户看到"系统异常",客诉 +3
思考一下:用户注册时没有填昵称(name = null),这太常见了——第三方登录(微信、QQ)的用户很多都没有昵称。直接 .trim() 就 NPE。在生产环境中,NPE 是 Java 最常见的运行时异常,没有之一。

修正后的代码

diff --git a/src/.../UserServiceImpl.java - String name = user.getName().trim(); - String city = user.getAddress().getCity(); + // 方案1:Optional 链式安全处理 + String name = Optional.ofNullable(user.getName()) + .map(String::trim) + .filter(n -> !n.isEmpty()) + .orElse("匿名用户"); + + // 嵌套对象也用 Optional 安全导航 + String city = Optional.ofNullable(user.getAddress()) + .map(Address::getCity) + .orElse("未知城市");
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📄 SeckillService.java
📄 OrderService.java
// ====== 方案2:使用 JSR-305 注解声明空值契约 ====== public UserProfileVO buildProfile(@NotNull User user) { // @NotNull 让 IDE 和 JSR 校验器在编译期/运行时帮你检查 Objects.requireNonNull(user, "user不能为null"); String name = StringUtils.trimToEmpty(user.getName()); if (name.isEmpty()) { name = "匿名用户"; // 给个默认值 } vo.setDisplayName(name); String city = Optional.ofNullable(user.getAddress()) .map(Address::getCity) .orElse("未知"); vo.setCity(city); return vo; } // ====== 方案3:在数据源头用 @NotNull + 数据库 NOT NULL 约束 ====== @Entity public class User { @NotNull @Column(nullable = false) private String name; // 从源头杜绝 null }
面试金句 Redisson 分布式锁 = 基于 Redis + Lua 脚本的分布式锁实现。面试核心三点:(1) 加锁+过期时间必须原子操作(SET NX EX 或 Lua)(2) 锁续期(WatchDog 机制,默认10s续期一次)(3) 锁标识用 UUID 防止误解锁。Redisson 封装了全部细节,生产首选。
技术群 — PR #143 讨论
今天 10:23
李明(Leader)
@王芳 getName() 可能返回 null,直接 trim() 线上必 NPE。用 Optional 处理一下,或者用 StringUtils.trimToEmpty()。
王芳(后端)
啊,我本地测试的时候昵称都有值所以没发现问题... 这种链式调用怎么防?
李明(Leader)
记住口诀:"永远不要信任外部数据"。不管是数据库查出来的、RPC返回的、还是前端传的,都可能为 null。用 Optional 或者判空。
NPE 防御四层: 第一层 — 入口校验:Objects.requireNonNull() / @NotNull 注解
第二层 — 安全导航:Optional.ofNullable().map().orElse()
第三层 — 工具兜底:StringUtils.defaultString() / CollectionUtils.isEmpty()
第四层 — 全局兜底:GlobalExceptionHandler 捕获 NPE 返回友好提示

Review #03 — 事务边界问题

类比:数据库连接池 = 停车场
数据库连接池就像一个只有 20 个车位的停车场。@Transactional 一开,就等于"占了一个车位"。如果事务里调了一个外部 RPC(比如调第三方支付 API),而那个 API 响应慢(3 秒),这 3 秒里车位一直被占着。20 个车位,21 个请求同时来,停车场就满了——连接池耗尽,整个系统拒绝服务。
PR #145  |  feat: 下单调用支付接口  |  由 陈强(后端) 提交

下单时创建订单并调用第三方支付接口冻结金额。

src/.../OrderService.java +32 −5

提交的代码(长事务 + RPC)

OrderService.java
📦 com.shop
📁 service
📄 OrderService
35@Service
36public class OrderService {
37
38 @Transactional // ⚠️ 事务开启,占用DB连接
39 public OrderResult createOrder(OrderRequest req) {
40 // 1. 扣库存(DB操作,快)
41 productMapper.deductStock(req.getProductId());
42
43 // 2. 创建订单(DB操作,快)
44 Order order = buildOrder(req);
45 orderMapper.insert(order);
46
47 // 3. ⚠️ 调第三方支付API(RPC,慢!可能3秒)
48 PayResult pay = payFeignClient.freezeAmount(order);
49
50 // 4. 更新订单状态(DB操作)
51 orderMapper.updateStatus(order.getId(), pay.getStatus());
52 return buildResult(order, pay);
53 } // 事务提交,释放连接(但连接已被占了好几秒!)
Code Review — 赵磊(架构师)
@陈强 commented on line 38 · 1 hour ago

事务内不能有 RPC 调用!

@Transactional 会持有数据库连接,直到方法返回。中间 payFeignClient.freezeAmount() 是个远程调用,网络抖动一下 3 秒就过去了。这 3 秒里数据库连接一直被占着。

我们的连接池只有 20 个连接。并发 20 个下单请求,连接池就满了,第 21 个请求直接超时。长事务 = 慢性自杀

事务生命周期对比

❌ 错误:事务包住 RPC

开启事务
(占用连接)
扣库存
2ms
建订单
3ms
RPC调支付
3000ms
更新状态
2ms
提交
(释放连接)

总持有时长:~3007ms  |  连接池 20 个,吞吐量仅 ~6 req/s


✅ 正确:事务只包 DB 操作

开启事务 扣库存+建订单
5ms
提交
(释放连接)
RPC调支付
3000ms
(不占连接)
开启事务2 更新状态
2ms
提交

DB连接持有:~7ms  |  RPC 在事务外,不占连接,吞吐量提升 400 倍

修正后的代码

diff --git a/src/.../OrderService.java - @Transactional - public OrderResult createOrder(OrderRequest req) { - productMapper.deductStock(req.getProductId()); - orderMapper.insert(order); - PayResult pay = payFeignClient.freezeAmount(order); // RPC在事务内! - orderMapper.updateStatus(order.getId(), pay.getStatus()); + public OrderResult createOrder(OrderRequest req) { + // 步骤1:事务内完成DB操作 + Order order = createOrderInDb(req); + + // 步骤2:事务外调用RPC(不占数据库连接) + PayResult pay = payFeignClient.freezeAmount(order); + + // 步骤3:事务内更新支付结果 + updateOrderStatus(order.getId(), pay.getStatus()); + return buildResult(order, pay); + } + + @Transactional + public Order createOrderInDb(OrderRequest req) { + productMapper.deductStock(req.getProductId()); + Order order = buildOrder(req); + orderMapper.insert(order); + return order;
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📄 UserService.java
📄 ProfileService.java
// ====== 或者使用编程式事务,精确控制范围 ====== @Autowired private TransactionTemplate transactionTemplate; public OrderResult createOrder(OrderRequest req) { // 编程式事务——只有 lambda 内的代码在事务中 Order order = transactionTemplate.execute(status -> { productMapper.deductStock(req.getProductId()); Order o = buildOrder(req); orderMapper.insert(o); return o; }); // ← 此处事务已提交,连接已释放 // RPC 调用在事务外,不占用数据库连接 PayResult pay = payFeignClient.freezeAmount(order); // 再开一个小事务更新状态 transactionTemplate.execute(status -> { orderMapper.updateStatus(order.getId(), pay.getStatus()); return null; }); return buildResult(order, pay); }
面试金句 JSR-305 @Nonnull/@Nullable = 编译期空值契约注解,配合 IntelliJ Inspection 做静态分析。比运行时 NPE 更早发现问题。面试加分:说明与 Lombok @NonNull(运行时抛 NPE)和 Optional(运行时容器)的区别——JSR-305 是编译期预防,Lombok 是运行时拦截,Optional 是显式声明可能为空
// 修复前:HikariCP 连接池监控 HikariPool-1 - Pool stats (total=20, active=20, idle=0, waiting=15) ERROR Connection is not available, request timed out after 30000ms // 修复后:同样的并发量 HikariPool-1 - Pool stats (total=20, active=3, idle=17, waiting=0) // 连接秒还秒借,吞吐量大幅提升
事务三原则: 1. 事务要短——只包含必须原子的 DB 操作
2. 事务内禁止 RPC 调用、禁止文件 IO、禁止 Thread.sleep
3. 需要事务嵌套时注意传播机制(Propagation.REQUIRES_NEW

Review #04 — 并发安全问题

PR #148  |  feat: 优惠券领取功能  |  由 刘洋(后端) 提交

用户领取优惠券,先检查库存再扣减。

src/.../CouponService.java +25 −0

提交的代码(Check-Then-Act 竞态)

CouponService.java
📦 com.shop
📁 service
📄 CouponService
20@Service
21public class CouponService {
22
23 public boolean claimCoupon(Long couponId, Long userId) {
24 // 第1步:查库存(CHECK)
25 Coupon c = couponMapper.selectById(couponId);
26 if (c.getCount() <= 0) {
27 return false; // 库存不足
28 }
29
30 // 第2步:扣库存(ACT)
31 couponMapper.updateCount(couponId, c.getCount() - 1);
32 return true;
33 }

竞态条件时间线

优惠券库存 = 1,两个用户同时领取

线程A(用户1)

T=0ms: SELECT count → 1
T=1ms: count > 0 ✓
T=2ms: (准备UPDATE...)
T=3ms: UPDATE count = 0
结果:领取成功

线程B(用户2)

T=0ms: SELECT count → 1
T=1ms: count > 0 ✓
T=2ms: (准备UPDATE...)
T=4ms: UPDATE count = 0
结果:领取成功

实际库存只有 1 张,但 2 个人都领取成功了!超卖!

Code Review — 赵磊(架构师)

典型的 Check-Then-Act 竞态条件!

SELECT 和 UPDATE 之间有时间窗口。两个线程同时 SELECT 都读到 count=1,然后各自 UPDATE 扣减——库存为 1 却发出了 2 张券。618 大促这种代码直接超卖,公司赔钱。

三种修复方案:
1. 数据库原子操作(最推荐)
2. 乐观锁(version 字段)
3. 分布式锁(Redisson)

修正方案1:数据库原子操作(最推荐)

diff --git a/src/.../CouponService.java - Coupon c = couponMapper.selectById(couponId); - if (c.getCount() <= 0) return false; - couponMapper.updateCount(couponId, c.getCount() - 1); + // 把检查和扣减合并成一条原子SQL + int rows = couponMapper.deductIfAvailable(couponId); + return rows > 0; // affected rows > 0 说明扣减成功
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 mapper
📄 UserMapper.java
📄 OrderMapper.java
// ====== CouponMapper.java ====== public interface CouponMapper { // 利用数据库行锁,UPDATE 是原子的 @Update("UPDATE coupon SET count = count - 1 " + "WHERE id = #{id} AND count > 0") int deductIfAvailable(@Param("id") Long id); } // ====== 为什么这样安全? ====== // MySQL 的 UPDATE 会对这行加排他锁(X-Lock) // 线程A: UPDATE ... WHERE count>0 → 成功,count变0 // 线程B: UPDATE ... WHERE count>0 → count已经是0,条件不满足,0行受影响 // 完美!不可能超卖
面试金句 SQL 注入防护 = MyBatis #{} 使用 PreparedStatement 的参数占位符(?),在 JDBC 层面防止注入;${} 是字符串拼接,能不用就不用。唯一需要 ${} 的场景:动态表名/列名(如分表路由),此时必须做白名单校验。面试考:二者区别 = 预编译 vs 拼接。

修正方案2:乐观锁(version 字段)

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📄 OrderService.java
📄 PaymentService.java
// 乐观锁:加 version 字段,CAS 思想 @Update("UPDATE coupon SET count = count - 1, version = version + 1 " + "WHERE id = #{id} AND version = #{version} AND count > 0") int deductWithVersion(@Param("id") Long id, @Param("version") int version); // Service 层:失败则重试 public boolean claimCoupon(Long couponId, Long userId) { for (int i = 0; i < 3; i++) { // 最多重试3次 Coupon c = couponMapper.selectById(couponId); if (c.getCount() <= 0) return false; if (couponMapper.deductWithVersion(couponId, c.getVersion()) > 0) { return true; // 扣减成功 } // version 不匹配说明被别的线程改了,重试 } return false; }
面试金句 编程式事务 TransactionTemplate = 精确控制事务范围的方案,解决了声明式 @Transactional 无法实现"部分代码在事务内、部分在事务外"的问题。面试高频:声明式事务的 5 大失效场景(自调用/非public/异常被吃/rollbackFor默认只回滚RuntimeException/传播行为理解错误),编程式事务绕过 AOP 代理,天然规避自调用失效。

修正方案3:Redis 分布式锁(Redisson)

📁 order-service
📂 src
📂 main
📂 resources
📂 mapper
📄 ProductMapper.xml
📄 OrderMapper.xml
@Autowired private RedissonClient redisson; public boolean claimCoupon(Long couponId, Long userId) { String lockKey = "coupon:lock:" + couponId; RLock lock = redisson.getLock(lockKey); try { // 尝试加锁,最多等待3秒,锁自动释放10秒 if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) { throw new BusinessException("系统繁忙,请重试"); } // 加锁成功,安全地检查+扣减 Coupon c = couponMapper.selectById(couponId); if (c.getCount() <= 0) return false; couponMapper.updateCount(couponId, c.getCount() - 1); return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }
专业定义 MyBatis XML 批量查询 = 使用 <foreach> 标签生成 IN 条件,一条 SQL 查询多条记录。原理:MyBatis OGNL 解析 foreach → 生成 id IN (?, ?, ?) → PreparedStatement 批量绑定。避免 N+1 查询问题,将 N 次查询降为 1 次。
方案选型: 单机/轻量 → 数据库原子 UPDATE(最简单)
中并发 → 乐观锁(version + CAS 重试)
高并发/分布式 → Redis 分布式锁(Redisson)
秒杀级 → Redis Lua 脚本预扣 + 异步落库
一句话总结:只要你的代码是"先查后改"(check-then-act),就一定有并发问题。多线程环境下,查询和修改之间永远存在时间窗口。原子化是唯一出路。

Review #05 — SQL注入与N+1查询

PR #152  |  feat: 订单列表带商品详情  |  由 周婷(后端) 提交

查询用户的订单列表,并关联查询每个订单的商品信息。

问题1:N+1 查询

OrderQueryService.java
📦 com.shop
📄 OrderQueryService
15public List<OrderVO> listOrders(Long userId) {
16 // 第1次查询:查100条订单
17 List<Order> orders = orderMapper.selectByUserId(userId);
18
19 List<OrderVO> result = new ArrayList<>();
20 for (Order order : orders) {
21 // ⚠️ 循环内查数据库!100条=100次查询
22 Product p = productMapper.selectById(order.getProductId());
23 OrderVO vo = convert(order, p);
24 result.add(vo);
25 }
26 return result;
27}
// N+1 问题:1次查订单 + 100次查商品 = 101次SQL查询! ==> Preparing: SELECT * FROM orders WHERE user_id = ? ==> Parameters: 1001(Long) <== Total: 100 (查到100条订单) // 接下来循环100次查商品... ==> Preparing: SELECT * FROM product WHERE id = ? ==> Parameters: 1(Long) ==> Preparing: SELECT * FROM product WHERE id = ? ==> Parameters: 2(Long) ... 重复98次 ... ==> Preparing: SELECT * FROM product WHERE id = ? ==> Parameters: 100(Long) 总计 SQL 执行次数:101 次  |  总耗时:850ms

问题2:SQL 注入

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 mapper
📄 CouponMapper.java
📄 OrderMapper.java
// ⚠️ 致命漏洞:字符串拼接SQL @Mapper public interface UserMapper { // 危险!直接拼接用户输入 @Select("SELECT * FROM users WHERE name = '" + name + "'") User findByName(String name); // SQL注入! } // 攻击示例: // 输入 name = "admin' OR '1'='1" // 实际执行的SQL: // SELECT * FROM users WHERE name = 'admin' OR '1'='1' // 结果:返回所有用户数据!数据泄露! // 更恐怖的攻击: // 输入 name = "'; DROP TABLE users; --" // SELECT * FROM users WHERE name = ''; DROP TABLE users; --' // 整张表没了!
专业定义 数据库原子操作 = 利用 UPDATE ... WHERE count > 0 的行级锁 + 条件判断,在单条 SQL 内完成"检查+扣减"操作。比"先查后改"少了 SELECT 查询,避免了 ABA 问题。InnoDB 行锁 + WHERE 条件天然保证了原子性,无需额外加锁。
Code Review 讨论 — PR #152
今天 14:00
赵磊(架构师)
@周婷 for 循环里查数据库是性能杀手!100条订单就是101次SQL。改成批量查询 IN。
周婷(后端)
好的!那SQL注入那个是历史代码,我在同一个文件里看到的...
赵磊(架构师)
SQL注入是 P0 级安全漏洞!马上改!用 #{} 参数化查询,永远不要用字符串拼接。这条写进团队规范。

修正后的代码

diff --git a/src/.../OrderQueryService.java - List<Order> orders = orderMapper.selectByUserId(userId); - for (Order order : orders) { - Product p = productMapper.selectById(order.getProductId()); - // N+1 查询:101次SQL - } + List<Order> orders = orderMapper.selectByUserId(userId); + // 收集所有 productId + Set<Long> productIds = orders.stream() + .map(Order::getProductId).collect(Collectors.toSet()); + // 一次性批量查询! + Map<Long, Product> productMap = productMapper + .selectByIds(productIds).stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + // 组装VO + for (Order order : orders) { + Product p = productMap.get(order.getProductId()); + result.add(convert(order, p)); + }
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📄 OrderService.java
📄 PaymentService.java
// ====== MyBatis XML 批量查询 IN ====== <!-- ProductMapper.xml --> <select id="selectByIds" resultType="Product"> SELECT id, name, price FROM product WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select> // ====== SQL注入修复:永远用参数化查询 #{} ====== // ✅ 正确:#{} 是预编译参数,MyBatis会自动用PreparedStatement @Select("SELECT * FROM users WHERE name = #{name}") User findByName(String name); // 安全! // ❌ 错误:${} 是字符串替换,会SQL注入 @Select("SELECT * FROM users WHERE name = '${name}'") User findByNameUnsafe(String name); // 危险!
面试金句 幂等性设计 = 同一请求执行多次结果一致。面试标准方案:(1) 唯一索引(DB 层兜底)(2) 状态机 CAS(UPDATE WHERE status=expected)(3) Token 机制(前端获取一次性 token)。三道防线层层递进,DB 唯一索引是最后一道防线。
// 修复后:只有 2 次 SQL 查询! ==> Preparing: SELECT * FROM orders WHERE user_id = ? ==> Parameters: 1001(Long) <== Total: 100 ==> Preparing: SELECT id, name, price FROM product WHERE id IN (?,?,?,?,?...100个) ==> Parameters: 1(Long), 2(Long), ... 100(Long) <== Total: 100 总计 SQL 执行次数:2 次  |  总耗时:12ms (从 850ms 降到 12ms!)
N+1 检测技巧: 开启 MyBatis 日志(logging.level.com.xxx.mapper=debug),看 SQL 执行次数。如果接口返回 1 条数据时执行 N 次 SQL,返回 10 条时执行 N+10 次,那就是 N+1 问题。

Review #06 — 异常处理不当

PR #155  |  feat: 订单创建逻辑  |  由 孙浩(后端) 提交

实现创建订单的核心逻辑。

提交的代码(吞掉异常)

OrderCreateService.java
📦 com.shop
📄 OrderCreateService
40public Order createOrder(OrderRequest req) {
41 try {
42 Order order = buildOrder(req);
43 orderMapper.insert(order);
44 inventoryService.deduct(req);
45 return order;
46 } catch (Exception e) {
47 e.printStackTrace(); // ⚠️ 异常被吞了!
48 return null; // ⚠️ 返回null,调用方不知道出错了
49 }
50}
Code Review — 李明(Team Leader)

@孙浩 异常被吞了!

e.printStackTrace() 输出到 stdout,生产环境日志根本收不到(日志走 logback)。线上出问题查不到任何线索。

更严重的是 return null——调用方拿到 null,以为成功了,但订单根本没创建成功。用户付了钱没订单,客诉就来了。

正确做法: 记录带上下文的错误日志 + 抛出业务异常,让调用方知道出错了。

日志对比:吞异常 vs 正确记录

// ❌ 吞异常 — 线上日志什么都看不到 2026-06-13 14:30:00 INFO OrderController - 收到创建订单请求 2026-06-13 14:30:00 INFO OrderController - 返回结果: null (没有错误信息!无法排查!) // ✅ 正确记录 — 日志一目了然 2026-06-13 14:30:00 INFO OrderController - 收到创建订单请求, userId=1001 2026-06-13 14:30:01 ERROR OrderCreateService - 创建订单失败, userId=1001, productId=2001 java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'ORD20260613001' at com.mysql.cj.jdbc.ClientPreparedStatement.execute(ClientPreparedStatement.java:387) at com.shop.mapper.OrderMapper.insert(OrderMapper.java:25) at com.shop.service.OrderCreateService.createOrder(OrderCreateService.java:43) 2026-06-13 14:30:01 WARN GlobalExceptionHandler - 业务异常: 订单号重复

修正后的代码

diff --git a/src/.../OrderCreateService.java - } catch (Exception e) { - e.printStackTrace(); - return null; - } + } catch (DuplicateKeyException e) { + log.error("订单号重复, userId={}, orderId={}", req.getUserId(), orderId, e); + throw new BusinessException("ORDER_DUPLICATE", "订单号重复,请重试"); + } catch (Exception e) { + log.error("创建订单失败, userId={}, productId={}", + req.getUserId(), req.getProductId(), e); + throw new BusinessException("ORDER_CREATE_FAILED", "创建订单失败"); + }
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 mapper
📄 CouponMapper.java
📄 OrderMapper.java
@Slf4j @Service public class OrderCreateService { public Order createOrder(OrderRequest req) { Order order = null; try { order = buildOrder(req); orderMapper.insert(order); inventoryService.deduct(req); return order; } catch (DuplicateKeyException e) { // 精准捕获:订单号重复 log.error("订单号重复, userId={}, orderId={}", req.getUserId(), order.getId(), e); throw new BusinessException("ORDER_DUPLICATE", "订单号重复"); } catch (Exception e) { // 兜底捕获:记录带上下文的日志,然后抛出 log.error("创建订单失败, userId={}, productId={}", req.getUserId(), req.getProductId(), e); throw new BusinessException("ORDER_CREATE_FAILED", "创建订单失败"); } } } // ====== 全局异常处理器:统一返回错误信息 ====== @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public Result<?> handleBusiness(BusinessException e) { log.warn("业务异常: code={}, msg={}", e.getCode(), e.getMessage()); return Result.fail(e.getCode(), e.getMessage()); } @ExceptionHandler(Exception.class) public Result<?> handleAll(Exception e) { log.error("系统异常", e); return Result.fail("SYSTEM_ERROR", "系统繁忙,请稍后重试"); } }
专业定义 乐观锁 (Optimistic Lock) = 基于 CAS (Compare-And-Swap) 思想的并发控制。UPDATE ... SET version=version+1 WHERE id=? AND version=?,如果 affectedRows=0 说明被其他线程修改过,需要重试。相比悲观锁(SELECT FOR UPDATE),不持有锁、不阻塞,适合读多写少场景。
异常处理四不要: 1. 不要 e.printStackTrace()(生产看不到)
2. 不要 catchreturn null(调用方不知道出错了)
3. 不要 catch (Exception) 后什么都不做(静默吞掉)
4. 不要丢失异常链(throw new RuntimeException(e) 要带上 cause)
记住:日志是你的"黑匣子"。飞机出事靠黑匣子找原因,线上出问题靠日志排查。log.error("xxx失败", e) 最后那个 e 一定要带上,它会打印完整的异常堆栈——那是排查问题的唯一线索。

Review #07 — 幂等性问题

类比:收银台扫码
你在超市买东西,收银员扫了你手机上的付款码,扣了 100 块。但因为网络延迟,收银机没收到支付平台的确认回调。支付平台以为没成功,又发了一次回调——结果又扣了 100 块。你花了 100 块买了 50 块的东西。

这就是支付回调不幂等的后果。幂等性就是:同样的请求,不管来几次,结果都一样。
PR #160  |  feat: 微信支付回调处理  |  由 吴敏(后端) 提交

接收微信支付结果回调,更新订单状态并扣款。

提交的代码(不幂等)

PayCallbackController.java
📦 com.shop
📁 controller
📄 PayCallbackController
30@PostMapping("/pay/callback")
31public String handleCallback(@RequestBody PayCallback cb) {
32 Order order = orderMapper.selectById(cb.getOrderId());
33
34 // ⚠️ 没有判断当前状态!直接改
35 order.setStatus("PAID");
36 orderMapper.update(order);
37
38 // ⚠️ 每次回调都发积分!重复发!
39 pointsService.addPoints(order.getUserId(), order.getAmount());
40
41 // ⚠️ 每次回调都发短信!
42 smsService.sendPaySuccess(order.getUserId());
43 return "SUCCESS";
44}

重复回调时序图

微信支付平台可能发送多次回调(网络超时重试机制)

支付平台

我们的服务(无幂等)

T=0s  支付平台 → 回调1 → 我们的服务
→ 订单状态改为 PAID
→ 积分 +100
→ 发送短信
T=3s  支付平台(没收到ACK)→ 回调2 → 我们的服务
→ 订单状态又改 PAID(重复)
→ 积分又 +100(重复!)
→ 又发短信(用户收到2条!)
T=6s  支付平台 → 回调3 ...
→ 积分又 +100

用户付 100 块,积分却加了 300!

Code Review — 赵磊(架构师)

@吴敏 支付回调必须幂等!

微信支付平台有重试机制——如果 5 秒内没收到你的 ACK 响应,它会重复发送回调,最多 8 次。你的代码每次都加积分、发短信,用户会被骚扰死。

核心思路:处理前先判断状态——如果订单已经是 PAID,说明之前处理过,直接返回 SUCCESS 跳过。

修正后的代码

diff --git a/src/.../PayCallbackController.java - Order order = orderMapper.selectById(cb.getOrderId()); - order.setStatus("PAID"); - orderMapper.update(order); - pointsService.addPoints(order.getUserId(), order.getAmount()); - smsService.sendPaySuccess(order.getUserId()); + // 幂等检查:用状态做 CAS 更新 + int rows = orderMapper.updateStatusIfUnpaid( + cb.getOrderId(), "PAID", "UNPAID"); + if (rows == 0) { + // 订单已经是PAID,说明之前处理过,直接返回成功 + log.info("重复回调,订单已处理, orderId={}", cb.getOrderId()); + return "SUCCESS"; + } + // 只有第一次才会执行后续逻辑 + Order order = orderMapper.selectById(cb.getOrderId()); + pointsService.addPoints(order.getUserId(), order.getAmount()); + smsService.sendPaySuccess(order.getUserId());
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📄 OrderService.java
📄 OrderConsumer.java
// ====== 方案1:数据库状态 CAS(推荐,最可靠)====== @Update("UPDATE orders SET status = #{newStatus}, pay_time = NOW() " + "WHERE id = #{orderId} AND status = #{oldStatus}") int updateStatusIfUnpaid(@Param("orderId") Long id, @Param("newStatus") String newStatus, @Param("oldStatus") String oldStatus); // ====== 方案2:Redis Token 幂等 ====== @Autowired private StringRedisTemplate redis; public String handleCallback(PayCallback cb) { String key = "pay:callback:" + cb.getOrderId(); // setIfAbsent = SETNX,只有第一次能设置成功 Boolean firstTime = redis.opsForValue() .setIfAbsent(key, "1", 24, TimeUnit.HOURS); if (Boolean.FALSE.equals(firstTime)) { log.info("重复回调,已处理, orderId={}", cb.getOrderId()); return "SUCCESS"; // 幂等返回 } // 第一次处理 doProcessPayment(cb); return "SUCCESS"; } // ====== 方案3:唯一约束防重(数据库层)====== // 对 transaction_id 加唯一索引 @Table(uniqueConstraints = @UniqueConstraint(columnNames = {"transactionId"})) public class PayRecord { private String transactionId; // 微信支付流水号 private Long orderId; private BigDecimal amount; } // 插入重复的 transactionId 会抛 DuplicateKeyException // catch 住就是幂等返回
面试金句 数据库状态 CAS = UPDATE orders SET status=#{newStatus} WHERE id=#{id} AND status=#{expectedOldStatus},利用 affectedRows 判断是否更新成功。天然幂等:重试请求的 status 已变,CAS 失败,不会重复操作。
技术群 — 幂等性讨论
今天 15:30
赵磊(架构师)
@吴敏 所有涉及"外部回调"和"用户重复提交"的接口都必须幂等。支付回调、消息消费、重试机制,这些场景天然可能重复。
吴敏(后端)
除了支付回调,还有哪些接口需要做幂等?
赵磊(架构师)
记住一个原则:GET 天然幂等,POST/PUT/DELETE 默认不幂等。需要幂等的场景:支付、转账、下单、发券、发短信。用唯一约束、状态机、Redis token 都行,核心是"识别重复并跳过"

✅ 幂等处理后的时序

T=0s  回调1到达
→ CAS更新成功(UNPAID→PAID)✓
→ 积分+100 ✓
→ 发短信 ✓
T=3s  回调2到达
→ CAS更新失败(已经是PAID,0行受影响)
→ 跳过积分、短信
→ 直接返回 SUCCESS ✓
T=6s  回调3到达
→ 同上,安全跳过 ✓

无论回调几次,积分只加一次,短信只发一条!

幂等实现三方案: 1. 数据库唯一约束(最可靠,适合插入操作)
2. 状态机 CAS(UPDATE ... WHERE status = old,适合更新操作)
3. Redis Token(SETNX,适合高频场景)

代码评审清单(Checklist)

每次 Code Review,把这 7 项过一遍,覆盖了 90% 的常见问题:

# 检查项 核心问题 本章
1 命名规范 方法名/变量名是否表达意图?返回值是否强类型? Review#01
2 空指针防御 所有外部数据是否判空?链式调用是否安全? Review#02
3 事务边界 事务是否过长?事务内是否有 RPC/IO 操作? Review#03
4 并发安全 是否有 Check-Then-Act?是否需要锁/原子操作? Review#04
5 SQL安全 是否有 N+1?是否参数化查询(防注入)? Review#05
6 异常处理 异常是否被吞?日志是否有上下文?是否抛出? Review#06
7 幂等性 重复请求是否安全?回调/重试是否幂等? Review#07
Bonus 检查项: 8. 日志规范(关键操作是否有日志?日志级别是否合适?)
9. 入参校验(@Valid + @NotNull / @Size
10. 权限控制(接口是否需要鉴权?是否有越权风险?)

总结:从"能跑就行"到"代码工匠"

代码成长的三个阶段

阶段一:"能跑就行" — 刚入行时,代码能编译通过、功能跑通就觉得大功告成。命名随意,异常吞掉,SQL 拼接。这是业余选手

阶段二:"考虑边界" — 经历过几次线上事故后,开始关注 null、并发、事务、异常。知道"正常流程"只是冰山一角,异常流程才是考验。这是合格工程师

阶段三:"代码工匠" — 每一行代码都经得起推敲:命名精准、边界清晰、并发安全、易于维护。写代码像写作,追求可读、可测、可扩展。这是技术专家

7 个 Review 的核心一句话总结

Review一句话
01 命名好的命名是最好的文档
02 空指针永远不要信任外部数据
03 事务事务要短,事务内不调 RPC
04 并发Check-Then-Act 必有竞态
05 SQL循环查 DB 是性能杀手,参数化防注入
06 异常异常不要吞,日志带上下文
07 幂等回调/重试必须幂等
给 Reviewer 的话: Code Review 不是显示自己比谁聪明,而是帮助团队一起进步。好的 Reviewer 说"这里这样改会不会更好?",坏的 Reviewer 说"你这写的什么垃圾"。语气对了,团队才会接受 CR;语气错了,CR 变成人身攻击,团队会抵触。对人友善,对代码严格。
给被 Review 的人: 收到 review 意见不要有防御心理。每一条意见都是学习机会。我曾经收到一个 PR 上 50 条 review 意见——难受吗?难受。但改完之后,下次写的代码就进步了。被 review 的过程,就是成长的过程。
最后: 每一个优秀的工程师,都是在无数次 Code Review 中"被骂"出来的。不要害怕 CR,拥抱它。今天的每一句 "Changes Requested",都是明天"一次通过"的铺路石。从"能跑就行"到"代码工匠",差的不是天赋,而是每一次 CR 中的较真