缺陷修复总览 · mall电商项目:5类缺陷,1个病根,4个业务域

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.xmlrequiresUnpack 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:734lockStock

原始代码 ------ 三步都在 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:43add

原始代码 ------ 判断与扣减是分开的两步
复制代码
// 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:17getResourceList

mall-admin/.../service/impl/UmsRoleServiceImpl.java:43update

这个缺陷最有价值的地方:它是「两层根因」

第一层在 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.javapaySuccess:253cancelTimeOutOrder:281cancelOrder: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 → 193lock_stock 1 → 0 → -1
锁定库存被扣成负数 ------ 这本身就是重复扣减的铁证
订单复活 status=4(已超时取消)的订单 71 回调:状态变 1payment_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 CancelOrderSendersetExpiration 队头阻塞 :短延迟消息要等长延迟消息先过期

另一个 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 验证方法:受控实验三件套

每个缺陷都按同一套流程验证,保证结论可复核、数据可回滚

  1. 先拿基线数据 ------ 记录操作前的完整状态(订单分布、stock / lock_stock、缓存 key 存在性)
  2. 构造并发或异常时序 ------ 用压测程序打真实表;时序类缺陷用「手动分步执行生产 SQL 原语句」模拟窗口
  3. 修复后复跑同一脚本 ------ 数据必须能对回基线,否则说明修复引入了新问题

关于「两层根因」的拆解手法(缺陷④)

当一个缺陷同时涉及「数据对不对」和「缓存新不新」时,手动删缓存是最有效的隔离手段 : 删了还错 → 是数据层问题;删了才对 → 是缓存失效问题。 这个方法能一次性把复合根因拆成两个独立的、可分别验证的假设。

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, 清空后引用还在,不会触发重新加载 ------ 规则集永远是空的,所有接口全部 403clear()= 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 个提交

全部结论均以真实数据库受控实验复现,数据可回滚核对

相关推荐
此时不提桶,更待何时2 小时前
01-06-A-JVM排查实战详解
java·jvm
vipxieliang3 小时前
ValidX 在 DDD 领域驱动设计中的实践
java·spring boot
ba_pi3 小时前
mysql 查询所有表名并授权
java
ProcessOn官方账号3 小时前
java函数式编程--入门基础
java·编程·函数式编程
一嘴一个橘子4 小时前
优惠券秒杀思路,redis 分布式锁 方式
java
xufengzhu4 小时前
Java 虚拟线程实战指南(JDK 21+)
java·虚拟线程·jdk21+
大猫和小黄4 小时前
吃透Java泛型:从原理避坑到实战落地
java·泛型
Bingo_BIG5 小时前
Java Spring框架:单表的查询、新增、修改、删除、导入、导出
java·spring·前后端
程序猿_极客5 小时前
【免费】分享一套优质的基于SSM的校园失物招领系统的设计与实现,源码+文档+视频详解(讲解)
java·mysql·ssm·课程设计·失物招领系统