为什么需要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 — 命名规范问题
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 的评审意见
@张伟 commented on line 4 · 2 hours ago
这个方法名 getData() 完全看不出意图——取什么数据?订单?用户?商品?参数 a 和 b 更是毫无语义。三个月后你自己都不记得这是什么。
另外返回 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);
// ====== 修正后的 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)
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 是什么体验?
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
思考一下:用户注册时没有填昵称(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("未知城市");
// ====== 方案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 封装了全部细节,生产首选。
今天 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 个请求同时来,停车场就满了——连接池耗尽,整个系统拒绝服务。
下单时创建订单并调用第三方支付接口冻结金额。
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 } // 事务提交,释放连接(但连接已被占了好几秒!)
@陈强 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;
// ====== 或者使用编程式事务,精确控制范围 ======
@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 是显式声明可能为空。
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 — 并发安全问题
用户领取优惠券,先检查库存再扣减。
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 个人都领取成功了!超卖!
典型的 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 说明扣减成功
// ====== 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 字段)
// 乐观锁:加 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)
@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查询
查询用户的订单列表,并关联查询每个订单的商品信息。
问题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}
==> Preparing: SELECT * FROM orders WHERE user_id = ?
==> Parameters: 1001(Long)
<== Total: 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 注入
// ⚠️ 致命漏洞:字符串拼接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 条件天然保证了原子性,无需额外加锁。
今天 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));
+ }
// ====== 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 唯一索引是最后一道防线。
==> 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
N+1 检测技巧:
开启 MyBatis 日志(logging.level.com.xxx.mapper=debug),看 SQL 执行次数。如果接口返回 1 条数据时执行 N 次 SQL,返回 10 条时执行 N+10 次,那就是 N+1 问题。
Review #06 — 异常处理不当
提交的代码(吞掉异常)
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}
@孙浩 异常被吞了!
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", "创建订单失败");
+ }
@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. 不要 catch 后 return null(调用方不知道出错了)
3. 不要 catch (Exception) 后什么都不做(静默吞掉)
4. 不要丢失异常链(throw new RuntimeException(e) 要带上 cause)
记住:日志是你的"黑匣子"。飞机出事靠黑匣子找原因,线上出问题靠日志排查。log.error("xxx失败", e) 最后那个 e 一定要带上,它会打印完整的异常堆栈——那是排查问题的唯一线索。
Review #07 — 幂等性问题
类比:收银台扫码
你在超市买东西,收银员扫了你手机上的付款码,扣了 100 块。但因为网络延迟,收银机没收到支付平台的确认回调。支付平台以为没成功,又发了一次回调——结果又扣了 100 块。你花了 100 块买了 50 块的东西。
这就是支付回调不幂等的后果。幂等性就是:同样的请求,不管来几次,结果都一样。
提交的代码(不幂等)
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!
@吴敏 支付回调必须幂等!
微信支付平台有重试机制——如果 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());
// ====== 方案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% 的常见问题:
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 中的较真。