macrozheng / mall · 源码深挖产出
缺陷修复总览
5 类缺陷,1 个病根,4 个业务域
在开源电商项目 mall(Spring Boot 3.5.14 + JDK 17)上做的系统性并发与状态机缺陷排查。 所有缺陷均以真实数据库、受控实验 复现并量化,全部修复 + 提交, 修复前后数据均可回滚核对。累计改动 17 个文件 / 696 行。

结论先行
先看判断,再看过程
5 个缺陷不是 5 个独立 bug,是同一个病根的 5 种表现。
病根:「读 → 判断 → 写」三步没有原子化;状态变更缺少前置条件。 在单线程下这串代码完全正确,并发下就互相覆盖。 库存、优惠券、权限缓存、订单状态四个看起来毫不相干的模块, 犯的是同一个错误 ------ 这正是它的价值:不是运气好找到了几个 bug, 而是识别出了一类缺陷模式,并能在陌生代码里主动定位它 。
为什么这个发现是「系统性」的,而不是「碰巧」的
关键证据:项目里已经存在正确写法 。 管理端发货 SQL(mall-admin/.../OmsOrderDao.xml:36)用的是 WHERE id IN (...) AND status = 1 ------ 带状态条件的原子更新。 同一个项目、同一张 oms_order 表,后台写对了,用户端没写 。
这说明问题不是「团队不懂原子更新」,而是约束没有贯彻到所有调用点 。 能识别出「同一个项目里同一个问题被做对过一次」,比找到一个 bug 更有说服力 ------ 它证明的是横向比对能力 。
病根 → 表现:一张图看清
共同病根 读→判断→写 未原子化 状态变更缺前置条件 ① 下单超卖 P0 库存 ② 优惠券超发 P0 营销 ③ 权限缓存刷新竞态 P1 权限 ④ 禁用角色不生效(越权) P0 权限 ⑤ 支付回调不幂等 / TOCTOU P0 交易 统一修复范式 条件 UPDATE + 影响行数判定 一次实现校验/幂等/互斥
02
缺陷总表
一眼看全,细节在后
| # | 域 | 级别 | 缺陷类型 | 关键位置(基线行号) | 提交 | 量化证据 |
|---|---|---|---|---|---|---|
| ① | 库存 | P0 | 超卖 | |||
| 丢更新(lost update) | OmsPortalOrderServiceImpl.lockStock:734 |
19105d1 |
100 并发抢 10 件 → 成功 100 单(超卖 90 件) | |||
| ② | 营销 | P0 | 超发 | |||
| 丢更新 | UmsMemberCouponServiceImpl.add:43 |
158381f |
100 并发抢 10 张 → 100 / 55 / 76(最多超发 90 张) | |||
| ③ | 权限 | P1 | 竞态 | |||
| 空快照窗口 + NPE | DynamicSecurityMetadataSource:20/30/44 |
5a1808a |
真实规模 20s 压测异常 61,216 次 | |||
| (静默 403 44,614 / NPE 16,602) | ||||||
| ④ | 权限 | P0 | 越权 | |||
| 两层根因(数据 + 缓存) | UmsAdminRoleRelationDao.xml:17 |
|||||
UmsRoleServiceImpl.update:43 |
e573cf5 |
禁用角色后 3 个超管账号残留 87 条授权 | ||||
| ⑤ | 交易 | P0 | 资损 | |||
| 不幂等 + TOCTOU | paySuccess:253 |
|||||
cancelTimeOutOrder:281 |
||||||
cancelOrder:297 |
db0336c |
重复回调lock_stock 1→0→-1;已取消订单被回调「复活」;付款订单被关闭 |
||||
| ⑥ | 构建 | P1 | 部署形态相关缺陷 | |||
| 胖 jar 类加载 | pom.xml(requiresUnpack) |
fb90fcd |
IDE 正常、java -jar 下所有加密接口 500 |
|||
| ⑦ | MQ | P1 | 可靠性 | |||
| 无限重投 | RabbitMqConfig / QueueEnum |
2231aab |
消费失败 → nack → 立刻重入队 → 再失败,无 DLQ 兜底 | |||
| ⑧ | 配置 | P1 | 环境不一致 | application-dev.yml |
3277daf |
MinIO 端点 9000 → 9090 (compose 映射是 9090:9000) |
①②③④⑤ 是「同一个病根」的 5 种表现,本文重点; ⑥⑦⑧ 是排查过程中顺带修掉的工程问题,用于说明排查纪律,不占主线。
03
缺陷① 下单超卖
19105d1 · P0 · 库存
1
100 个并发请求,把 10 件库存卖成了 100 单
P0 资损
mall-portal/.../service/impl/OmsPortalOrderServiceImpl.java:734(lockStock)
原始代码 ------ 三步都在 JVM 内存里完成
// master 基线:734 行
private void lockStock(List<CartPromotionItem> cartPromotionItemList) {
for (CartPromotionItem cartPromotionItem : cartPromotionItemList) {
PmsSkuStock skuStock = skuStockMapper.selectByPrimaryKey(...); // ① 读
skuStock.setLockStock(skuStock.getLockStock() + ...getQuantity()); // ② 判断+改(内存)
skuStockMapper.updateByPrimaryKeySelective(skuStock); // ③ 写回
}
}
根因:丢更新(lost update)
三个线程同时读到 lock_stock = 0,各自在内存里加 1 得到 1,然后各自写回 1。 三次「加 1」只留下一次结果 ------ 数据库最终是 1 而不是 3。 但业务上三笔订单都已经创建成功 ,锁定库存却少记了 2 件, 于是可用库存 stock - lock_stock 被高估,系统继续放单 → 超卖 。
这是最典型的「单线程正确、并发错误」
读、判断、写三步分开,任何一步之间都可能被其他线程插入。用 synchronized 能修,但那会退化成单机串行、多实例部署下失效。
实测数据(100 并发线程,真实表 pms_sku_stock,SKU 99,初始库存 10,跑 3 轮)
| 指标 | 实现 A(mall 原版) | 实现 B(修复版) |
|---|---|---|
| 成功下单数 | 100(库存 10 → 超卖 90 件) | 10 ✅ |
最终 lock_stock |
1 ~ 2(丢更新 98~99 次) | 10 ✅ |
| 3 轮一致性 | 每轮都复现超卖 | 每轮恒等于 10 ✅ |
修复:把三步压缩成一条带条件的 UPDATE
<!-- PortalOrderDao.xml -->
<update id="lockSkuStock">
UPDATE pms_sku_stock
SET lock_stock = lock_stock + #{quantity}
WHERE id = #{skuId}
AND stock - lock_stock >= #{quantity} <!-- 条件同时完成库存校验 -->
</update>
// OmsPortalOrderServiceImpl.lockStock(修复后 782 行)
int rows = portalOrderDao.lockSkuStock(skuId, quantity);
if (rows == 0) {
// ApiException 继承 RuntimeException,触发 @Transactional 回滚
Asserts.fail("库存不足,无法下单");
}
为什么这样就好了
① 原子性 :单条 SQL,InnoDB 行锁保证「判断 + 累加」不可分割,不存在中间窗口;
② 无需分布式锁 :数据库行锁天然跨实例有效;
③ 影响行数即结果 :rows == 0 同时表达「库存不足」,省掉一次多余的查询;
④ 事务语义正确 :抛 ApiException 让整个下单事务回滚,不会留下半成品订单。
04
缺陷② 优惠券超发
158381f · P0 · 营销
2
10 张券被领了 100 次 ------ 和超卖一模一样的错
P0 资损
mall-portal/.../service/impl/UmsMemberCouponServiceImpl.java:43(add)
原始代码 ------ 判断与扣减是分开的两步
// master 基线:43 行
SmsCoupon coupon = couponMapper.selectByPrimaryKey(couponId);
if (coupon.getCount() <= 0) { Asserts.fail("优惠券已经领完了"); } // ① 在内存里判断
// ... perLimit 校验 ...
couponHistoryMapper.insert(couponHistory);
coupon.setCount(coupon.getCount() - 1); // ② 内存里减
couponMapper.updateByPrimaryKey(coupon); // ③ 整行写回
和缺陷①结构完全一样 :先查、在内存里判断、再整行写回。 100 个线程同时读到 count = 10,各自判断「还有库存」,各自减 1 写回 ------ 校验形同虚设 。
额外一个细节:updateByPrimaryKey 是整行覆盖 , 还会把其他字段一起写回,存在覆盖他人修改的二次风险。
实测数据(100 并发抢 10 张,跑 3 轮)
100
第 1 轮成功领取
超发 90 张
55
第 2 轮成功领取
超发 45 张
76
第 3 轮成功领取
超发 66 张
三轮数值不同,正是因为竞态的本质是「谁先写回谁覆盖」------ 结果不可预测,这本身就是并发缺陷的特征。
修复:新增 SmsCouponDao,原子扣减
<update id="decreaseCount">
UPDATE sms_coupon
SET count = count - 1,
receive_count = IFNULL(receive_count, 0) + 1
WHERE id = #{couponId}
AND count >= 1 <!-- 判断与扣减合并为一条 SQL -->
</update>
// UmsMemberCouponServiceImpl.add
@Transactional(rollbackFor = Exception.class)
public void add(Long couponId) {
// 内存里的 count 判断保留,但降级为「快速失败」,减少无意义的写库
if (coupon.getCount() <= 0) { Asserts.fail("优惠券已经领完了"); }
// 真正的「不超发」由这一条原子 SQL 保证
if (couponDao.decreaseCount(couponId) == 0) {
Asserts.fail("优惠券已经领完了");
}
couponHistoryMapper.insert(couponHistory); // 扣减成功才写领取记录
}
修复后:3 轮全部恒定 10 张 ✅
还顺手修了一个隐蔽问题:先扣减、后写领取记录 。原顺序下若写记录失败,会留下「券没发出去却有领取记录」的脏数据。
05
缺陷③ 权限缓存刷新竞态
5a1808a · P1 · 权限
3
一次资源变更,20 秒内制造 61,216 次异常
P1 可用性
mall-security/.../component/DynamicSecurityMetadataSource.java:20 / 30 / 44
原始代码 ------ 先清空、再置空
// master 基线
private static Map<String, ConfigAttribute> configAttributeMap = null; // 20 行:没有 volatile
public void clearDataSource() {
configAttributeMap.clear(); // 30 行:先原地清空 → 留下「引用非 null、内容已空」的窗口
configAttributeMap = null;
}
public List<ConfigAttribute> getConfigAttributesWithPath(String path) {
if (configAttributeMap == null) this.loadDataSource(); // 44 行:加载完再回头读一次共享引用
... configAttributeMap.keySet().iterator() ... // 47 行:可能 NPE
}
根因:三个并发缺陷叠在一起
| 问题 | 后果 | 为什么难排查 |
|---|---|---|
clear() 后 = null 之间的窗口 |
请求读到空规则集 ,所需权限集合为空 → 判定无权限 → 接口静默 403 | 不抛异常、不打日志 ,表现为「偶发没权限」,极难定位 |
连续调用两次 clear() |
第二次的 clear() 落在 null 上 → NPE |
只在特定调用时序下出现 |
loadDataSource() 后重读共享引用 |
加载已完成但尚未重读时被其他线程置空 → 拿到 null |
窗口极窄,单线程测试永远不复现 |
实测数据(真实规模:31 条资源规则,20 秒并发压测)
61,216
修复前异常总数
(20 秒)
44,614
其中静默 403
(无异常无日志)
0
修复后
(1110 万次调用零异常)
修复:volatile 快照 + 只做整体替换 + 返回加载结果 + 双重检查锁
private static volatile Map<String, ConfigAttribute> configAttributeMap = null;
// 返回结果给调用方,而不是让它回头再读一次共享引用 ------ 堵住「加载完、重读前被置空」的窗口
private Map<String, ConfigAttribute> reloadDataSource() {
Map<String, ConfigAttribute> loaded = dynamicSecurityService.loadDataSource();
configAttributeMap = loaded;
return loaded;
}
// 只断开引用,不 clear()。旧快照交给 GC,正在用它的请求可以安全读完
public void clearDataSource() { configAttributeMap = null; }
public List<ConfigAttribute> getConfigAttributesWithPath(String path) {
Map<String, ConfigAttribute> snapshot = configAttributeMap; // 整个方法基于这一份快照
if (snapshot == null) {
synchronized (DynamicSecurityMetadataSource.class) { // 只放一个线程去加载
snapshot = configAttributeMap;
if (snapshot == null) { snapshot = reloadDataSource(); }
}
}
if (snapshot == null) { return configAttributes; } // 兜底:不抛异常
...
}
顺带解决了一个放大效应
原来的写法下,快照失效瞬间的所有并发请求都会各自触发一次全表查询 。加双重检查锁后,一次资源变更只放大成一次数据库查询。
面试可以这样讲这个修复的「思维层次」
初级修法是加 synchronized;进阶修法是 volatile + 快照; 真正到位的修法是意识到「先 clear 再置 null」这个顺序本身就是错的 ------ 它人为制造了一个「非 null 但为空」的非法中间态。改成只断引用, 非法状态在结构上就不可能出现,而不是靠加锁去躲开它。
06
缺陷④ 禁用角色不生效
e573cf5 · P0 · 越权
4
后台「禁用角色」按钮点了没用,权限一条不少
P0 越权
mall-admin/src/main/resources/dao/UmsAdminRoleRelationDao.xml:17(getResourceList)
mall-admin/.../service/impl/UmsRoleServiceImpl.java:43(update)
这个缺陷最有价值的地方:它是「两层根因」
第一层在 SQL 层 (查出来的数据本身就不对),第二层在 缓存层 (改了多久才生效)。 只修一层都没用。而拆开这两层的唯一方法,是手动删缓存再验证 ------ 这是排查手法上的关键一步。
第一层根因:算权限的 SQL,JOIN 了角色表却不看 status
<!-- UmsAdminRoleRelationDao.xml:17 -->
<select id="getResourceList">
SELECT ur.id, ur.name, ur.url ...
FROM ums_admin_role_relation ar
LEFT JOIN ums_role r ON ar.role_id = r.id <!-- 表 JOIN 进来了 -->
LEFT JOIN ums_role_resource_relation rrr ON r.id = rrr.role_id
LEFT JOIN ums_resource ur ON ur.id = rrr.resource_id
WHERE ar.admin_id = #{adminId}
AND ur.id IS NOT NULL
<!-- 但完全没看 r.status -->
GROUP BY ur.id
</select>
角色表被 JOIN 进来只为取 id 去关联资源,status 字段自始至终没参与过判定 。 于是「禁用角色」这个后台功能,在权限计算层面完全没有作用 ------ 按钮点了,数据库里 status 变成了 0, 但管理员依然持有该角色下的全部权限。
第二层根因:修改角色时不做缓存失效
// UmsRoleServiceImpl.update:43(master 基线)
public int update(Long id, UmsRole role) {
role.setId(id);
return roleMapper.updateByPrimaryKeySelective(role);
// 只更新数据库,不做任何缓存失效
}
后台的「修改角色状态」接口 POST /role/updateStatus/{id} 走的正是这个方法 。 而权限缓存 key mall:ums:resourceList:<adminId> 的 TTL 是 24 小时 ------ 禁用角色后,最长 24 小时内该角色下所有管理员的权限缓存依然有效。
受控实验(可回滚,角色 5 = 超级管理员)
| 步骤 | 操作 | 修复前 | 修复后 |
|---|---|---|---|
| 1 | 带 token 访问 /role/listAll |
body.code 200 | 200 |
| 2 | POST /role/updateStatus/5?status=0 |
成功 | 成功 |
| 3 | redis-cli EXISTS mall:ums:resourceList:3 |
1(未失效) | 0(已失效)✅ |
| 4 | 手动 DEL 缓存 后再访问 | 仍 200(越权!) | --- |
| 5 | 再次访问 /role/listAll |
--- | 403 没有相关权限 ✅ |
第 4 步是整个排查的转折点
缓存没失效可以解释「为什么还生效」,但不能解释「为什么删了缓存还生效」 。 手动 DEL 之后仍然返回 200,才证明问题不只在缓存 ------ SQL 层本身就是错的。 这就是「两层根因」的拆解方法。
SQL 对照(admin_id = 3)
| SQL 版本 | 返回资源条数 | 结论 |
|---|---|---|
| 原 SQL | 29 条 | 角色已禁用,权限一条不少 |
加 AND r.status = 1 |
0 条 | 禁用生效 ✅ |
受影响的 admin_id 为 1 / 3 / 4,各残留 29 条 → 合计 87 条越权授权 。修复后全部归零。
修复:两层同时修
<!-- ① SQL 层:过滤掉已禁用的角色 -->
WHERE ar.admin_id = #{adminId}
AND ur.id IS NOT NULL
AND r.status = 1
GROUP BY ur.id
// ② 缓存层:更新角色后失效该角色下所有管理员的资源缓存
public int update(Long id, UmsRole role) {
role.setId(id);
int count = roleMapper.updateByPrimaryKeySelective(role);
adminCacheService.delResourceListByRole(id);
return count;
}
一个不能踩的坑:没有顺手「多修」
UmsRoleDao.getResourceListByRoleId 长得很像,但它是后台「角色-资源」配置页用的 ------ 禁用角色也要能查看和编辑它的资源 ,所以那里不能 加 status 过滤。 改动前先确认调用方语义,是避免「修一个坏一个」的关键。
07
缺陷⑤ 订单状态流转
db0336c · P0 · 交易
5
一个 commit 里三个 P0:重复扣库存、订单复活、付款后被关闭
P0 资损
mall-portal/.../service/impl/OmsPortalOrderServiceImpl.java: paySuccess:253 / cancelTimeOutOrder:281 / cancelOrder:297
三个缺陷共享同一根因:全是「先 SELECT 校验 → 再无条件 UPDATE」
SELECT 和 UPDATE 之间是开放窗口 ,任何并发操作都能在窗口里插进来。
状态机全景
0 待付款→ 1 待发货→ 2 已发货→ 3 已完成
0 待付款→ 4 已关闭(超时取消 / 用户取消) 5 无效订单 ------ 全项目从未被写入
缺陷 5-A:支付回调不幂等、不校验状态
// master 基线:253 行
public Integer paySuccess(Long orderId, Integer payType) {
OmsOrder order = new OmsOrder();
order.setId(orderId);
order.setStatus(1); // 无条件覆盖
order.setPaymentTime(new Date());
orderMapper.updateByPrimaryKeySelective(order); // WHERE 只有 id
OmsOrderDetail orderDetail = portalOrderDao.getDetail(orderId);
return portalOrderDao.updateSkuStock(orderDetail.getOrderItemList()); // 无条件扣减
}
| 场景 | 实测证据 |
|---|---|
| 重复回调 | |
| 支付网关重试是常态 | 连续调用两次:sku 201 的 stock 195 → 194 → 193 , lock_stock 1 → 0 → -1 |
| 锁定库存被扣成负数 ------ 这本身就是重复扣减的铁证 | |
| 订单复活 | 对 status=4(已超时取消)的订单 71 回调:状态变 1 、 payment_time 被写入;而取消时已经释放过锁库存、退过券和积分 |
→ sku 217 的 stock 499 → 498、lock_stock 0 → -1 |
附带修复:orderId 不存在时 getDetail 返回 null,原代码会直接 NPE。
缺陷 5-B:取消与支付的 TOCTOU 竞态
// master 基线:297 行
OmsOrderExample example = new OmsOrderExample();
example.createCriteria().andIdEqualTo(orderId).andStatusEqualTo(0); // T2 查询命中
List<OmsOrder> cancelOrderList = orderMapper.selectByExample(example);
// ← ← ← 开放窗口:用户此刻支付成功,status 变成 1,payment_time 写入 ← ← ←
if (cancelOrder != null) {
cancelOrder.setStatus(4);
orderMapper.updateByPrimaryKeySelective(cancelOrder); // T4:WHERE 只有 id,无条件覆盖
}
实测时序
T2 查询命中 → T3 窗口期内用户支付成功(status=1、payment_time 写入) → T4 被覆盖成 status=4,而 payment_time 仍然保留 。
结果:用户付了钱,订单被关闭 ------ 而且后续又执行了一遍释放库存、退券、退积分。
缺陷 5-C:超时取消批量无条件更新
// master 基线:281 行
List<Long> ids = ...; // 扫描出的全部超时订单
portalOrderDao.updateOrderStatus(ids, 4); // WHERE 只有 id IN (...),无 status 条件
for (OmsOrderDetail timeOutOrder : timeOutOrders) {
portalOrderDao.releaseSkuStockLock(...); // 对「全部」扫到的订单执行释放
updateCouponStatus(...); // 无法区分哪几单真的翻转成功
...
}
return timeOutOrders.size(); // 返回值是「扫描到的单数」,不是「真正取消的单数」
修复:统一改为带条件的原子更新 + 影响行数判定
// ① paySuccess:幂等 + 状态校验
OmsOrderExample example = new OmsOrderExample();
example.createCriteria().andIdEqualTo(orderId).andStatusEqualTo(0);
OmsOrder order = new OmsOrder();
order.setStatus(1); order.setPaymentTime(new Date()); order.setPayType(payType);
int updated = orderMapper.updateByExampleSelective(order, example);
if (updated == 0) { return 0; } // 订单不存在/已支付/已取消:不重复扣减库存
return portalOrderDao.updateSkuStock(orderDetail.getOrderItemList());
// ② cancelTimeOutOrder:批量改逐单条件更新
for (OmsOrderDetail timeOutOrder : timeOutOrders) {
OmsOrderExample ex = new OmsOrderExample();
ex.createCriteria().andIdEqualTo(timeOutOrder.getId()).andStatusEqualTo(0);
if (orderMapper.updateByExampleSelective(statusUpdate, ex) == 0) { continue; }
portalOrderDao.releaseSkuStockLock(...); // 只有真正翻转成功的才释放
count++; // 返回值语义改为「真正取消的单数」
}
// ③ cancelOrder:条件更新失败即不做任何释放
if (orderMapper.updateByExampleSelective(statusUpdate, example) == 0) { return; }
一个写法同时解决三个问题
① 状态校验 :status = 0 保证只对「待付款」生效;
② 幂等 :第二次回调影响 0 行,天然幂等;
③ 并发互斥 :靠 InnoDB 行锁,取消与支付竞争时只有一个能成功 ------ 不需要分布式锁 。
修复后三守卫验证(全部通过)
| 守卫 | 操作 | 结果 |
|---|---|---|
| 幂等 | 连续回调两次 | 第 1 次影响 1 行、第 2 次影响 0 行;sku 201 保持 195/1 ✅ |
| 竞态 | 窗口期内支付后再取消 | 取消更新影响 0 行,订单保持 status=1 ✅ |
| 复活 | 对 status=4 回调 |
影响 0 行,状态仍为 4;sku 217 保持 499/0 ✅ |
实验数据已全部恢复到基线(订单分布 0:1 / 1:18 / 2:6 / 3:17 / 4:23),可复核。
关键对照证据 ------ 这是整个排查里最有分量的一条
<!-- mall-admin/src/main/resources/dao/OmsOrderDao.xml:36 ------ 管理端发货 -->
<update id="delivery">
UPDATE oms_order SET delivery_sn = CASE id ... END, `status` = CASE id ... THEN 2 END
WHERE id IN (...)
AND `status` = 1 <!-- ← 正确写法,一直都有 -->
</update>
同一个项目、同一张oms_order 表,后台写对了,用户端没写。 这说明不是「团队不懂」,而是约束没有贯彻到所有调用点 。 面试讲这一条,证明的是横向比对能力,比讲「我修了个 bug」有力得多。
顺带发现的四个状态机「空洞」(设计缺口,不是 bug)
| # | 空洞 | 证据 | 后果 |
|---|---|---|---|
| 1 | 状态 5「无效订单」 | 全项目无任何写入 | 死枚举 |
| 2 | autoConfirmDay 只写不读 |
全项目仅 setAutoConfirmDay 一处引用 |
自动收货未实现 ,已发货订单永不自动完成 |
| 3 | OrderTimeOutCancelTask |
第 14 行是 //@Component |
超时取消只剩 MQ 一条路,无定时对账兜底 |
| 4 | 延迟队列用消息级 TTL | CancelOrderSender 的 setExpiration |
队头阻塞 :短延迟消息要等长延迟消息先过期 |
另一个 P1:/order/paySuccess 绕过验签直接暴露
OmsPortalOrderController:49 只需会员登录,无归属校验、无验签 ------ 任何会员可以传任意 orderId 触发「订单置为已支付 + 扣库存」。 对比 AlipayServiceImpl.notify:81 是做了 AlipaySignature.rsaCheckV1 验签的。 同一份能力,两条入口,一条有防护一条没有。
08
横向分析:为什么会反复犯同一个错
比修 bug 更有价值的部分
把 5 个缺陷放在一起看,能看出三个结构性诱因 。这三个诱因不是 mall 特有的,是绝大多数 CRUD 型 Java 项目的通病。
诱因一:MBG 生成的方法天生缺状态条件
updateByPrimaryKeySelective生成的 SQL WHERE 只有主键- 开发者用它做状态流转,等于默认「状态一定是合法的」
- 想带条件必须改用
updateByExampleSelective或写自定义 SQL ------ 有门槛,容易被跳过
诱因二:分层架构把「校验」和「写入」分开了
- Service 层负责
SELECT+ 业务判断 - Mapper 层负责无条件
UPDATE - 两层之间没有事务隔离保证 ------ 「先查后改」成了默认写法
- 要修必须把判断下推到 SQL 的 WHERE ,这是思维方式的转变
诱因三:单线程测试永远发现不了
- 所有 5 个缺陷在功能测试、接口联调里全部表现为「正常」
- 项目里有单元测试,但没有并发测试
- 缺陷③的静默 403 甚至连异常都不抛 ------ 日志里什么都没有
- 只有构造并发场景才能暴露,这就是为什么必须先建压测环境
一个反直觉的观察
- 缺陷①和②结构完全一样 :查 → 内存判断 → 整行写回
- 但一个在库存模块、一个在营销模块,分属不同开发者
- 说明这不是个人疏忽,是团队层面缺少统一约束
- 对应到工程实践:应该有一个「库存/额度类字段必须原子扣减」的团队规范 + Code Review 检查项
09
可复用的方法论
这套流程本身也是产出
9.1 缺陷排查检查清单
拿到一个业务写操作,按顺序问这几个问题 ------ 5 个缺陷全部是这套清单扫出来的:
| # | 检查项 | 命中过的缺陷 |
|---|---|---|
| 1 | 这个写操作有没有「先查、再判断、后写」?三步之间会不会被并发插入? | ①②⑤ |
| 2 | 状态字段参与判定了吗?还是只被无脑写入? | ⑤(status=0 没进 WHERE) |
| 3 | 用 MBG 生成的 updateByPrimaryKey* 做状态流转了吗? |
⑤ |
| 4 | 共享的可变状态(缓存 / Map / 快照)是否 volatile?是否只做整体替换? |
③ |
| 5 | 数据变更后,所有 受影响的缓存都失效了吗? | ④ |
| 6 | SQL 里 JOIN 进来的表,它的状态字段是不是也应该参与过滤? | ④ |
| 7 | 回调 / MQ 消费是否幂等?重复执行会怎样? | ⑤、⑦ |
| 8 | 同一份能力有没有多个入口?防护是否一致? | ⑤(/order/paySuccess vs 支付宝回调) |
9.2 修复范式:条件更新 + 影响行数
四个缺陷(①②⑤)用的是同一个修复范式 ,可以直接背下来:
UPDATE 表名
SET 目标字段 = 目标字段 ± 变化量
WHERE 主键 = ?
AND 前置条件 ← 校验下推到 SQL
// 判影响行数:0 = 前置条件不满足 = 拒绝本次操作
if (mapper.xxx(...) == 0) { /* 快速失败 / continue / return */ }
一个写法覆盖四种需求
状态校验 (status=0)+ 额度校验 (count>=1)+ 幂等 (重复执行影响 0 行)+ 并发互斥 (InnoDB 行锁串行化)。 不需要 synchronized,不需要 Redis 分布式锁,不需要乐观锁版本号。
9.3 验证方法:受控实验三件套
每个缺陷都按同一套流程验证,保证结论可复核、数据可回滚 :
- 先拿基线数据 ------ 记录操作前的完整状态(订单分布、
stock/lock_stock、缓存 key 存在性) - 构造并发或异常时序 ------ 用压测程序打真实表;时序类缺陷用「手动分步执行生产 SQL 原语句」模拟窗口
- 修复后复跑同一脚本 ------ 数据必须能对回基线,否则说明修复引入了新问题
关于「两层根因」的拆解手法(缺陷④)
当一个缺陷同时涉及「数据对不对」和「缓存新不新」时,手动删缓存是最有效的隔离手段 : 删了还错 → 是数据层问题;删了才对 → 是缓存失效问题。 这个方法能一次性把复合根因拆成两个独立的、可分别验证的假设。
10
简历怎么写
三档颗粒度,按场合选用
版本 A · 一句话(项目名后的括注 / 自我介绍)
在 mall 电商项目上系统性排查并发与状态机缺陷,定位并修复 5 类 P0/P1 问题(超卖、券超发、权限缓存竞态、角色越权、订单资损), 全部以真实数据库受控实验量化验证,累计 17 个文件 696 行改动。
版本 B · 项目描述(简历正文,推荐)
mall 电商平台 · 并发缺陷排查与修复 (Spring Boot 3.5 / MySQL / Redis / RabbitMQ / ES)
- 超卖与超发 :定位下单、领券两处「读---判断---写」非原子导致的丢更新, 压测实测 100 并发抢 10 件库存时超卖 90 件 、抢 10 张券时最多超发 90 张 ; 改为带条件的原子 UPDATE(
stock - lock_stock >= qty),修复后三轮恒等于 10 。 - 订单状态机资损 :发现支付回调不幂等、取消与支付的 TOCTOU 竞态、超时取消批量无条件更新三类 P0; 实测重复回调使
lock_stock由 1 变 -1 、已取消订单被回调复活 ; 统一改为「条件更新 + 影响行数判定」,一次实现状态校验、幂等与并发互斥 ,修复后三个守卫影响行数均为 0。 - 权限越权 :发现后台「禁用角色」功能在权限层面完全无效 (SQL 未过滤角色状态 + 修改角色不失效缓存), 实测 3 个超管账号残留 87 条越权授权 ;两层根因分别修复,修复后越权请求返回 403。
- 权限缓存竞态 :定位共享快照「先 clear 再置 null」产生的非法中间态, 20 秒压测复现异常 61,216 次 (含 44,614 次无日志的静默 403 ); 改为
volatile整体替换 + 双重检查锁,修复后 1110 万次并发调用零异常 。 - 共性问题归纳 :5 类缺陷同一病根 ------「读---判断---写」未原子化、状态变更缺前置条件; 输出可复用的 8 项排查清单与「条件更新 + 影响行数」修复范式。
版本 C · STAR(面试口述,2 分钟版)
S(情境) :我在拿 mall 这个开源电商项目做深度学习,读下单链路时发现库存扣减写得很奇怪 ------ 先 selectByPrimaryKey 查出来,在 Java 里加一下,再整行写回去。
T(任务) :我想验证这到底有没有问题,于是搭了 MySQL + Redis + RabbitMQ + ES 的完整环境,写压测程序打真实表。
A(行动) :100 并发抢 10 件库存,原版成功下单 100 笔、超卖 90 件 。 确认是丢更新后,我把「判断 + 累加」压进一条带条件的 SQL。 然后我意识到这可能不是孤例 ------ 顺着这个模式去扫其他写操作,果然在领券、权限缓存、订单状态流转里 找到了结构完全一样的 4 个缺陷 ,包括一个让「禁用角色」功能完全失效的越权问题。 所有修复都用「先记录基线 → 构造并发 → 复跑对回基线」的流程验证。
R(结果) :5 类缺陷全部修复,17 个文件 696 行改动,10 个提交。 比修 bug 更有价值的是,我归纳出了这类缺陷的识别清单 ------ 现在拿到一段业务写代码,我能比较快地判断它有没有并发风险。
写作要点(这几条决定了这份材料有没有说服力)
① 每个结论后面必须跟数字 ------ 「超卖 90 件」比「存在超卖问题」强十倍;
② 写「怎么发现的」,不只写「修了什么」 ------ 排查过程体现能力,修复结果只体现工作量;
③ 写「同类问题还有几处」 ------ 证明是模式识别而非碰运气;
④ 给出可回滚的验证方式 ------ 说明结论可信,不是「我觉得修好了」。
11
面试追问预演
10 个高概率问题
你怎么保证这个原子更新真的能防住并发?数据库层面发生了什么?
UPDATE ... WHERE id = ? AND 条件 执行时,InnoDB 会对命中的行加排他锁(X 锁) 。 并发事务在同一行上会串行等待,第一个事务提交后第二个才能继续,此时它重新读到的 lock_stock 已经是更新后的值,条件判定基于最新数据 ------ 所以「判断 + 累加」在数据库层面是不可分割的。 影响行数 0 就表示条件不满足。整个过程不需要应用层加锁。
为什么不用 synchronized 或者 Redis 分布式锁?
synchronized 只在单 JVM 内有效,多实例部署直接失效,而且会把并发请求串行化、拖垮吞吐。 Redis 分布式锁能跨实例,但引入了新的失败模式:锁超时、锁误删、Redis 不可用时怎么办 ------ 为了一个数据库本来就能解决的问题,引入了一个分布式一致性问题 。 这里库存扣减天然落在数据库上,用数据库自己的行锁是最短路径、最少依赖的方案。 需要分布式锁的场景是「跨多个资源、无法用单条 SQL 表达」的临界区。
支付回调幂等,用「查一次订单状态再判断」不行吗?
不行,那还是「先查后改」,只是把窗口缩小了,没有消除。 正确做法是把状态条件放进 UPDATE 的 WHERE 里 , 用影响行数判定 ------ 这样「校验」和「写入」是同一条 SQL 的原子操作, 中间不存在任何可插入的窗口。这是本质区别,不是优化。
如果并发量再大一个数量级,你这个方案还成立吗?
成立,但瓶颈会转移到数据库行锁的争抢上 ------ 同一行 SKU 的所有请求会串行等待。 量级再上去的优化方向:① 库存分片 (把 1000 件拆成 10 个桶各 100 件,请求按 SKU 分片路由,把行锁竞争降一个数量级); ② Redis 预扣减 + 异步落库 (Redis 用 Lua 脚本保证原子性,把校验前置到内存层,数据库只做最终一致); ③ 排队削峰 (请求先进 MQ,消费端串行处理)。 但要注意:这些都是在正确性已经保证的前提下 做的性能优化。 先让它对,再让它快 ------ 顺序不能反。
你提到「禁用角色不生效」有两层根因,怎么确定是两层的?
关键在于把两层拆开验证 。缓存没失效只能解释「为什么改完还生效」, 解释不了「为什么删了缓存还生效」。所以我手动执行 redis-cli DEL 删掉缓存, 然后再次请求 ------ 仍然返回 200 。这就证明 SQL 层本身就是错的。 如果删完缓存就对了,那才是单纯的缓存失效遗漏。 这个手法可以推广:复合根因的缺陷,要找到一个能隔离各层的操作 , 分别验证每个假设,而不是一起改。
权限缓存的竞态,为什么 clear() 之后还要 = null?直接 clear() 不行吗?
两个都错,而且错法不同。只clear() : getConfigAttributesWithPath 里的判空条件是 map == null, 清空后引用还在,不会触发重新加载 ------ 规则集永远是空的,所有接口全部 403 。 先clear() 再 = null :中间存在「引用非 null、内容已空」的窗口, 窗口期内进入的请求读到空规则集 → 静默 403;连续调用两次还会 NPE。 正确做法是只断开引用 ,不对旧快照做原地修改 ------ 正在使用旧快照的请求可以安全读完,非法中间态在结构上就不可能出现。
你说修复后「1110 万次调用零异常」,这个测试是怎么设计的?
多线程并发调用 getConfigAttributesWithPath, 同时另一个线程高频执行 clearDataSource() 制造快照失效 ------ 也就是把「资源变更」这个低频动作在测试里放大成高频,让竞态窗口被反复命中。 用真实规模的 31 条资源规则,20 秒压测。 修复前统计到 61,216 次异常(其中 44,614 次是静默 403,需要断言「返回的权限集合非空」才能捕获, 因为它不抛异常);修复后同样负载下异常数归零。 测试用例已提交(DynamicSecurityMetadataSourceRaceTest,237 行)。
这些缺陷你是怎么发现的?是看代码看出来的吗?
起点是读下单链路时对一段代码产生了怀疑 ------ 它把「读、改、写」三步都放在 Java 内存里。 验证之后,我意识到这不是个例,而是一类模式 : 「读---判断---写」未原子化、状态变更缺前置条件。 于是我把这个模式当成检索条件,去扫其他所有写操作,才找到后面几个。 另外还用了横向比对 :同一个项目里管理端发货用的是带状态条件的 SQL, 说明正确写法团队是会的,只是没贯彻到用户端 ------ 这种「同一个项目里同一件事做对过一次」 往往是最强的线索。
修复这些缺陷,你怎么保证没有引入新问题?
三条纪律: ① 先记录基线 ------ 操作前把完整状态记下来(订单各状态分布、stock/lock_stock、缓存 key); ② 用可回滚的方式验证 ------ 时序类缺陷不靠「制造真实并发」碰运气, 而是手动分步执行生产 SQL 的原语句来精确模拟窗口期,完全可控; ③ 修复后复跑同一脚本,数据必须能对回基线 ------ 对不回就说明改动影响了预期之外的状态。 另外每个修复都单独一个 commit,出问题可以精确回退。
如果让你给这个项目提一个改进建议,你会说什么?
最该做的是把「库存/额度类字段必须原子扣减」变成团队规范和 Code Review 检查项 。 因为缺陷①和②结构完全一样,却分属库存和营销两个模块、由不同人写 ------ 这说明问题的根源不在个人能力,而在缺少统一约束 。 具体落法:① 代码扫描规则,禁止对 stock/count/lock_stock 这类字段使用 updateByPrimaryKey*;② 加并发回归测试进 CI; ③ 订单状态流转统一收口到一个 OrderStateMachine 组件里, 把「合法流转路径」显式建模,而不是散落在各个 Service 里用 setStatus 裸写。
12
提交清单与验证环境
可复核
12.1 分支 fix-reliability-and-security(基线 dcaa93b)
db0336c fix(portal): 订单状态流转改为带条件的原子更新(消除资损与竞态) 54+ 12-
e573cf5 fix(admin): 让「禁用角色」真正撤销权限(SQL 过滤 + 缓存失效) 22+ 1-
fb90fcd fix(build): 修复胖 jar 下 BouncyCastle JCE 签名校验失败 62+
158381f fix(portal): 用原子扣减消除优惠券超发 55+ 6-
5a1808a fix(security): 消除权限缓存刷新的并发竞态(含 237 行并发测试) 288+ 7-
9908a54 chore(docker): 新增本机中间件修正配置与避坑说明 102+
3277daf fix(admin): 修正 MinIO 端点端口 9000 -> 9090 1+ 1-
2231aab fix(mq): 订单取消消息补死信队列与消费重试 70+ 2-
19105d1 fix(portal): 用原子 SQL 消除下单超卖 42+ 6-
dcaa93b ← master 基线
合计 17 个文件、696 行新增、35 行删除 。工作区干净,git status 无未提交改动。
12.2 验证环境(全部实测跑通)
| 组件 | 端口 | 版本 / 说明 |
|---|---|---|
| MySQL | 3306 | 76 张表,修复验证全部打真实表 |
| Redis | 6379 | 缓存失效验证(EXISTS / DEL) |
| Elasticsearch | 9200 | 7.17.24 + analysis-ik |
| RabbitMQ | 5672 | vhost /mall,DLQ 验证 |
| MongoDB | 27017 | --- |
| MinIO | 9090 | 注意宿主机端口是 9090(非默认 9000) |
| mall-admin | 8080 | 权限类缺陷验证 |
| mall-portal | 8085 | 库存 / 券 / 订单类缺陷验证 |
| mall-search | 8081 | 搜索链路 |
| mall-admin-web | 5173 | Vite 7.3.0 |
12.3 压测与复现程序
| 程序 / 脚本 | 用途 |
|---|---|
电商学习/压测复现/CouponOversellTest.java |
优惠券超发复现(100 并发) |
DynamicSecurityMetadataSourceRaceTest.java |
权限缓存竞态回归测试(237 行,已提交) |
| 订单状态流转三守卫验证 | 手动分步执行生产 SQL 原语句,精确模拟窗口期 |
配套文档
每类缺陷都有独立深挖报告: 下单链路代码地图与超卖缺陷分析.html、 优惠券超发缺陷复现与修复-mall电商项目.html、 权限缓存并发缺陷复现与修复-mall电商项目.html、 缓存设计深挖与禁用角色越权缺陷-mall电商项目.html、 订单状态机深挖与订单流转缺陷-mall电商项目.html。 本文是它们的汇总与提炼。
mall 电商项目学习笔记 · 2026-09-21 · 缺陷修复总览
分支 fix-reliability-and-security(基线 dcaa93b)| 17 文件 / 696+ / 35− | 5 类核心缺陷 + 3 项工程问题 / 9 个提交
全部结论均以真实数据库受控实验复现,数据可回滚核对