采云台 · M6-M8 目录与申领车 · 深度分析

模块范围:M6 物资目录 · M7 组合申领包 · M8 申领车
对应第三、四部分(完整执行链路 + 为什么这么设计)及第五、六、七部分
依据:backend/cai-yun-tai 实际源码逐行核验;mvn -o -DskipTests compile 退出码 0
为什么合并:这三个模块单独都不足以支撑一场面试追问,但它们之间有一条共同的主线——「服务端的权威数据与客户端的信任边界」,以及一个反复出现的反模式——「先查再改」。三个模块正好是这个反模式的三个变体,对比着讲比单独讲每个都更有说服力。


零、三个模块的定位(先建立对比框架)

模块表读的频次写的频次核心问题面试价值
M6 物资dish + dish_flavor极高(每个员工进申领页都拉)极低一对多组装导致 N+1,且被缓存掩盖★★★★
M7 组合包setmeal + setmeal_dish高极低快照字段信任前端 + 跨模块状态联动★★★
M8 申领车shopping_cart中高(每次加/减都写)“先查再改”竞态 + 规格合并逻辑★★★

贯穿三者的主线:

M6:listWithSpecs 是【循环里查库】→ 性能问题(可优化)
M7:comboItems 的 name/price 【来自前端】→ 信任边界问题(安全)
M8:addShoppingCart 是【先查再改】→ 并发正确性问题(安全+正确性)
                    ↓
        M8 的 M2 对比:M2 用"条件 UPDATE"解决了同类问题,
        但 M8 【没有】用同样的解法 —— 同一项目里两种做法的不一致

最后这一条是这三个模块里最有价值的面试素材:因为它展示了你能识别出自己项目内部的方案不一致,而不是只会背单个知识点的答案。


M6 物资目录

一、业务场景

物资是申领的标的物。一条物资的数据结构是:

dish(物资)
 ├── name / price / image / description
 ├── spec(规格型号文本,如 "A4/70g")+ unit(计量单位,如 "包")   ← 采购场景新增
 ├── category_id → category
 └── status(0 停用 / 1 启用)
       │
       └── dish_flavor(一对多:规格可选值)
             ├── name  = "颜色" / "型号"
             └── value = "黑,白" / "A4,A3"        ← 多个值用逗号存在一个字段里

注意 dish_flavor 的设计:它不是”一个规格一行”,而是 value 字段里用逗号分隔存多个可选值("黑,白")。所以 dish_flavor 是”规格维度表”,不是”规格值表”。这个设计来自原项目,改造成采购场景时保留了——因为采购物资的规格维度通常也是”颜色可选几种、尺寸可选几种”。

代价:value 存逗号分隔的字符串,数据库层无法约束、无法按单个值查询(要 FIND_IN_SET 或 LIKE '%黑%')。当前项目没有任何按规格值查询/筛选的功能,所以这个代价还没显现。

二、核心类与执行链路

★★★ 链路 1:员工端物资列表(GoodsServiceImpl.listWithSpecs 的 N+1)

GET /user/goods/list?categoryId=23
   ↓ JwtTokenUserInterceptor(认证)
   ↓ user/GoodsController.list  → Redis GET goods:list:23
   │      命中 → 直接返回(0 次 SQL)
   │      未命中 ↓
   ↓ GoodsServiceImpl.listWithSpecs(goods)          ← 第 212-229 行
   │
   ├─【1】List<Goods> goodsList = goodsMapper.list(goods)              ← ★ 第 1 次 SQL
   │      SELECT * FROM dish
   │       <where> name LIKE ? / category_id = ? / status = ? </where>
   │       ORDER BY create_time DESC
   │
   └─【2】for (Goods d : goodsList) {                                   ← ★ 循环 N 次
             List<GoodsSpec> flavors = goodsFlavorMapper.getByGoodsId(d.getId());   ← 第 N 次 SQL
             GoodsVO goodsVO = new GoodsVO();
             BeanUtils.copyProperties(d, goodsVO);
             goodsVO.setFlavors(flavors);
             goodsVOList.add(goodsVO);
          }
   ↓ Redis SET goods:list:23(TTL 1800 + random(0,300)s)

总 SQL 次数 = 1 + N(N = 该分类下在售物资数)。

【为什么这个 N+1 一直没被发现 —— 这是本模块最值得讲的一点】

因为它被缓存掩盖了:

状态每次请求的 SQL 次数
缓存命中(30~35 分钟内的绝大多数请求)0
缓存未命中(每 30 分钟一次、或被清缓存后)1 + N

所以从”用户感知”上,这个接口一直都很快。 但这是脆弱的掩盖,有三个时刻会暴露:

  1. Redis 重启/清空后的冷启动:所有分类的缓存都不在,第一批请求全部回源,N+1 × 分类数同时打库;
  2. 管理员改物资后:cleanCache("goods:list:*") 是通配清除(见 M5-3),一次修改让所有分类的缓存失效 → 后续请求全部回源;
  3. Redis 内存打满时(maxmemory-policy 未配置,默认 noeviction,写入会失败):缓存写不进去,每个请求都走 N+1。

【面试标准答法】

「这里有一个 N+1:一次查询物资列表,然后循环里逐条查规格,所以是 1+N 次 SQL。它一直没暴露问题,是因为缓存掩盖了它——命中缓存时一次库都不查。

但这是脆弱的掩盖:只要缓存大面积失效(Redis 冷启动、或者管理员改物资触发通配清缓存),N+1 就会在重建时被集中触发。所以正确的判断是:缓存降低了对 N+1 的敏感度,但没有消除这个缺陷。

修法:把这一页的物资 id 收集起来,一次 SELECT * FROM dish_flavor WHERE dish_id IN (...) 查完,再用 Collectors.groupingBy(GoodsSpec::getGoodsId) 在内存分组,最后填回每个 GoodsVO。从 1+N 降到 2 次查询。项目里同一个模式还有两处(员工端申领单分页、管理端申领单搜索),三处应该一起改。」

链路 2:管理员新增/修改物资(含规格的删除重插)

【新增】POST /admin/goods  @RequireRole(ADMIN)
   ↓ GoodsServiceImpl.saveWithSpecs(dto)   @Transactional      第 48-68 行
   │   ├─ BeanUtils.copyProperties(dto, goods)
   │   ├─ goodsMapper.insert(goods)      ← @AutoFill(INSERT) 填 create/update 四字段
   │   │     useGeneratedKeys 回填 goods.id
   │   └─ if (flavors != null && flavors.size() > 0)
   │           flavors.forEach(f -> f.setGoodsId(goodsId))
   │           goodsFlavorMapper.insertBatch(flavors)    ← 一条 INSERT ... VALUES (...),(...)
   ↓ admin/GoodsController.cleanCache("goods:list:*")     ← 通配清除(M5-3)

【修改】PUT /admin/goods  @RequireRole(ADMIN)
   ↓ GoodsServiceImpl.updateWithSpecs(dto)   @Transactional     第 140-159 行
   │   ├─ goodsMapper.update(goods)                       ← 动态 <set>,只改非 null 字段
   │   ├─ ★ goodsFlavorMapper.deleteByGoodsId(dto.getId())   ← 【先全删】
   │   └─ ★ 再 insertBatch(flavors)                          ← 【再全插】
   ↓ cleanCache("goods:list:*")

【“先全删、再全插”这个模式的分析】

维度评价
正确性✅ 放在 @Transactional 里,所以是原子的(删完插入失败会一起回滚)。这一点是对的——原缺陷清单里记的”updateWithSpecs 缺事务”已经修复
代价规格行的 id 全部变化(不是 UPDATE,是 DELETE + INSERT)。所以如果有别的地方引用 dish_flavor.id,引用会失效。当前项目没有任何地方引用规格 id,所以没问题
优点逻辑简单:不用做”哪些要删、哪些要改、哪些要新增”的 diff。对一个小的一对多集合,这是最常见也最可接受的实现
⚠️ 隐患flavors 为空时会静默清空所有规格——if (flavors != null && flavors.size() > 0) 这个判断意味着:如果调用方传了 flavors: null 或 flavors: [],前面的 deleteByGoodsId 已经执行了,但后面不插入 → 规格被清空且不报错。前端如果没把回显的规格带回来,保存一次规格就没了

链路 3:物资停售时的级联(跨模块联动的核心,也是 M5-1 的现场)

POST /admin/goods/status/{status}  @RequireRole(ADMIN)
   ↓ admin/GoodsController.startOrStop(status, id)            第 131-138 行
   ↓ GoodsServiceImpl.startOrStop(status, id)   @Transactional  第 167-191 行
   │   ├─ goodsMapper.update(Goods{id, status})               ← 改物资状态
   │   └─ ★ if (status == StatusConstant.DISABLE) {            ← 只有停用时联动
   │          List<Long> comboIds = comboDishMapper.getComboIdsByGoodsIds([id]);
   │             SQL: SELECT setmeal_id FROM setmeal_dish WHERE dish_id IN (?)
   │          for (Long comboId : comboIds) {
   │              comboMapper.update(Combo{id: comboId, status: DISABLE});   ← 级联停用组合包
   │          }
   │      }
   ↓ ★ cleanCache(RedisKeyConstant.GOODS_LIST_PREFIX + "*")    ← 【只清物资缓存!】
   ↓
   ⚠️⚠️⚠️ 上一步【修改了 setmeal.status】,但这里【没有清 combo:list:*】
            → 员工端组合包缓存里继续显示已停用的组合包,最长 35 分钟(TTL)
            → 这正是 M5-1 记录的缓存一致性 bug

【这个联动设计的意图是对的】:保证”不会申领到一个包含已停用物资的组合包”。因为员工端能看到的组合包都是启用的,如果组合包里有停用物资、组合包还是启用的,员工就能申领到停用物资——联动停用把这个漏洞堵住了。

【但它有两个配套缺陷】:

  1. 清缓存漏了(M5-1)——数据库层面堵住了,但缓存层没堵,所以实际仍然可能申领到;
  2. GoodsMapper.getByComboId 不过滤 status(第 82-83 行):
@Select("select a.* from dish a left join setmeal_dish b on a.id = b.dish_id where b.setmeal_id = #{comboId}")
List<Goods> getByComboId(Long comboId);

它被 ComboServiceImpl.startOrStop 用来做”启用组合包前校验包内物资是否都启用”——这个校验是生效的(不过滤反而正确,因为它要拿到全部物资再逐个检查 status)。

三、M6 的其他设计取舍

设计点评价
删除物资前的双重引用检查✅ deleteBatch 第 88-103 行:① 逐个检查是否在售(在售不能删);② 查是否被组合包引用(被引用不能删)。两道检查都在删除之前,逻辑正确。而且组合包用的是 getComboIdsByGoodsIds(ids) 一次 IN 查询,不是循环——这一点做对了
删除含规格的物资✅ 手动删 dish_flavor(deleteByGoodsId),因为没有外键。放在 @Transactional 里,是对的。代价:每次新增实体都要记得手工清理子表,漏了就是孤儿数据
d ish没有ON DELETE CASCADE` 外键⚠️ 全项目没有外键。好处是插入顺序自由、不影响性能;代价是引用完整性完全靠应用层保证——项目已经因此产生过孤儿预算行(M2-13,回归脚本第 27 节要专门清理)
updateWithSpecs 支持改 categoryId⚠️ 改了分类之后,旧分类的缓存也要失效。因为 cleanCache 是通配清除,恰好覆盖了——但这属于”因为清得足够多所以没出错”,不是设计正确
getByIdWithSpecs 用于回显两次查询(物资 + 规格),单条查询的 N+1。管理端回显场景,可接受
spec 和 unit 是纯文本⚠️ 没有字典/校验,管理员可以随便填。作为展示字段可以接受

M7 组合申领包

一、业务场景

组合包 = 把若干物资打成一个包(如”新员工入职办公包”),员工一次申领一个包。

setmeal(组合包)
 ├── name / price / image / description / category_id / status
 └── setmeal_dish(关联表,含冗余快照)
       ├── setmeal_id → setmeal.id
       ├── dish_id    → dish.id
       ├── name       ← ★ 冗余:物资名称的快照
       ├── price      ← ★ 冗余:物资单价的快照
       └── copies     (份数)

【为什么关联表要冗余 name 和 price?】

这是一个有意为之的反范式,代码注释里也写明了(demo_data.sql):

setmeal_dish 冗余保存了物资名称与单价,属于有意的快照设计,使套餐价格与物资后续调价解耦。

核心论证:

  • 如果不冗余:组合包详情要 JOIN dish 拿 name/price。物资调价之后,历史组合包的”构成清单”会跟着变——你会看到”2025 年的入职包里,A4 纸的价格是 2026 年的价格”,这是错的;
  • 冗余之后:setmeal_dish 存的是”创建这个组合包时,包内物资是什么、多少钱”,这是一个时间点快照,不受后续调价影响。

【但这只是”半个快照”——必须主动指出的缺口】

组合包自身的 setmeal.price(包的售价)是独立字段,不是 sum(item.price × copies)。所以:

  • ✅ 包价和物资价格是解耦的(物资涨价不会自动改包价)——这是对的;
  • ❌ 但”包价 vs 包内物资成本”没有校验:管理员可以把包价设成 1 元,而包内物资成本 200 元。没有任何地方检查这个差额。对”企业内部申领”场景可能无所谓(不涉及利润),但它意味着”包价是不可信的、与内容无关的数字”——如果以后要做成本核算或预算控制,这里是个漏洞。

二、核心类与执行链路

★★★ 链路 1:新增/修改组合包(快照字段信任前端 —— 本模块的核心问题)

【新增】POST /admin/combo  @RequireRole(ADMIN)
   ↓ ComboServiceImpl.saveWithItems(dto)   @Transactional      第 49-70 行
   │   ├─ BeanUtils.copyProperties(dto, combo)      ← combo.name / combo.price(包价)
   │   ├─ comboMapper.insert(combo)                 ← @AutoFill(INSERT),回填 id
   │   ├─ Long comboId = combo.getId()
   │   ├─ comboItems.forEach(cd -> cd.setComboId(comboId))     ← 只补 comboId
   │   └─ comboDishMapper.insertBatch(comboItems)
   │         INSERT INTO setmeal_dish (setmeal_id, dish_id, name, price, copies)
   │         VALUES (?, ?, #{sd.name}, #{sd.price}, #{sd.copies})   ← ★★★ 直接写前端传的值
   ↓ admin/ComboController.cleanComboCache()

【修改】PUT /admin/combo  @RequireRole(ADMIN)
   ↓ ComboServiceImpl.update(dto)   @Transactional             第 123-147 行
   │   ├─ comboMapper.update(combo)                  ← 改包的基本信息
   │   ├─ comboDishMapper.deleteByComboId(comboId)   ← 【先全删关联】
   │   └─ comboDishMapper.insertBatch(comboItems)    ← 【再全插关联】(同样是快照值)
   ↓ cleanComboCache()

【问题:ComboItem.name / ComboItem.price 完全来自前端请求体】

ComboDTO 里是:

private List<ComboItem> comboItems = new ArrayList<>();

而 ComboItem 就是提交的数据结构:

public class ComboItem {
    private Long comboId;
    private Long goodsId;
    private String name;        // ← 前端传什么就存什么
    private BigDecimal price;   // ← 前端传什么就存什么
    private Integer copies;
}

而前端确实是这么传的(admin-ui/console.js):

onPickGoods(row) {
  const d = this.allGoodsList.find((x) => x.id === row.goodsId);
  if (d) {
    row.name = d.name;        // ← 前端从它自己缓存的物资列表里取 name
    row.price = d.price;      // ← 前端从它自己缓存的物资列表里取 price
  }
},
// 提交时
const payload = Object.assign({}, this.comboForm, { comboItems: goodsList });

所以快照的来源是”客户端从自己缓存里取、再回传给服务端”。服务端完全不校验。

【影响评估(要诚实,不要夸大也不要缩小)】

场景影响
正常的 UI 流程⚠️ 前端缓存的物资列表可能过期(allGoodsList 来自 GET /admin/goods/list,而缓存 TTL 是 30 分钟、且有清缓存机制)。如果管理员在另一个标签页改了物资价格,当前标签页的 allGoodsList 还是旧价 → 快照存的是旧价。影响很小(只是展示快照),但它证明”快照值不等于服务端权威值”
恶意/被篡改的请求传 price: 0 或 price: 0.01 → 数据库里存下假的成本快照。包价(setmeal.price)不受影响(那是另一个字段,管理员单独填),所以用户付的钱不会变
后果的严重性中等偏低。因为快照字段只用于展示”包内物资构成”,不参与金额计算(submitOrder 用的是 shopping_cart.amount,也就是组合包的 price,不是快照价)

【为什么仍然应该修 —— 这是”信任边界”原则的问题】

根本问题不是”这次影响有多大”,而是”服务端把权威数据的决定权交给了客户端”。

因为 goodsId 已经在请求里了,服务端凭它就能查出权威的 name 和 price:

// 正确的做法(当前未实现)
for (ComboItem item : comboItems) {
    Goods goods = goodsMapper.getById(item.getGoodsId());
    if (goods == null) {
        throw new DeletionNotAllowedException("组合包内的物资不存在");
    }
    item.setName(goods.getName());      // ★ 用服务端查出来的权威值覆盖前端传的值
    item.setPrice(goods.getPrice());
}

这个修法顺便解决了另一个当前缺失的校验:现在完全不检查 goodsId 是否存在。如果传一个不存在的 goodsId,comboDishMapper.insertBatch 会把它写进 setmeal_dish(因为没有外键)→ 产生一条指向不存在物资的关联。之后 ComboServiceImpl.startOrStop 启用组合包时,goodsMapper.getByComboId 用的是 LEFT JOIN,这条关联会返回 null 而不是报错——孤儿关联静默存在。

【与 M3 那个已修复缺陷的对比 —— 这是最有价值的面试点】

「这个问题的形态和申领单那个已修复的缺陷完全一样:申领单原来是”前端算好金额告诉后端扣多少”,我修成了”后端按申领车逐项重算,完全忽略前端的 amount”。而组合包的 comboItems 里的 name/price 现在还是信任前端的——同一个项目里,同类问题我修了一处、漏了一处。

区别在于影响面:申领单那个会直接导致资金错误(可以零元提交、甚至负数反向下减预算),是 P0;组合包快照这个只影响展示,不改包价、不影响用户支付,所以严重性低得多。但根因是同一个:服务端对自己已经能查到的权威数据,选择了信任客户端。

修法也一样简单:goodsId 已经在请求里,服务端查一次库覆盖掉 name/price 即可,还顺带补上了”校验物资是否存在”(现在这个校验也完全没有)。」

链路 2:启用组合包前的校验(唯一一个”把非法状态挡在写入之前”的设计)

POST /admin/combo/status/{status}  @RequireRole(ADMIN)
   ↓ ComboServiceImpl.startOrStop(status, id)              第 150-173 行
   │   ├─ ★ if (status == StatusConstant.ENABLE) {           ← 只在启用时校验
   │   │      List<Goods> goodsList = goodsMapper.getByComboId(id);
   │   │         SQL: SELECT a.* FROM dish a
   │   │              LEFT JOIN setmeal_dish b ON a.id = b.dish_id
   │   │              WHERE b.setmeal_id = ?
   │   │      if (goodsList != null && goodsList.size() > 0) {
   │   │          goodsList.forEach(goods -> {
   │   │              if (StatusConstant.DISABLE == goods.getStatus())
   │   │                  throw new ComboEnableFailedException(
   │   │                      "组合申领包内包含已停用物资,无法启用");
   │   │          });
   │   │      }
   │   │   }
   │   └─ comboMapper.update(Combo{id, status})
   ↓ cleanComboCache()

【这个设计的两面】

✅ 正确的部分 —— “校验前置”:

它把校验放在写操作之前,所以非法状态(包含停用物资的组合包)根本不会被写入。这比”写进去之后再靠别的地方拦”要好——数据库里永远不存在”启用的组合包包含停用物资”这个状态(理想情况下)。

⚠️ 不完整的部分 —— 校验只覆盖了”启用”这一个入口:

导致”启用的组合包含停用物资”的路径当前是否被拦住
管理员直接启用组合包✅ 拦住(就是这段代码)
管理员停用组合包里的物资✅ 拦住——GoodsServiceImpl.startOrStop 有级联停用组合包的逻辑
管理员把停用物资加进已启用的组合包❌ 没拦!ComboServiceImpl.update(修改组合包)完全不校验包内物资的 status。所以:停用一个物资 → 建/改一个组合包把它加进去 → 组合包是启用的、里面是停用物资。这条路径绕过了所有校验
组合包启用后,物资被停用但不经过 GoodsServiceImpl.startOrStop(比如直接改数据库)❌ 没拦(但这是绕过应用层,可接受)

所以”数据库里永远不存在非法状态”这个说法是不成立的——修改组合包这条路径能造出非法状态。

【面试标准答法】

「这里有一个校验前置的设计,思路是对的:启用组合包时先查包内物资的状态,有停用的就抛异常,所以非法状态不会被写进数据库。配合物资停用时的级联停用组合包,两边形成闭环。

但这个闭环有三个缺口,我要说清:

第一,修改组合包这条路径没校验。 ComboServiceImpl.update(以及 saveWithItems 新增)完全不检查包内物资的 status。所以可以:先停用一个物资 → 再编辑某个已启用的组合包、把这个停用物资加进去 → 得到一个”已启用且包含停用物资”的组合包。这条路径绕过了启用时的校验和级联停用两道防线。

第二,级联停用改了数据库但没清缓存(M5-1),所以实际上员工在 30~35 分钟内仍然能看到、并且能申领这个本该停用的组合包——数据库层堵住了,缓存层没堵。

第三,/user/combo/goods/{id} 这个查包内物资明细的接口,返回的 GoodsItemVO 只有 name/copies/image/description,【根本没有 status 字段】。所以即使前端想过滤停用物资也做不到——接口层面就没给这个信息。

完整的修法应该是:① saveWithItems 和 update 里也校验包内物资的 status(或者干脆在 SQL 层用 JOIN 条件过滤);② 级联停用后清 combo:list:*;③ GoodsItemVO 加 status 字段让前端能过滤。三处都没做。」

链路 3:删除组合包

DELETE /admin/combo?ids=  @RequireRole(ADMIN)
   ↓ ComboServiceImpl.deleteBatch(ids)   @Transactional      第 89-108 行
   │   ├─ ids.forEach(id -> { 查 getById → if (ENABLE) throw SETMEAL_ON_SALE })
   │   │     ★ 在售的组合包不能删
   │   └─ ids.forEach(id -> {
   │          comboMapper.deleteById(id)              ← 删组合包
   │          comboDishMapper.deleteByComboId(id)     ← 删关联
   │      })
   ↓ cleanComboCache()

【这里有一个真实的审计缺口】

删除组合包时,历史申领单的 order_detail 里还有 combo_id 指向这个被删除的组合包——因为没有外键,删除不会失败,也不会级联。

那历史单据还能看吗?能看,因为 OrderDetail 是快照:

public class OrderDetail {
    private String name;        // ← 快照
    private Long goodsId;
    private Long comboId;
    private String goodsSpec;   // ← 快照
    private Integer number;     // ← 快照
    private BigDecimal amount;  // ← 快照
    private String image;       // ← 快照
}

但这里有一个关键的缺口:order_detail 快照了组合包自己的名字和价格,但没有快照”这个组合包里当时包含哪些物资”。

所以:

员工 2025 年申领了"新员工入职办公包"
2026 年管理员改了这个包的构成(或删了重建)
   ↓
查 2025 年那张申领单的明细
   → 只能看到"新员工入职办公包 × 1,150 元"
   → 【看不到当时包里是哪些物资、单价多少】

setmeal_dish 里的快照虽然存在,但它是”当前”的快照——如果组合包被修改过,setmeal_dish 已经被”先全删、再全插”覆盖了。所以**“这个包当时包含什么”这个信息永久丢失了**。

【这个缺口的严重性】

对”内部申领”场景影响中等:申领单的金额是对的(order_detail.amount 是包的售价快照),所以预算和账目不会错。丢的是”当时包里的构成”,也就是审计细节。

修法:要么在下单时把包内物资展开成多条 order_detail(每条记一个物资),要么在 order_detail 里加一个 JSON 字段存当时的包内构成快照。两个都没做。

【面试可以主动说的】:

「组合包在申领单明细里是快照——名字、价格、数量、图片都存下来了,所以包被改或被删之后,历史单据的金额和显示都还是对的。

但快照只做了”包”这一层,没做”包内物资”这一层。 如果组合包后来被修改了构成(修改是”先全删关联、再全插”,所以旧构成被覆盖了),就永远查不出”2025 年那张单当时领的是包里的哪些物资”。金额不会错(因为有包价快照),但审计细节丢了。

修法有两个:下单时把包展开成多条明细,或者在 order_detail 里加一个 JSON 字段存当时的构成快照。我都没做。」

三、M7 的其他设计取舍

设计点评价
getByIdWithItems 用 resultMap + <collection> 做嵌套映射✅ 一次 JOIN 查询搞定”包 + 包内物资”,没有 N+1。这是全项目唯一正确处理一对多的查询(对比 M6 的 listWithSpecs 和 M3 的分页)。可以作为”我知道怎么写对”的正面例子
getByIdWithItems 用 LEFT JOIN✅ 正确选择。如果用 INNER JOIN,空的组合包(没有关联物资)会查不出记录,变成”组合包不存在”。LEFT JOIN 保证空包也能返回,comboItems 会是含 null 元素或空集合
修改用”先全删关联、再全插”同 M6 的分析:在 @Transactional 里所以原子,逻辑简单;代价是关联行 id 全变(无引用,不影响)
deleteBatch 只检查”在售不能删”⚠️ 不检查”是否被历史申领单引用”。这是对的——历史单据是快照,不依赖组合包存在。如果这里加了”被订单引用不能删”的检查,组合包就永远删不掉了。所以当前设计是正确的
saveWithItems 没有 comboItems 为空的处理⚠️ comboItems.forEach(...) 如果 comboItems 是 null 会 NPE。ComboDTO 里初始化成了 new ArrayList<>(),所以走正常 JSON 反序列化不会为 null。但如果请求体显式传 "comboItems": null,Jackson 会把它设成 null → NPE → 500。GoodsServiceImpl.saveWithSpecs 里对 flavors 做了 null 判断,ComboServiceImpl 里没做——同一个项目两种写法

M8 申领车

一、业务场景

申领车是”临时存放打算申领什么”的地方,结构上是:

shopping_cart
 ├── user_id      (谁的申领车)
 ├── dish_id      (物资)    ← 与 setmeal_id 二选一
 ├── setmeal_id   (组合包)
 ├── dish_flavor  (所选规格,字符串)
 ├── name / image (加入时的快照)
 ├── amount       (★ 加入时的单价快照)
 ├── number       (数量)
 └── create_time

关键特征:申领车是”高频写”的表(每次加/减都写),而且是用户私有的(每人只能看自己的)。这两点决定了它的设计取舍——不能放 Redis(必须持久化,且要支持事务回滚)、不需要复杂的读写分离。

amount 是加入时的快照:员工把 A4 纸加入申领车时,amount 记录的是那一刻的单价。如果管理员随后调价,申领车里还是旧价,提交时按旧价扣预算。而申领车在提交后就被清空了,所以不存在长期脏数据。这个取舍可以接受——但面试如果被问”调价后申领车里的价格怎么算”,答案就是”按加入时的快照,不是提交时的实时价”。

二、核心类与执行链路

★★★ 链路 1:加入申领车(“先查再改”竞态 —— 本模块的核心问题)

POST /user/shoppingCart/add
Body: {goodsId: 71}  或  {comboId: 8}  或  {goodsId:71, goodsSpec:"A4"}
Header: authentication={JWT}

   ↓ JwtTokenUserInterceptor(认证,无 @RequireRole——员工端不需要角色限制)
   ↓ ShoppingCartController.add(ShoppingCartDTO)
   ↓ ShoppingCartServiceImpl.addShoppingCart(dto)            第 36-76 行

   ├─【1】BeanUtils.copyProperties(dto, shoppingCart)   ← goodsId / comboId / goodsSpec
   ├─【2】shoppingCart.setUserId(BaseContext.getCurrentId())   ← 强制当前登录人(安全)
   │
   ├─【3】★ List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart)     ← 【查】
   │      SQL: SELECT id, name, user_id, image,
   │                  dish_id AS goodsId, setmeal_id AS comboId,
   │                  dish_flavor AS goodsSpec, number, amount, create_time
   │           FROM shopping_cart
   │           <where> user_id=? AND dish_id=? AND setmeal_id=? AND dish_flavor=? </where>
   │           ⚠️ 没有 ORDER BY
   │
   ├─【4】★ if (list != null && list.size() > 0) {
   │          ShoppingCart cart = list.get(0);              ← ★ 只取第一条!
   │          cart.setNumber(cart.getNumber() + 1);         ← 【改】(在 JVM 内存里)
   │          shoppingCartMapper.updateNumberById(cart);    ← 【写】
   │             SQL: UPDATE shopping_cart SET number = #{number} WHERE id = #{id}
   │      } else {
   │          ★ 判断是物资还是组合包
   │          if (goodsId != null) {
   │              Goods goods = goodsMapper.getById(goodsId);
   │              shoppingCart.setName(goods.getName());      ← ⚠️ goods 为 null 则 NPE
   │              shoppingCart.setImage(goods.getImage());
   │              shoppingCart.setAmount(goods.getPrice());   ← ★ 单价快照
   │          } else {
   │              Combo combo = comboMapper.getById(comboId);
   │              shoppingCart.setName(combo.getName());      ← ⚠️ 同样可能 NPE
   │              shoppingCart.setImage(combo.getImage());
   │              shoppingCart.setAmount(combo.getPrice());
   │          }
   │          shoppingCart.setNumber(1);
   │          shoppingCart.setCreateTime(LocalDateTime.now());
   │          shoppingCartMapper.insert(shoppingCart)        ← 【写:INSERT】
   │      }
   ↓ Result.success()

【核心问题:查 → 判断 → 改 → 写 四步不是原子的】

时刻   请求 A(用户点了两次"加入")        请求 B(第二次点击)
──────────────────────────────────────────────────────────────────
t1     list() → 找到,number = 1
t2                                          list() → 找到,number = 1     ← 读到同一个值
t3     cart.setNumber(1 + 1) = 2
t4                                          cart.setNumber(1 + 1) = 2     ← 同样算出 2
t5     UPDATE ... SET number = 2
t6                                          UPDATE ... SET number = 2     ← 覆盖,还是 2
──────────────────────────────────────────────────────────────────
期望:number = 3(点了三次)    实际:number = 2    → 【丢失更新】

这就是”丢失更新”(lost update),和 M2 里”先查余额再扣”是完全同一类的并发缺陷。

【关键点:这是”读-改-写”,而不是”判断-写”】

和 M2 的预算对比,注意差异:

M2 预算预扣M8 申领车加数量
操作形态used_amount = used_amount + ? (读-改-写)number = number + 1 (读-改-写)
判断条件有:total_amount - used_amount >= ?无
采用的解法✅ 判断下推到 SQL 的 WHERE,用影响行数裁决❌ 在 Java 里 get(0) 然后 +1 再 UPDATE
正确性✅ 并发安全(行锁 + 当前读)❌ 并发丢失更新

【⭐ 这是三个模块里最有价值的面试点 —— 同一项目里两种做法的不一致】

「申领车的”加入”是先查出来、在 Java 里 number + 1、再 UPDATE 回去——这是典型的读-改-写,并发下会丢失更新:用户连点两次,两次都读到 number = 1,都算出 2,最后写进去还是 2,少加了一次。

这个问题我在项目另一处用完全不同的方式解决了:申领单预扣预算那里也是”读-改-写”(used_amount = used_amount + ?),但我在SQL 的 WHERE 里加了额度判断,让”判断 + 更新”成为一条原子语句,靠 InnoDB 行锁保证串行。同样的形态,我在预算那里做对了,在申领车这里没做——这是项目内部方案不一致,我认这个批评。」

【正确的修法 —— 而且有两个层次】

层次 1:用”原子 UPDATE”(对应 M2 的做法)

UPDATE shopping_cart SET number = number + 1
 WHERE user_id = ? AND dish_id = ? AND setmeal_id = ? AND dish_flavor = ?

看影响行数:>= 1 说明已有行、加成功;0 说明没有行,走 INSERT。这解决了”改”的原子性。

层次 2:用”唯一索引 + INSERT ... ON DUPLICATE KEY UPDATE”(更彻底)

-- 前提:给 (user_id, dish_id, setmeal_id, dish_flavor) 加唯一索引
INSERT INTO shopping_cart (name, user_id, dish_id, ..., number, ...)
VALUES (?, ?, ?, ..., 1, ...)
ON DUPLICATE KEY UPDATE number = number + 1

一条 SQL 同时处理”有则累加、无则插入”,天然并发安全,而且避免了”先查再判断走哪个分支”这一步。

代价:(user_id, dish_id, setmeal_id, dish_flavor) 里的 dish_id/setmeal_id 互斥为 null,而 MySQL 的唯一索引对 NULL 不去重(多行 NULL 不冲突)。所以这个唯一索引对”组合包”路径可能失效(dish_id 是 NULL)。这是个真实的实现难点——需要把”物资或组合包”归一成一个字段(比如统一的 item_type + item_id),或者用生成列(MySQL 5.7+ 的 generated column)把 NULL 替换成 0。

【这正好解释了”为什么这个项目没这么做”——但要诚实地说这是设计债】

「我倾向的修法是唯一索引 + ON DUPLICATE KEY UPDATE,一条 SQL 解决”有则累加、无则插入”。

但这里有一个实际的实现障碍:我的表用 dish_id 和 setmeal_id 两个列二选一表示”物资还是组合包”,而 MySQL 的唯一索引不会对 NULL 去重——所以 (user_id, dish_id, setmeal_id, dish_flavor) 这个索引对组合包路径可能失效(dish_id 是 NULL)。

要绕过去,得把”物资或组合包”归一成一个字段(比如 item_type + item_id),或者用生成列把 NULL 替换成 0。这是表结构的改动,所以我当时没做,只留在了已知问题里。」

链路 2:规格在查询条件里生效、但不影响行匹配的唯一性

我对 list() 的 WHERE 逐个字段核对过,goodsSpec 确实参与了条件:

<if test="goodsSpec != null">
    and dish_flavor = #{goodsSpec}
</if>

而调用方 addShoppingCart 里也确实把 DTO 的 goodsSpec 拷贝进了查询条件(BeanUtils.copyProperties(dto, shoppingCart) 之后直接 list(shoppingCart))。

【所以”同物资不同规格会合并”这个担心是不成立的 —— 但边界要说清】

场景行为是否正确
前端传 goodsSpec:"A4" 加两次第二次查到同一行(dish_flavor='A4'),number 累加✅ 正确
前端传 goodsSpec:"A4" 和 "A3"查到两行,各自累加✅ 正确
前端不传 goodsSpec(加同一个物资两次)条件里没有 dish_flavor,匹配到该物资的所有规格行,然后 list.get(0) 取其中一条⚠️ 不确定行为

【最后一行是真实问题】:list() 的 SQL 没有 ORDER BY,所以 list.get(0) 取到哪一条取决于 InnoDB 的返回顺序(通常是主键顺序,但SQL 不保证)。如果该物资下已经有两行不同规格,“不传规格”的请求会随机累加到其中一行。

而且前端已经意识到这个问题了,代码注释写得很明确:

// 申领车里点 "+":需要带上原来的规格,否则会被当成新行插入
addCartByRow(row) {
  const body = { number: 1 };
  if (row.goodsId) body.goodsId = row.goodsId;
  if (row.comboId) body.comboId = row.comboId;
  if (row.goodsSpec) body.goodsSpec = row.goodsSpec;    // ← 有意带上规格
  ...
}

意思就是:前端在”申领车里点 +“时会带上规格(避免插成新行),但商品列表页第一次加入时是不知道规格的——所以第一次加入不传规格(走 INSERT 分支),后续从申领车点 + 才传规格(走累加分支)。

【这个绕法的代价】

  • 第一次加入的 dish_flavor 是 null(ShoppingCartDTO.goodsSpec 没传);
  • 后续从申领车点 + 时传了规格 → dish_flavor = 'A4' 匹配不到那行 null 的行 → 会被当成新行 INSERT → 申领车里出现两条:一条 dish_flavor=null、一条 dish_flavor='A4';
  • 前端注释说的”否则会被当成新行插入”,恰恰就是在说这个行为。

所以实际表现是:同一个物资、从两个不同入口加,会在申领车里出现两行。 提交时会变成两条 order_detail,金额汇总是对的(都是同一单价 × 各自数量),所以金额不出错,但申领车的显示会看起来”重复了”。

【面试标准答法】

「这里有一个规格参与匹配的设计:查询条件里有 dish_flavor,所以同物资不同规格会各自成行——这是对的。

但有一个不确定行为:list() 的 SQL 没有 ORDER BY,然后用 list.get(0) 取第一条。如果该物资下已经有多行(不同规格),而这次请求没传规格,条件里就没有 dish_flavor,会匹配到所有规格行,然后不确定地取其中一条累加。SQL 不保证 get(0) 拿到哪一行。

前端其实意识到这个问题了——它在”申领车点 +“时会带上原来的规格,注释里写着”否则会被当成新行插入”。但这导致另一个现象:商品列表页第一次加入时不知道规格、dish_flavor 存成 null;从申领车点 + 时带了规格、匹配不上那行 null → 插成新行 → 同一个物资在申领车里出现两行。金额不会错(两条明细单价相同),但显示上看起来重复。

根因还是”先查再改”这个模式:因为它要”先查出来判断该累加还是新增”,就必须依赖 list() 的匹配精度和 get(0) 的确定性;而改成唯一索引 + ON DUPLICATE KEY UPDATE 之后,这个”判断”步骤就消失了,规格是否参与匹配由唯一索引的定义来决定——语义明确、行为确定。」

链路 3:减少数量(subShoppingCart)—— 同样的模式,同样的竞态

POST /user/shoppingCart/sub
Body: {goodsId: 71, goodsSpec: "A4"}
   ↓ ShoppingCartServiceImpl.subShoppingCart(dto)             第 101-123 行
   ├─ BeanUtils.copyProperties(dto, shoppingCart)
   ├─ shoppingCart.setUserId(BaseContext.getCurrentId())
   ├─ list(shoppingCart)                                       ← 【查】
   ├─ if (list 非空) {
   │      shoppingCart = list.get(0)
   │      Integer number = shoppingCart.getNumber()
   │      if (number == 1)
   │          shoppingCartMapper.deleteById(shoppingCart.getId())      ← 减到 1 就删行
   │      else {
   │          shoppingCart.setNumber(shoppingCart.getNumber() - 1)     ← 【改】
   │          shoppingCartMapper.updateNumberById(shoppingCart)        ← 【写】
   │      }
   │  }

完全相同的”先查再改”模式,同样的丢失更新风险。 而且这里还有一个更微妙的竞态:

时刻   请求 A(sub:数量 2 → 1)        请求 B(sub:数量 2 → 1)
──────────────────────────────────────────────────────────────────
t1     list() → number = 2
t2                                       list() → number = 2
t3     2 != 1,所以 setNumber(1) 走 UPDATE
t4                                       2 != 1,所以 setNumber(1) 走 UPDATE
t5     UPDATE number = 1
t6                                       UPDATE number = 1
──────────────────────────────────────────────────────────────────
期望:减两次 → 行被删除(2 → 1 → 删除)   实际:number = 1,【只减了一次】

还有更严重的一种交错:如果 A 读到的 number = 1(走 deleteById),而 B 同时也读到 number = 1(也走 deleteById)——两次删除同一条记录,第二次删 0 行,无害。但如果是”A 读到 1 要删、B 读到 2 要减到 1”,最终结果取决于执行顺序,可能出现”已经被删了却又被 update”(update 影响 0 行,无害)。

所以 sub 的并发后果是”少减”,属于用户体验问题,不会导致数据错误或金额错误。

三、M8 的其他设计取舍

设计点评价
ShoppingCartMapper.list 显式给列起别名✅ 必须这么做,而且源码注释解释了原因:数据库列名仍是 dish_id/setmeal_id/dish_flavor(表结构未改),而实体字段已改名为 goodsId/comboId/goodsSpec,靠驼峰映射对不上。这是”改造时改了 Java 字段名但没改表结构”留下的痕迹,注释留档做得很好
list 用 dish_id AS goodsId 而不是 SELECT *✅ 正确。如果用 SELECT *,这三个字段会查出 null —— 会导致 addShoppingCart 里”判断是物资还是组合包”的逻辑完全失效(永远走 else 分支)。这是一个非常隐蔽的 bug,作者避开了并且写了注释
addShoppingCart 不校验物资/组合包是否存在❌ goodsMapper.getById(goodsId) 返回 null 时,goods.getName() 直接 NPE → HTTP 500。而且 GlobalExceptionHandler 不处理 NPE(只处理 BaseException 和 SQL 异常)。同一个文件里的 GoodsServiceImpl 也没做这个校验——全项目对”前端传的 id 是否存在”普遍不做校验
addShoppingCart 不校验物资/组合包是否启用⚠️ getById 的 SQL 是 select * from dish where id = #{id},不带 status 条件。所以停用的物资也能被加进申领车。员工端列表页只显示启用的,正常操作不会遇到;但直接打接口可以。提交时也不校验——这意味着”停用物资”能被申领出去。当前唯一的防护是”停用物资时级联停用组合包”(M6),但它不拦”单独申领一个停用物资”
申领车不放 Redis✅ 正确的选择。理由:① 它是高频写(每次加/减都写),放 Redis 要处理持久化和一致性;② 它必须持久化(用户关掉浏览器再回来,车里的东西不能丢);③ submitOrder 要在同一个 MySQL 事务里读申领车、写单据、清空申领车——放 Redis 就跨存储了,事务边界会破。这是”什么时候不该用 Redis”的好例子
insertBatch 用于”再来一单”✅ OrderServiceImpl.repetition 把订单明细转成申领车记录,用一条 INSERT ... VALUES (...),(...) 批量插入。没有循环单条插入 —— 这一点做对了(对比 M6 的 listWithSpecs)
申领车没有唯一索引❌ 这是”先查再改”模式能产生脏数据的根本原因。没有唯一索引 → 应用层判断失误就会插重复行;有唯一索引 → 数据库直接拦住。建议加 (user_id, dish_id, setmeal_id, dish_flavor) 唯一索引(但要处理 NULL 不去重的问题,见上文)

四、M6-M8 复习优先级文件清单

三个模块合计约 550 行,但只需要精读约 120 行。

A 类:必须能讲清、能指着代码说

优先级文件 / 位置看什么为什么
P0ShoppingCartServiceImpl.java 第 36-76 行(addShoppingCart)“查 → get(0) → +1 → UPDATE” 四步非原子M8 的核心缺陷。要能画出丢失更新的时序图,并对比 M2 的正确做法
P0GoodsServiceImpl.java 第 212-229 行(listWithSpecs)循环里 goodsFlavorMapper.getByGoodsId = N+1要能说清”被缓存掩盖”这个判断,以及三个会暴露它的时刻
P0GoodsServiceImpl.java 第 167-191 行(startOrStop)级联停用组合包(comboMapper.update(status=DISABLE))这是 M5-1 那个缓存 bug 的另一半。跨模块联动 + 缓存失效遗漏
P0ComboServiceImpl.java 第 49-70 行(saveWithItems)+ 第 123-147 行(update)comboItems 的 name/price 直接来自前端、goodsId 不校验存在性M7 的核心问题。要能对比 M3 已修复的”金额信任前端”,说明同类问题修一处漏一处
P0ComboServiceImpl.java 第 150-173 行(startOrStop)启用前校验包内物资 + 指出三个缺口要能说清”校验前置是对的,但 update 路径没校验、缓存没清、GoodsItemVO 没有 status”
P0ShoppingCartMapper.xml 第 8-32 行(list)显式列别名 + 没有 ORDER BY别名是”改了 Java 字段名但没改表结构”的必需处理(有注释);没 ORDER BY 导致 get(0) 不确定

B 类:知道流程即可

文件 / 位置看什么
GoodsServiceImpl.deleteBatch(第 88-111 行)双重引用检查(在售不可删 + 被组合包引用不可删),组合包用一次 IN 查询
GoodsServiceImpl.updateWithSpecs(第 140-159 行)“先全删规格、再全插”,在事务里所以原子;flavors 为空时会静默清空规格
GoodsMapper.getByComboId(第 82-83 行)LEFT JOIN + 不过滤 status(正确——启用校验需要拿到全部再逐个检查)
ComboMapper.getByIdWithItems(第 77-91 行)+ resultMap✅ 全项目唯一正确的一对多查询(LEFT JOIN + <collection> 嵌套映射,无 N+1)
ShoppingCartServiceImpl.subShoppingCart(第 101-123 行)同样的”先查再改”,还有”减到 1 就删行”的分支
ComboMapper.getGoodsItemByComboId(@Select)/user/combo/goods/{id} 的 SQL:LEFT JOIN dish,GoodsItemVO 里没有 status 字段
sql/demo_data.sqlsetmeal_dish 冗余快照的设计说明(原文注释)

C 类:不用看

  • GoodsVO / ComboVO / GoodsItemVO / ComboItem / ShoppingCart / GoodsSpec —— Lombok 数据类
  • CategoryServiceImpl —— 属于 M11,只需知道”删除前检查是否被物资/组合包引用”
  • GoodsController / ComboController 的缓存清理(已在 M5 详述)

45 分钟复习顺序

1. ShoppingCartServiceImpl.addShoppingCart,画丢失更新时序图   ← 10 分钟(最重要)
2. GoodsServiceImpl.listWithSpecs + "被缓存掩盖"的论述          ← 8 分钟
3. GoodsServiceImpl.startOrStop  → 联到 M5-1 缓存 bug           ← 6 分钟
4. ComboServiceImpl.saveWithItems / update → 快照信任前端       ← 10 分钟(对比 M3)
5. ComboServiceImpl.startOrStop → 三个缺口                      ← 6 分钟
6. ShoppingCartMapper.xml 的 list → 别名 + 无 ORDER BY          ← 5 分钟

五、面试追问链(重点在 M8 的并发 + M7 的信任边界)

追问链 1:申领车的并发(三个模块里最有价值的一条)


【面试官问题 1】
用户往申领车里加物资,这段代码并发安全吗?

【推荐回答】
不安全,这是一个我能明确指出来的缺陷。 addShoppingCart 的逻辑是四步:先 list() 查出来、在 Java 里 get(0) 取第一条、把 number + 1、再 UPDATE 回去。这是典型的”读-改-写”,四步之间没有任何锁,并发下会丢失更新。

【回答关键词】 主动承认 · 读-改-写 · 四步非原子 · 丢失更新
【可能继续追问】 具体会怎样?


【面试官问题 2】
具体会出什么问题?

【推荐回答】
用户快速点两次”加入”,只加了一次。 时序是这样:

t1  请求A:list() → number = 1
t2  请求B:list() → number = 1          ← 读到同一个值
t3  请求A:setNumber(1 + 1) = 2
t4  请求B:setNumber(1 + 1) = 2          ← 同样算出 2
t5  请求A:UPDATE number = 2
t6  请求B:UPDATE number = 2             ← 覆盖,还是 2
期望:3    实际:2

这就是”丢失更新”——和”先查余额再扣”是同一类并发缺陷。用户感知是”我明明点了三次,怎么只有两个”。

【回答关键词】 具体时序 · 都读到 1 · 都算出 2 · 最后写 2 · 用户可见
【可能继续追问】 这个问题在你项目别的地方有吗?


【面试官问题 3】
这个问题在你项目别的地方有过吗?

【推荐回答】
有,而且我在那一处用完全不同的方式解决了——这正是我要主动说的项目内部不一致。

申领单预扣部门预算那里,操作形态完全一样,也是”读-改-写”:

UPDATE budget SET used_amount = used_amount + #{amount} WHERE ...

但我在 SQL 的 WHERE 里加了额度判断:

  AND total_amount - used_amount >= #{amount}

这样”判断 + 更新”变成一条原子语句,靠 InnoDB 行锁保证两个并发事务串行执行,业务层只看影响行数。那一处是并发安全的。

而申领车这里,我把”判断”留在了 Java 里(list.get(0) 然后 +1),所以同样的形态、我做了两种处理。这是项目内部的方案不一致,我认这个批评——更准确地说,是”我在资金相关的地方(预算)更谨慎,在体验相关的地方(申领车)放松了”。

【回答关键词】 主动指出不一致 · M2 是条件 UPDATE · M8 留在 Java 里 · 同一形态两种处理 · 承认并解释(资金 vs 体验)
【可能继续追问】 那申领车该怎么修?


【面试官问题 4】
申领车该怎么修?

【推荐回答】
两个层次,我倾向第二个。

层次一:把”改”变成原子 UPDATE,和 M2 一个思路:

UPDATE shopping_cart SET number = number + 1
 WHERE user_id = ? AND dish_id = ? AND setmeal_id = ? AND dish_flavor = ?

看影响行数:>= 1 说明已有行、累加成功;0 说明没有行,走 INSERT。这解决了”改”的原子性,但”先查判断走哪个分支”这一步还在。

层次二(更彻底):唯一索引 + INSERT ... ON DUPLICATE KEY UPDATE

-- 前提:给 (user_id, dish_id, setmeal_id, dish_flavor) 建唯一索引
INSERT INTO shopping_cart (...) VALUES (..., 1, ...)
ON DUPLICATE KEY UPDATE number = number + 1

一条 SQL 同时处理”有则累加、无则插入”,不需要先查、不需要判断分支,天然并发安全。

但我必须说清一个实际障碍:我的表用 dish_id 和 setmeal_id 两个列二选一表示”是物资还是组合包”,而 MySQL 的唯一索引不会对 NULL 去重——多行 dish_id = NULL 不冲突。所以这个唯一索引对组合包路径会失效。

绕过去要么把”物资或组合包”归一成一个字段(item_type + item_id),要么用 MySQL 5.7+ 的生成列把 NULL 替换成 0。这是表结构改动,所以我当时没做,只把它留在了已知问题清单里。

【回答关键词】 层次一:原子 UPDATE(同 M2)· 层次二:唯一索引 + ON DUPLICATE KEY UPDATE(更彻底)· NULL 不去重的障碍 · 归一字段或生成列 · 表结构改动所以没做
【可能继续追问】 那 sub 减少数量呢?


【面试官问题 5】
减少数量那个方法呢?

【推荐回答】
完全相同的”先查再改”模式,同样的丢失更新风险,而且多了一个分支。

subShoppingCart 的逻辑是:查到 number,如果是 1 就删除整行,否则减 1 再 UPDATE。并发下:

t1  A:list() → number = 2
t2  B:list() → number = 2
t3  A:2 != 1,setNumber(1),UPDATE
t4  B:2 != 1,setNumber(1),UPDATE
结果:number = 1,期望是"减两次 → 行被删除"

所以 sub 的并发后果是”少减”,属于用户体验问题。它不会导致金额错误——因为 submitOrder 是按申领车当前的状态重算金额的,而且申领车提交后会被清空。

但 sub 有一个更微妙的点:如果 A 读到 number = 1 走删除、B 也读到 1 走删除,两次删同一条记录,第二次影响 0 行、无害。而如果 A 读到 1 要删、B 读到 2 要减到 1,最终结果取决于执行顺序——可能出现”先被删了、后又被 UPDATE”(UPDATE 影响 0 行,也无害)。所以这类交错的最坏后果是”数量不对”,不会产生负数量或数据损坏。

【回答关键词】 同模式同风险 · “减到 1 就删行”分支 · 后果是少减 · 不会金额错误(提交时重算)· 最坏是数量不对、不会损坏数据
【可能继续追问】 那申领车里为什么不用 Redis?


【面试官问题 6】
申领车为什么不用 Redis?不是可以更快吗?

【推荐回答】
三个理由,核心是第三个。

第一,它是高频写。 每次加/减都要写,而 Redis 要处理持久化和一致性,高频写的场景用 Redis 反而增加复杂度(要决定用 Hash 还是 String、什么时候落库)。

第二,它必须持久化。 用户关掉浏览器再回来,车里的东西不能丢。Redis 虽然有持久化,但异步持久化会丢数据(RDB 是定时的、AOF 默认 everysec 也可能丢 1 秒),而申领车丢了用户要重新挑一遍。

第三,也是最关键的——事务边界。 submitOrder 要在同一个 MySQL 事务里做四件事:读申领车算金额、预扣预算、写申领主表和明细、清空申领车。如果申领车在 Redis 里,就跨存储了——“预扣预算成功但清空申领车失败”这种不一致就没法靠事务回滚解决,得引入补偿机制。

所以申领车放 MySQL 是正确选择。 这个判断的依据是:它需要和其他 MySQL 数据在同一个事务里保持一致——这是”什么时候不该用 Redis”的典型判据。

【回答关键词】 高频写 · 必须持久化(Redis 异步持久化会丢)· 必须和 MySQL 同事务(跨存储就破坏事务边界)· “什么时候不该用 Redis”的判据
【可能继续追问】 (通常转向 M6 的 N+1)


追问链 2:物资列表的 N+1 与缓存(5 层)


【面试官问题 1】
员工端物资列表的查询,SQL 是怎么执行的?

【推荐回答】
有一个 N+1。 listWithSpecs 先查一次物资列表,然后在 for 循环里逐条查这个物资的规格:

List<Goods> goodsList = goodsMapper.list(goods);        // 1 次
for (Goods d : goodsList) {
    List<GoodsSpec> flavors = goodsFlavorMapper.getByGoodsId(d.getId());   // N 次
    ...
}

所以一次请求是 1+N 次 SQL(N = 该分类下的物资数,大约 10-20,所以是 11-21 次)。

【回答关键词】 主动暴露 · 1+N · N 约 10-20 · 循环里查库
【可能继续追问】 那为什么这个接口一直没出问题?


【面试官问题 2】
那为什么这个问题一直没被发现?

【推荐回答】
因为它被缓存掩盖了。

状态每次请求的 SQL 次数
缓存命中(30~35 分钟内的绝大多数请求)0
缓存未命中(每 30 分钟一次,或被清缓存后)1 + N

所以从用户感知上,这个接口一直很快。但这是脆弱的掩盖,有三个时刻会暴露:

第一,Redis 冷启动或数据丢失:所有分类的缓存都不在,第一批请求全部回源,N+1 乘以分类数同时打库;
第二,管理员改物资:cleanCache("goods:list:*") 是通配清除(一次修改让所有分类的缓存失效),后续请求全部回源;
第三,Redis 内存打满:项目没有配置 maxmemory-policy(默认 noeviction),内存满了新写入直接失败 → 缓存写不进去 → 每个请求都走 N+1。

所以正确的判断是:缓存降低了对 N+1 的敏感度,但没有消除这个缺陷。

【回答关键词】 被缓存掩盖(命中 0 次 SQL)· 脆弱 · 三个暴露时刻(冷启动/通配清缓存/内存打满)· “降低敏感度 ≠ 消除缺陷”
【可能继续追问】 那怎么修?


【面试官问题 3】
N+1 怎么修?

【推荐回答】
用 IN 一次批量查回来、再在内存分组:

// 1. 查物资列表(1 次)
List<Goods> goodsList = goodsMapper.list(goods);
if (goodsList.isEmpty()) return Collections.emptyList();
 
// 2. 收集 id,一次查出所有规格(1 次)
List<Long> goodsIds = goodsList.stream().map(Goods::getId).collect(Collectors.toList());
List<GoodsSpec> allFlavors = goodsFlavorMapper.getByGoodsIds(goodsIds);   // WHERE dish_id IN (...)
 
// 3. 按 goodsId 分组
Map<Long, List<GoodsSpec>> flavorMap = allFlavors.stream()
        .collect(Collectors.groupingBy(GoodsSpec::getGoodsId));
 
// 4. 组装
for (Goods d : goodsList) {
    GoodsVO vo = new GoodsVO();
    BeanUtils.copyProperties(d, vo);
    vo.setFlavors(flavorMap.getOrDefault(d.getId(), new ArrayList<>()));
    goodsVOList.add(vo);
}

从 1+N 降到 2 次。

项目里同一个模式有三处,应该一起改:

  1. GoodsServiceImpl.listWithSpecs(物资列表)——最严重,因为是高频读;
  2. OrderServiceImpl.pageQuery4User(员工端申领单分页)——每页 10 条发 11 次;
  3. OrderServiceImpl.getOrderGoodsStr(管理端申领单搜索)——而且这一处更特别:它查明细不是为了返回结构化明细,而是为了拼一个 "物资名*数量;" 的展示字符串。这种情况其实可以直接用 SQL 的 GROUP_CONCAT 一次聚合出来,连内存分组都省了。

【回答关键词】 IN 批量查 + groupingBy 内存分组 · 1+N → 2 · 项目里三处同类 · 第三处更适合 GROUP_CONCAT · 三处一起改
【可能继续追问】 那这个查询有没有索引问题?


【面试官问题 4】
除了 N+1,这个查询还有别的性能问题吗?

【推荐回答】
有两个可以说的,但我要先说清一个边界——我没做压测、也没跑过 EXPLAIN,下面是根据表结构和 SQL 的推断,不是实测结论。

第一,dish_flavor 的 dish_id 上有没有索引? 我不能确定——因为原项目的建表脚本没有提交到仓库(sql/ 目录下只有迁移脚本和演示数据),所以 dish、dish_flavor、setmeal 这些原表的索引定义我看不到。这是一个信息缺口:我只能看到新增的表(department、budget)和新增的索引(idx_employee_role、idx_employee_dept、idx_orders_requester_dept、idx_orders_approve_user、uk_orders_number)。所以**“dish_flavor.dish_id 有没有索引”这个问题的答案,在当前代码库里无法确认**。

第二,GoodsMapper.list 的排序:SQL 是 ORDER BY create_time DESC。如果 (category_id, status, create_time) 上没有复合索引,这里会有 filesort。同样,因为看不到原表的索引定义,我不能确定。

所以这两个问题我无法给出确定答案。如果面试官问我,我会直接说”这个我没法确认,因为原表结构不在仓库里,需要实际看 SHOW INDEX FROM dish_flavor”。 我觉得说”不知道”比编一个答案要安全。

【回答关键词】 主动区分”推断”和”实测” · 承认压测/EXPLAIN 都没做 · 原表建表脚本不在仓库,索引定义无法确认 · 宁可说不知道也不编
【可能继续追问】 (通常转向 M7 的快照问题)


追问链 3:组合包的快照与信任边界(5 层)


【面试官问题 1】
组合包的关联表为什么要冗余存物资名称和价格?

【推荐回答】
有意的快照设计,目的是让包内物资的”构成清单”与物资后续调价解耦。

如果不冗余,组合包详情要 JOIN dish 拿 name/price。那物资调价之后,历史组合包的构成清单会跟着变——你会看到”2025 年的入职包里,A4 纸显示的是 2026 年的价格”,这是错的。

冗余之后,setmeal_dish 存的是”创建这个组合包时,包内物资是什么、多少钱”,是一个时间点快照,不受后续调价影响。

【回答关键词】 有意的反范式 · 时间点快照 · 不受后续调价影响 · 不冗余会导致历史数据被”追溯修改”
【可能继续追问】 那这个快照的值是从哪来的?


【面试官问题 2】
那这个快照的值是谁填的?

【推荐回答】
是前端传的,而且服务端完全不校验——这是一个信任边界问题,我要主动说。

ComboDTO 里的 comboItems 是 List<ComboItem>,而 ComboItem 就是提交的数据结构:

public class ComboItem {
    private Long goodsId;
    private String name;        // ← 前端传什么就存什么
    private BigDecimal price;   // ← 前端传什么就存什么
    private Integer copies;
}

前端确实是这么传的:onPickGoods 里从它自己缓存的 allGoodsList 里取 name 和 price 填进去,然后整个 comboItems 提交上来。服务端 saveWithItems 只是补了个 comboId 就 insertBatch 了,name 和 price 原样入库。

而 goodsId 明明已经在请求里了——服务端凭它就能查出权威的 name 和 price。所以这是”服务端对自己能查到的权威数据,选择了信任客户端”。

【回答关键词】 前端传什么存什么 · onPickGoods 从本地缓存取值 · 服务端只补 comboId · goodsId 已在请求里、完全可查权威值 · 信任边界
【可能继续追问】 这个问题在你项目别的地方也出现过吗?


【面试官问题 3】
这个问题的形态,在你项目别的地方出现过吗?

【推荐回答】
出现过,而且我修了那一处、漏了这一处——这是我要主动承认的项目内部不一致。

申领单提交那里,原来的缺陷是**“前端算好金额,告诉后端扣多少”**:

// 修复前
BigDecimal amount = ordersSubmitDTO.getAmount() == null ? BigDecimal.ZERO : ordersSubmitDTO.getAmount();

这个可以传 amount = 0 零元提交,甚至传负数让预扣 SQL 的 >= 恒成立、反向下减已用金额。我修成了:

// 修复后:按申领车逐项重算,完全忽略前端的 amount
BigDecimal amount = shoppingCartList.stream()
        .map(c -> c.getAmount().multiply(BigDecimal.valueOf(c.getNumber())))
        .reduce(BigDecimal.ZERO, BigDecimal::add);

根因和组合包这个完全一样:服务端信任了客户端传来的、自己本来能算出来的数据。

区别在于影响面:

  • 申领单那个是 P0——直接导致资金错误(零元提交、负数反减);
  • 组合包快照这个是展示层问题——包价(setmeal.price)是独立字段,管理员单独填,用户付的钱不会变,快照只用于展示”包内构成”。

但”影响小”不等于”不用修”,因为:一是根因是一样的(信任边界),二是前端缓存的物资列表可能是过期的(比如管理员在另一个标签页改了价,当前页面的 allGoodsList 还是旧价),所以正常流程也可能存下过期的快照。

【回答关键词】 主动指出同类问题 · 申领单是 P0 我已修 · 组合包是展示层、影响中等 · 根因同一个:信任客户端 · 前端缓存过期也会导致快照不准
【可能继续追问】 那怎么修?


【面试官问题 4】
那这个怎么修?

【推荐回答】
服务端用 goodsId 查一次库,覆盖掉前端传来的 name 和 price:

// 正确做法(当前未实现)
for (ComboItem item : comboItems) {
    if (item.getGoodsId() == null) {
        throw new BaseException("组合包内的物资不能为空");
    }
    Goods goods = goodsMapper.getById(item.getGoodsId());
    if (goods == null) {
        throw new BaseException("组合包内的物资不存在");
    }
    item.setName(goods.getName());      // ★ 用服务端的权威值覆盖前端值
    item.setPrice(goods.getPrice());
    item.setComboId(comboId);
}
comboDishMapper.insertBatch(comboItems);

这个修法顺便补上了另一个当前完全缺失的校验:物资是否存在。

现在如果传一个不存在的 goodsId,insertBatch 会直接把它写进 setmeal_dish——因为没有外键。之后产生一条指向不存在物资的孤儿关联,而且 ComboServiceImpl.startOrStop 启用组合包时用的是 LEFT JOIN,这条关联会返回 null 而不是报错,所以孤儿关联会静默存在。

【回答关键词】 用 goodsId 查库覆盖 name/price · 顺带补上”物资是否存在”的校验(现在完全缺失) · 无外键导致孤儿关联 · LEFT JOIN 让孤儿静默存在
【可能继续追问】 那组合包的”启用校验”做得怎么样?


【面试官问题 5】
组合包启用的时候有校验吗?

【推荐回答】
有,思路是对的,但我说清三个缺口。

有的部分是”校验前置”:启用组合包时会先查包内物资、有停用的就抛异常。所以非法状态(启用的包含停用物资)不会被写进数据库。 配合物资停用时的级联停用组合包,两边形成闭环。

但三个缺口:

第一,修改组合包这条路径没校验。 ComboServiceImpl.update(还有 saveWithItems)完全不检查包内物资的 status。所以可以:先停用一个物资 → 再编辑某个已启用的组合包、把这个停用物资加进去 → 得到一个”已启用、且包含停用物资”的组合包。这条路径绕过了启用校验和级联停用两道防线。

第二,级联停用改了数据库但没清缓存(M5-1)。所以员工在 30~35 分钟内仍然能看到并且能申领这个本该停用的组合包——数据库层堵住了,缓存层没堵。

第三,/user/combo/goods/{id} 这个查包内物资明细的接口,返回的 GoodsItemVO 只有 name/copies/image/description,【没有 status 字段】。所以即使前端想过滤停用物资也拿不到这个信息——接口层面就没给。

完整修法:① saveWithItems / update 也校验 status(或 SQL 层 JOIN 时过滤);② 级联停用后清 combo:list:*;③ GoodsItemVO 加 status。三处都没做。

【回答关键词】 校验前置(思路对)· 缺口一:update/save 没校验 → 能造出非法状态 · 缺口二:级联没清缓存(M5-1)· 缺口三:GoodsItemVO 无 status · 完整修法三条
【可能继续追问】 (通常收尾)


六、M6-M8 容易被质疑的地方

#问题面试官为什么可能质疑当前代码实际情况我应该怎么解释是否建议修改
M8-1★ addShoppingCart 是”读-改-写”,并发丢失更新”用户连点两次,数量对得上吗?”✅ 成立。list() → list.get(0) → setNumber(+1) → UPDATE ... WHERE id=?,四步非原子主动承认,并对比 M2:「这是典型的读-改-写。而申领单预扣预算那里是完全相同的形态,我用”条件 UPDATE + 影响行数”做对了;申领车这里我把判断留在了 Java 里,所以做错了。同一个项目两种处理,这是方案不一致。」修法:原子 UPDATE ... SET number = number + 1 看影响行数;更彻底是唯一索引 + ON DUPLICATE KEY UPDATE,障碍是 dish_id/setmeal_id 二选一导致 NULL 不去重,需要归一字段或生成列建议修(M8 最高优先级)。这是”能识别自己项目内部不一致”的绝佳素材
M8-2list() 没有 ORDER BY 却用 list.get(0)”有多行的时候取哪一行?”✅ 成立。ShoppingCartMapper.list 的 SQL 无 ORDER BY;addShoppingCart/subShoppingCart 都用 list.get(0)。SQL 不保证返回顺序「get(0) 取哪一行不保证——通常是主键顺序但 SQL 没有承诺。这在”不传规格”的请求下会体现:条件里没有 dish_flavor,会匹配该物资的所有规格行,然后不确定地取一条累加。根因还是”先查再改”这个模式:它必须依赖 list() 的匹配精度和 get(0) 的确定性。改成唯一索引 + ON DUPLICATE KEY UPDATE 之后,“判断”这一步就消失了,匹配语义由唯一索引的定义决定——明确且确定。」建议修(随 M8-1 一起改)
M8-3规格匹配的边界行为:同物资会出现两行”同一个物资加两次,为什么申领车里有两行?“⚠️ 成立。前端在”申领车点 +“时带规格(app.js 注释写明”需要带上原来的规格,否则会被当成新行插入”),但商品列表页第一次加入不带规格(dish_flavor 存 null)。后续带规格的请求匹配不上那行 null → 走 INSERT → 两行「前端已经意识到这个问题并做了绕法,但绕法的结果是同一个物资在申领车里出现两行——一条 dish_flavor=null、一条带规格。金额不会错(两条明细单价相同、提交时逐项汇总),但显示上看起来重复。根因同上:dish_flavor 参与匹配导致的语义不确定性。」可选(体验问题,随 M8-1 一起改)
M8-4goodsMapper.getById 返回 null 时 NPE → 500”传一个不存在的 goodsId 会怎样?”✅ 成立。ShoppingCartServiceImpl.addShoppingCart(第 57-70 行)里 goods.getName() / combo.getName() 直接解引用,无判空;GlobalExceptionHandler 不处理 NPE → HTTP 500「全项目对”前端传的 id 是否存在”普遍不校验。这里会 NPE,而全局异常处理器只处理 BaseException 和 SQL 异常,NPE 落到 Spring 默认的 500。这是”400 的语义返回了 500”。修法:判空后抛业务异常(“物资不存在”)。」建议修(加判空,一行)
M8-5停用的物资/组合包仍可加入申领车并被申领”停用的物资还能申领吗?“⚠️ 成立。GoodsMapper.getById 的 SQL 是 select * from dish where id = ?,不带 status 条件;addShoppingCart 也不校验 status;submitOrder 也不校验。所以直接打接口可以把停用物资加进车、并且提交成功「正常 UI 流程不会遇到(员工端列表只返回启用的)。但直接打接口可以——而项目的接口文档已经导出成 JSON,等于攻击面是公开的。当前唯一的防护是”停用物资时级联停用组合包”,但它拦不住”单独申领一个停用物资”。修法:加购物车和提交时都校验 status。」建议修(业务规则漏洞)
M6-1listWithSpecs 的 N+1(被缓存掩盖)“一次请求发了几条 SQL?”✅ 成立。1 + N(N≈10-20)。缓存命中时 0 次,所以一直没暴露主动承认,并说清”被缓存掩盖是脆弱的”——三个暴露时刻(Redis 冷启动 / 通配清缓存 / Redis 内存打满导致写不进缓存)。修法:IN 批量查 + groupingBy 内存分组,项目里三处同类应一起改建议修
M6-2updateWithSpecs:flavors 为空时静默清空规格”修改物资但没传规格会怎样?“⚠️ 成立。updateWithSpecs 先 deleteByGoodsId 全删,然后 if (flavors != null && flavors.size() > 0) 才插入。所以传 null 或 [] → 规格被清空且不报错「这是”先全删、再全插”模式的固有风险。**前端如果没把回显的规格带回来,保存一次规格就没了。**修法:区分”没传 flavors”(不动规格)和”传了空数组”(清空规格)——但这要求 DTO 用 null 和 [] 表达不同语义,容易踩坑;更稳的是用独立的接口管理规格,或者加一个显式的 flavorsModified 标志。」建议修(或至少加日志告警)
M6-3删除物资前的引用检查不含”历史申领单""删除物资会影响历史单据吗?”✅ 实际是安全的,因为 order_detail 是快照(存了 name/amount/image),不依赖 dish 存在。所以 deleteBatch 只检查”在售”和”被组合包引用”是正确的——如果加上”被历史订单引用不能删”,物资就永远删不掉了这是一个要主动澄清的”看起来像问题但实际不是”的点:「删除物资不影响历史单据,因为申领明细是快照。所以这里不需要加”被订单引用”的检查——加了反而会让物资永远删不掉。这属于”快照设计的收益”。」不需要改
M7-1★ comboItems 的 name/price 信任前端”这个快照值是谁填的?”✅ 成立。ComboDTO.comboItems → ComboItem.name/price 前端传什么存什么(admin-ui/console.js 的 onPickGoods 从本地 allGoodsList 取值后回传);服务端只补 comboId主动承认,并对比 M3 已修复的同类缺陷:「形态和申领单”前端算金额”完全一样,我修了那处、漏了这处。区别是影响面:申领单是 P0(资金),组合包快照只影响展示(包价是独立字段)。但根因同一个:信任客户端。修法:用 goodsId 查库覆盖 name/price,顺带补上”物资是否存在”的校验(现在完全缺失)。」建议修(改动小,同时补两个缺口)
M7-2goodsId 不存在时不校验 → 孤儿关联”传一个不存在的物资 id 会怎样?”✅ 成立。comboDishMapper.insertBatch 直接写库(无外键);之后 GoodsMapper.getByComboId 用 LEFT JOIN,孤儿关联返回 null 而不报错 → 静默存在「全项目没有外键,引用完整性全靠应用层。而这里应用层也没校验。后果是setmeal_dish 里出现指向不存在物资的行,而且因为用 LEFT JOIN,启用组合包时的校验会静默跳过它。修法:insertBatch 前校验 goodsId 存在(和 M7-1 的修法是同一个)。」建议修(与 M7-1 一起)
M7-3修改组合包路径不校验包内物资状态”把停用物资加进已启用的组合包会怎样?”✅ 成立。ComboServiceImpl.update / saveWithItems 完全不检查包内物资的 status。所以可以造出”已启用、且含停用物资”的组合包,绕过启用校验和级联停用两道防线「启用时的校验(startOrStop)思路是对的——校验前置,非法状态不进库。但修改路径没有校验,所以数据库里仍然可能存在非法状态。**“数据库里永远不会出现非法状态”这个说法不成立。**修法:update/saveWithItems 也校验,或在 SQL 层 JOIN 时过滤。」建议修
M7-4组合包”包价 vs 包内成本”无校验”包价和包内物资总价对不上怎么办?“⚠️ 成立。setmeal.price 是独立字段、管理员单独填(comboForm.price),从不校验它与 Σ(item.price × copies) 的关系「包价和物资价格解耦是有意的(物资调价不该自动改包价)。但”完全没有校验”是另一回事——管理员可以把包价设成 1 元,而包内成本 200 元。对”内部申领”场景可能无所谓(不涉及利润),但它意味着包价是”与内容无关的可信数字”。如果以后要做成本核算或预算管控,这里是个缺口。至少应该在管理端给出提示(比如显示包内成本合计)。」建议加校验/提示
M7-5order_detail 只快照了包,没快照”包内构成""2025 年那张单,当时那个包里是什么?“⚠️ 成立。order_detail 快照了组合包的名字/价格/图片/数量,但没有存”当时包里包含哪些物资”。而 setmeal_dish 里的快照会被”先全删再全插”的修改覆盖「金额不会错(有包价快照),但审计细节会丢:组合包被改构成或被删之后,永远查不出”那笔申领当时领的是哪些物资”。修法:① 下单时把包展开成多条 order_detail(每条一个物资);② 或在 order_detail 加 JSON 字段存当时的构成快照。两个都没做。」建议修(若业务需要审计追溯)
M6/M7-6comboItems 为 null 时 NPE”显式传 comboItems: null 会怎样?“⚠️ 成立。ComboServiceImpl.saveWithItems 里 comboItems.forEach(...) 无判空;而 GoodsServiceImpl.saveWithSpecs 里对 flavors 做了判空。同一个项目两种写法「ComboDTO 里初始化成了 new ArrayList<>(),正常 JSON 反序列化不为 null。但如果请求体显式传 "comboItems": null,Jackson 会设成 null → NPE → 500。而且同一个项目里物资那边判了空、组合包这边没判——是不一致。」建议修(补判空,与 M6/M7 保持一致)
M6/M7-7dish_flavor.value 用逗号分隔存多个值”为什么不用一张规格值表?“⚠️ 设计取舍。value 是 VARCHAR(500) 存 "黑,白" 这种逗号分隔值。数据库层无法约束、无法按单个值查询(要 FIND_IN_SET/LIKE)「这是原项目遗留的设计,改造成采购场景时保留了——因为采购物资的规格维度也是”颜色可选几种、尺寸可选几种”。代价:无法按规格值查询、无法约束、无法建索引。当前项目没有任何”按规格值筛选”的功能,所以代价还没显现。如果要做规格筛选(比如”只看 A4 的纸”),就得拆成 flavor_value 子表或者用 JSON 列。」可选(取决于是否要规格筛选)
M6/M7-8GoodsItemVO 没有 status 字段”用户能看到包里有停用物资吗?”✅ 成立。GET /user/combo/goods/{id} 返回 GoodsItemVO{name, copies, image, description}——没有 status「接口层面就没给这个信息,所以即使前端想过滤停用物资也拿不到数据。修法:加 status 字段让前端能标记或过滤。」建议加(与 M7-3 配套)
M6/M7/M8-9全项目没有外键,引用完整性靠应用层”删了物资,关联表里的数据怎么办?“⚠️ 成立。无任何外键约束。项目已经因此产生过孤儿数据——M2-13 记录的孤儿预算行(dept_id 指向已删部门),回归脚本第 27 节专门写了一步清理「无外键的好处是插入顺序自由、不受约束检查影响、方便分库分表;代价是引用完整性完全靠应用层保证,而应用层必然有漏——项目已经真实产生过孤儿预算行(回归脚本里有专门的清理步骤,注释写明”历史上就因此留下过”)。这是个权衡,但至少要对外键缺失有意识、并且有定期的孤儿数据检查。」可选(要有意识 + 加对账检查)

明确不是问题、但可能被问的点

点回答
申领车为什么不用 Redis三个理由,核心是第三个:① 高频写——每次加/减都写,用 Redis 要处理持久化;② 必须持久化——关系重新打开浏览器车里的东西不能丢,而 Redis 异步持久化可能丢数据;③ 最关键:submitOrder 要在一个 MySQL 事务里做四件事(读申领车算金额、预扣预算、写单据和明细、清空申领车),申领车放 Redis 就跨存储了,事务边界会破。这是”什么时候不该用 Redis”的好例子
ShoppingCartMapper.list 为什么要显式写列别名必须写。数据库列名仍是 dish_id/setmeal_id/dish_flavor(表结构未改),而实体字段已改名为 goodsId/comboId/goodsSpec,驼峰映射对不上。如果用 SELECT *,这三个字段会查出 null,导致 addShoppingCart 里”判断是物资还是组合包”的逻辑完全失效(永远走 else 分支)。这个坑很隐蔽,作者避开了并且写了注释——是很好的正面例子
ComboMapper.getByIdWithItems 用 LEFT JOIN 是对的✅ 正确。如果用 INNER JOIN,空的组合包(没有任何关联物资)会查不出记录,前端会以为”组合包不存在”。LEFT JOIN 保证空包也能返回。而且它用 resultMap + <collection> 做嵌套映射,一次查询搞定”包 + 包内物资”,没有 N+1——这是全项目唯一正确处理一对多的查询,可以作为”我知道怎么写对”的正面例子(对比 M6 的 listWithSpecs)
删除组合包时不检查”是否被历史申领单引用”是对的✅ 正确。历史单据是快照,不依赖组合包存在。如果加了”被订单引用不能删”,组合包就永远删不掉了。这是快照设计的收益
删除物资时检查”被组合包引用”是必要的✅ 必要且正确。因为 GoodsMapper.getByComboId(启用校验用)依赖 setmeal_dish → dish 的关联。如果物资被删了但关联还在,启用校验就会拿到 null、静默跳过。所以”被引用不能删”是在保护这个校验的有效性
GoodsServiceImpl.startOrStop 的级联停用设计意图是对的✅ 对。它保证”不会申领到一个包含已停用物资的组合包”。问题不在设计意图,而在两个配套缺陷:级联后没清 combo:list:*(M5-1)、以及修改组合包这条路径没校验(M7-3)
GoodsMapper.getByComboId 不过滤 status 是对的✅ 正确。它被用来做”启用前校验包内物资”,需要拿到全部物资再逐个检查 status——如果 SQL 里就过滤掉停用的,那些停用物资就”看不见”了,校验反而失效
deleteBatch 用一次 IN 查询而不是循环✅ 做对了。comboDishMapper.getComboIdsByGoodsIds(ids) 一次 WHERE dish_id IN (...) 查出所有关联的组合包 id,没有循环单条查。对比 listWithSpecs 的 N+1,说明作者知道怎么写对——所以 listWithSpecs 更像是”为了可读性偷懒”,不是能力问题
insertBatch 用于”再来一单”✅ 做对了。OrderServiceImpl.repetition 把订单明细转成申领车记录,用一条 INSERT ... VALUES (...),(...) 批量插入,没有循环单条插入

七、M6-M8 关键数字与事实速查

事实值 / 位置
M6 表dish(+ 新增列 spec、unit)、dish_flavor(value 逗号分隔存多值)
M6 核心类GoodsServiceImpl(230 行):saveWithSpecs:48、pageQuery:76、deleteBatch:88、getByIdWithSpecs:119、updateWithSpecs:140、startOrStop:168、list:199、listWithSpecs:212(N+1)
M6 N+1listWithSpecs = 1 + N 次 SQL;被缓存掩盖(命中时 0 次)
M6 级联停用物资 → comboMapper.update(status=DISABLE) 级联停用包含它的组合包(startOrStop:175-190)· 但没清 combo:list:*(M5-1)
M6 删除校验① 在售不可删;② 被组合包引用不可删(getComboIdsByGoodsIds 一次 IN 查询)
M7 表setmeal、setmeal_dish(冗余快照 name/price,copies)
M7 核心类ComboServiceImpl(189 行):saveWithItems:49、pageQuery:74、deleteBatch:89、getByIdWithItems:112、update:123、startOrStop:150、list:176、getGoodsItemById:186
M7 快照信任前端ComboItem.name/price 前端传什么存什么(onPickGoods 从前端缓存取值);服务端只补 comboId;goodsId 存在性完全不校验
M7 启用校验startOrStop:150-173:启用前查包内物资,有停用的抛 ComboEnableFailedException——校验前置,思路对
M7 启用校验的三个缺口① update/saveWithItems 不校验 status(能造非法状态)② 级联停用没清缓存 ③ GoodsItemVO 无 status
M7 一对多查询ComboMapper.getByIdWithItems 用 resultMap + <collection> + LEFT JOIN,一次查询、无 N+1(项目里唯一正确的)
M8 表shopping_cart(user_id/dish_id/setmeal_id/dish_flavor/number/amount 快照)· 没有业务唯一索引
M8 核心类ShoppingCartServiceImpl(123 行):addShoppingCart:36(读-改-写竞态)、showShoppingCart:78、cleanShoppingCart:91、subShoppingCart:101
M8 的核心缺陷list() → get(0) → setNumber(+1) → UPDATE WHERE id=?,四步非原子 → 丢失更新
⭐ M2 vs M8 的对比同一项目、同一形态、两种做法:M2 用”条件 UPDATE + 影响行数”(✅ 正确);M8 把判断留在 Java 里(❌ 竞态)
M8 的 SQL 细节list 必须显式列别名(实体字段已改名,表结构未改)· 无 ORDER BY 却用 get(0) · updateNumberById 是 SET number = #{number}(不是 number + 1,所以必须先在 Java 里算好)
M8 未校验goodsId/comboId 是否存在(null → NPE → 500)· 物资/组合包是否启用(停用物资能加能提交)
申领车为何不用 Redis高频写 · 必须持久化 · 必须和 MySQL 同事务(关键)
全项目缺外键引用完整性靠应用层 · 已真实产生过孤儿预算行(M2-13)
未实现N+1 修复 · 申领车唯一索引 · ON DUPLICATE KEY UPDATE · 快照的服务端重算 · 组合包构成的订单级快照 · GoodsItemVO.status · 外键约束

八、如果只给我 3 分钟讲 M6-M8

这三个模块我放在一起讲,因为它们共享一条主线:服务端对自己能查到的权威数据、以及并发修改,处理得不够一致。

M6 物资目录:物资和规格是一对多,listWithSpecs 是”查物资列表 + 循环逐条查规格”,所以是 1+N 次 SQL。这个 N+1 一直没被发现问题,是因为缓存掩盖了它——命中缓存时一次库都不查。但这是脆弱的掩盖:Redis 冷启动、或者管理员改物资触发通配清缓存(一次修改让所有分类的缓存失效)、或者 Redis 内存打满导致缓存写不进去——这三种情况下 N+1 会在重建时集中打库。修法是一次 IN 查回来再 groupingBy 内存分组,项目里三处同类应该一起改。
另外物资停用时会级联停用包含它的组合包(设计意图是对的:避免申领到含停用物资的包),但级联之后没有清组合包缓存——这就是 M5 里那个缓存一致性 bug。

M7 组合申领包:setmeal_dish 冗余存了物资的 name 和 price 作为快照,目的是让包内构成与物资后续调价解耦——这是有意的反范式,设计正确。
但这两个快照值【完全来自前端】:前端从它自己缓存的物资列表里取 name/price 回传,服务端只补了个 comboId 就入库。而 goodsId 明明已经在请求里,服务端完全能查权威值。 这个形态和申领单”前端算金额”那个我已修复的 P0 缺陷完全一样——同一类问题我修了一处、漏了一处。区别是影响面:申领单是资金错误,这个只影响展示快照(包价是独立字段)。修法很简单:用 goodsId 查库覆盖,顺带补上”物资是否存在”的校验(现在完全缺失,会产生孤儿关联)。
这一块还有个好设计值得说:getByIdWithItems 用 resultMap + <collection> + LEFT JOIN,一次查询搞定”包 + 包内物资”,没有 N+1——这是全项目唯一正确处理一对多的查询。

M8 申领车:这是三者里最值得讲的,因为它是一个我能明确指出的并发缺陷。addShoppingCart 是”先 list() 查出来、get(0) 取第一条、在 Java 里 number + 1、再 UPDATE 回去”——读-改-写四步非原子,用户连点两次会丢失更新(都读到 1、都算出 2、最后写 2)。
关键是:申领单预扣预算那里是完全相同的形态(used_amount = used_amount + ?),我在那里用”把额度判断下推到 SQL 的 WHERE、用影响行数裁决”做对了;而申领车这里我把判断留在了 Java 里,所以做错了。 同一个项目、同一个形态、两种处理——这是项目内部的方案不一致,我认这个批评。
修法两层:一是原子 UPDATE(SET number = number + 1,看影响行数);更彻底是唯一索引 + INSERT ... ON DUPLICATE KEY UPDATE,一条 SQL 同时处理”有则累加、无则插入”。障碍是:表用 dish_id/setmeal_id 二选一表示”物资还是包”,而 MySQL 唯一索引不对 NULL 去重,所以得把这两列归一成一个字段、或者用生成列——这是表结构改动,所以我当时没做。
顺带两个 M8 的问题:list() 的 SQL 没有 ORDER BY 却用 get(0),所以”不确定取哪一行”;goodsId 不存在时会 NPE 返回 500。

最后的边界:这三个模块的性能结论我都是根据表结构和 SQL 推断的,没有压测、没跑过 EXPLAIN。而且原项目的建表脚本没有提交到仓库,所以 dish/dish_flavor/setmeal 这些原表的索引定义我看不到——比如”dish_flavor.dish_id 有没有索引”这个问题,我必须查 SHOW INDEX 才能回答,不能猜。