采云台 · 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 |
所以从”用户感知”上,这个接口一直都很快。 但这是脆弱的掩盖,有三个时刻会暴露:
- Redis 重启/清空后的冷启动:所有分类的缓存都不在,第一批请求全部回源,N+1 × 分类数同时打库;
- 管理员改物资后:
cleanCache("goods:list:*")是通配清除(见 M5-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
【这个联动设计的意图是对的】:保证”不会申领到一个包含已停用物资的组合包”。因为员工端能看到的组合包都是启用的,如果组合包里有停用物资、组合包还是启用的,员工就能申领到停用物资——联动停用把这个漏洞堵住了。
【但它有两个配套缺陷】:
- 清缓存漏了(M5-1)——数据库层面堵住了,但缓存层没堵,所以实际仍然可能申领到;
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 类:必须能讲清、能指着代码说
| 优先级 | 文件 / 位置 | 看什么 | 为什么 |
|---|---|---|---|
| P0 | ShoppingCartServiceImpl.java 第 36-76 行(addShoppingCart) | “查 → get(0) → +1 → UPDATE” 四步非原子 | M8 的核心缺陷。要能画出丢失更新的时序图,并对比 M2 的正确做法 |
| P0 | GoodsServiceImpl.java 第 212-229 行(listWithSpecs) | 循环里 goodsFlavorMapper.getByGoodsId = N+1 | 要能说清”被缓存掩盖”这个判断,以及三个会暴露它的时刻 |
| P0 | GoodsServiceImpl.java 第 167-191 行(startOrStop) | 级联停用组合包(comboMapper.update(status=DISABLE)) | 这是 M5-1 那个缓存 bug 的另一半。跨模块联动 + 缓存失效遗漏 |
| P0 | ComboServiceImpl.java 第 49-70 行(saveWithItems)+ 第 123-147 行(update) | comboItems 的 name/price 直接来自前端、goodsId 不校验存在性 | M7 的核心问题。要能对比 M3 已修复的”金额信任前端”,说明同类问题修一处漏一处 |
| P0 | ComboServiceImpl.java 第 150-173 行(startOrStop) | 启用前校验包内物资 + 指出三个缺口 | 要能说清”校验前置是对的,但 update 路径没校验、缓存没清、GoodsItemVO 没有 status” |
| P0 | ShoppingCartMapper.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.sql | setmeal_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 次。
项目里同一个模式有三处,应该一起改:
GoodsServiceImpl.listWithSpecs(物资列表)——最严重,因为是高频读;OrderServiceImpl.pageQuery4User(员工端申领单分页)——每页 10 条发 11 次;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-2 | list() 没有 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-4 | goodsMapper.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-1 | listWithSpecs 的 N+1(被缓存掩盖) | “一次请求发了几条 SQL?” | ✅ 成立。1 + N(N≈10-20)。缓存命中时 0 次,所以一直没暴露 | 主动承认,并说清”被缓存掩盖是脆弱的”——三个暴露时刻(Redis 冷启动 / 通配清缓存 / Redis 内存打满导致写不进缓存)。修法:IN 批量查 + groupingBy 内存分组,项目里三处同类应一起改 | 建议修 |
| M6-2 | updateWithSpecs: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-2 | goodsId 不存在时不校验 → 孤儿关联 | ”传一个不存在的物资 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-5 | order_detail 只快照了包,没快照”包内构成" | "2025 年那张单,当时那个包里是什么?“ | ⚠️ 成立。order_detail 快照了组合包的名字/价格/图片/数量,但没有存”当时包里包含哪些物资”。而 setmeal_dish 里的快照会被”先全删再全插”的修改覆盖 | 「金额不会错(有包价快照),但审计细节会丢:组合包被改构成或被删之后,永远查不出”那笔申领当时领的是哪些物资”。修法:① 下单时把包展开成多条 order_detail(每条一个物资);② 或在 order_detail 加 JSON 字段存当时的构成快照。两个都没做。」 | 建议修(若业务需要审计追溯) |
| M6/M7-6 | comboItems 为 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-7 | dish_flavor.value 用逗号分隔存多个值 | ”为什么不用一张规格值表?“ | ⚠️ 设计取舍。value 是 VARCHAR(500) 存 "黑,白" 这种逗号分隔值。数据库层无法约束、无法按单个值查询(要 FIND_IN_SET/LIKE) | 「这是原项目遗留的设计,改造成采购场景时保留了——因为采购物资的规格维度也是”颜色可选几种、尺寸可选几种”。代价:无法按规格值查询、无法约束、无法建索引。当前项目没有任何”按规格值筛选”的功能,所以代价还没显现。如果要做规格筛选(比如”只看 A4 的纸”),就得拆成 flavor_value 子表或者用 JSON 列。」 | 可选(取决于是否要规格筛选) |
| M6/M7-8 | GoodsItemVO 没有 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+1 | listWithSpecs = 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才能回答,不能猜。