采云台 · M3 申领单 · 深度分析
对应第三、四部分(完整执行链路 + 为什么这么设计)及第五、六、七部分中 M3 相关内容
依据:backend/cai-yun-tai实际源码逐行核验;mvn -o -DskipTests compile退出码 0
一句话定位:M3 是项目的主单据,也是事务边界最集中的地方。它的正确做法(金额重算、防重、归属校验、预算预扣)都在 627 行里,而它最大的缺口是——所有状态流转都是”先查再判断再写”,没有任何一条 SQL 把状态约束写进WHERE。
零、先纠正上一轮我写错的一处
在 M2 分析里我提到”Redis 防重键不主动删除”。后果我上一轮说反了,必须纠正:
我说”它防的是重复点击,2 秒内合法重试会被拦截”——这是错的。
【实际代码事实】user/OrderController.submit:52-58 的 SET NX 在 orderService.submitOrder() 之前、事务之外执行:
Controller: SET NX order:submit:{userId} → 成功
↓
Service: @Transactional 开始
扣预算失败 → 抛异常 → 事务回滚
↓
❌ Redis 的 order:submit:{userId} 键【不会回滚】,仍存在 5 秒
所以真实的副作用是:一次业务失败(余额不足、地址为空、采购已关闭)之后,用户在 5 秒内改好了条件重新提交,也会被”请勿重复提交申领单”挡住。错误提示与真实原因不符。
面试标准答法:
「防重键是在事务外写的,事务回滚不会回滚 Redis,所以一次失败提交后 5 秒内无法重试,提示还是’请勿重复提交’而不是真实原因。更好的做法是把防重做业务幂等——比如用客户端传的 requestId 做唯一索引,或者把 Redis key 的删除放到事务提交后的
TransactionSynchronization.afterCompletion里,回滚时主动删除。」
一、业务场景:申领单到底承担了什么
申领单不是一张普通的业务表,它同时是四件事的载体:
| 承载物 | 字段 | 为什么必须在同一张表 |
|---|---|---|
| 状态机 | status 1→6 | 单据生命周期,所有操作都要校验当前状态 |
| 金额与预算痕迹 | amount、pay_status | 钱和单据必须同事务,否则会出现”钱扣了没单据” |
| 审批痕迹 | approve_user、approve_time、rejection_reason、cancel_reason、is_returned | 审计追溯,谁批的、为什么驳回 |
| 组织归属 | user_id(申请人)、requester_dept(申请部门,冗余) | 越权校验 + 按部门统计 |
requester_dept 是冗余字段,可以从 employee.dept_id 关联得到。冗余的理由在代码注释里写明了:“便于按部门统计”。代价是:员工换部门后,历史单据的部门归属保持在提交时的值——这恰恰是正确的(历史单据应该归到当时花钱的部门),所以这个冗余是合理的反范式。
状态机全貌(Orders 常量,Orders.java):
1 PENDING_PAYMENT 待提交(原"待付款",新流程基本闲置,无任何代码路径会写入 1)
2 TO_BE_CONFIRMED 待审批 ← submitOrder 落库即此状态
3 CONFIRMED 采购中 ← confirm / 管理员核销前置
4 DELIVERY_IN_PROGRESS 配送中 ← delivery
5 COMPLETED 已完成 ← complete / confirmReceipt
6 CANCELLED 已取消 ← userCancelById / rejection / cancel / returnOrder 【三态合一】
“6 三态合一”是一个值得主动讲的设计取舍:已取消、已驳回、已退换共用状态 6,靠 cancelReason / rejectionReason / isReturned 三个字段区分。
- 好处:状态机简单,所有”终态”判断只需
status == 6; - 代价:无法用索引查询”所有被驳回的单据”,得
WHERE status=6 AND rejection_reason IS NOT NULL;而且从状态码本身看不出终态原因。
二、数据库与关键 SQL
2.1 状态流转全集(这是 M3 的核心 —— 注意每一条都不含状态条件)
<!-- OrderMapper.xml 第 27-63 行,全项目唯一的一条 orders UPDATE(通用动态 SQL,被 7 个方法复用)
精简展示,只列出 M3 关心的几个字段 -->
<update id="update" parameterType="com.caiyuntai.entity.Orders">
update orders
<set>
<if test="cancelReason != null and cancelReason!=''">cancel_reason=#{cancelReason},</if>
<if test="rejectionReason != null and rejectionReason!=''">rejection_reason=#{rejectionReason},</if>
<if test="cancelTime != null">cancel_time=#{cancelTime},</if>
<if test="payStatus != null">pay_status=#{payStatus},</if>
<if test="payMethod != null">pay_method=#{payMethod},</if>
<if test="checkoutTime != null">checkout_time=#{checkoutTime},</if>
<if test="status != null">status = #{status},</if> ← 直接把目标状态写进去
<if test="deliveryTime != null">delivery_time = #{deliveryTime},</if>
<if test="approveUser != null">approve_user = #{approveUser},</if>
<if test="approveTime != null">approve_time = #{approveTime},</if>
<if test="approveRemark != null and approveRemark!=''">approve_remark = #{approveRemark},</if>
<if test="remindTime != null">remind_time = #{remindTime},</if>
<if test="isReturned != null">is_returned = #{isReturned},</if>
</set>
where id = #{id} ← ★★★ 第 61 行,只有 id,没有任何 status 条件
</update>where id = #{id}(第 61 行) —— 这一行是 M3 最大的技术缺口。所有状态校验都在 Java 内存里做,SQL 层全无防护;status 是无条件写入的目标值,不是校验条件。
代码里还留了一条注释(第 50-52 行):「注意:
delivery_time必须保持”带逗号”的写法且不能放在<set>最末位,否则当它单独为空、而后面还有审批字段命中时,会拼出status = ? approve_user = ?这种缺逗号的非法 SQL」。
说明作者曾经踩过坑。这其实是<set>标签的已知特性(它只去掉整个 set 内容首尾的逗号,不处理中间的空洞),更稳的写法是用<trim suffixOverrides=",">包一层。当前代码把所有<if>的逗号都放在字段后面,所以这个坑已经被规避了——注释是对历史写法的告警,属于留档性注释。
2.2 表结构与索引
| 索引/列 | 说明 |
|---|---|
uk_orders_number 唯一索引 | M3 的单号唯一性兜底(Redis INCR 是主手段) |
idx_orders_requester_dept | 按部门统计 |
idx_orders_approve_user | 按审批人统计 |
| 无索引 | user_id、status、order_time 都没有单独索引 |
⚠️ 这里有一个真实的查询性能缺口:员工端最常用的查询是
SELECT * FROM orders WHERE user_id = ? AND status = ? ORDER BY order_time DESC但 user_id、status、order_time 三个都没有索引(只有 requester_dept 和 approve_user 建了索引,而这两个几乎只用于统计)。这意味着员工端的”我的申领单”查询是全表扫描 + filesort。 数据量小的时候看不出来,上万单就是明显的慢查询。
面试标准答法:
「当时只有
requester_dept和approve_user建了索引,是为了部门维度的统计。但员工端最热的查询是WHERE user_id = ? AND status = ? ORDER BY order_time DESC,这三个字段一个索引都没有,全表扫描加 filesort。正确的做法是建复合索引idx_orders_user_status_time (user_id, status, order_time DESC)——user_id和status用于等值过滤,order_time用于排序,这样能同时消掉扫描和 filesort。管理端的pageQuery是动态条件(optionalnumber/phone/beginTime/endTime),很难用一个索引覆盖,需要按实际查询模式再决定。」
三、完整代码执行链路
M3 有 7 条链路。★ 标记的是最需要掌握的三条。
★★★ 链路 1:提交申领单(submitOrder,全项目最重要的一段代码)
POST /user/order/submit Body: {addressBookId, amount(会被覆盖), remark, payMethod, ...}
Header: authentication={JWT}① JwtTokenUserInterceptor.preHandle()
JwtUtil.parseJWT(userSecretKey, token) → 验签
Redis GET login:token:{JWT} → 白名单(登出即失效)
employeeMapper.getById(empId) → 查库校验 status != DISABLE
BaseContext.setCurrentId / setCurrentRole
② user/OrderController.submit(OrdersSubmitDTO) ← 第 50 行
★ Redis SET NX order:submit:{empId} "1" TTL=5s
抢到 → 继续
未抢到 → throw OrderBusinessException(ORDER_REPEAT_SUBMIT)
※ 【关键】这一步在事务【之外】。事务回滚不会回滚这个键(见第零部分)
└─ orderService.submitOrder(dto)
③ OrderServiceImpl.submitOrder(dto) @Transactional ← 第 65-66 行,事务起点
【3.1】Redis GET shop:status
status != null && status == 0 → throw OrderBusinessException(SHOP_CLOSED)
※ 这一句是"采购开关"的唯一真实消费点(M2 地图里提过)
【3.2】AddressBookMapper.getById(dto.getAddressBookId()) → MySQL
null → throw AddressBookBusinessException(ADDRESS_BOOK_IS_NULL)
※ 原项目的"百度地图配送范围校验 + FastJSON 解析"在此被删除,
注释写明:企业内部物资送到部门/工位,不存在配送范围概念
【3.3】userId = BaseContext.getCurrentId()
ShoppingCartMapper.list(userId) → MySQL
null 或 size()==0 → throw ShoppingCartBusinessException(SHOPPING_CART_IS_NULL)
【3.4】★ EmployeeMapper.getById(userId) → MySQL
employee == null || deptId == null
→ throw BudgetBusinessException(BUDGET_NOT_FOUND)
deptId = employee.getDeptId()
【3.5】★ 金额重算(安全关键,第 104 行)
amount = shoppingCartList.stream()
.map(c -> c.getAmount().multiply(BigDecimal.valueOf(c.getNumber())))
.reduce(BigDecimal.ZERO, BigDecimal::add)
※ 完全忽略 DTO 里的 amount。
修复前的缺陷是"前端算好钱告诉后端扣多少":
可传 amount=0 零元提交;传负数会让预扣 SQL 的
total_amount - used_amount >= amount 恒成立,
used_amount = used_amount + 负数 会【反向下减】,凭空增加额度
【3.6】★★ BudgetService.deduct(deptId, period, amount) ← 第 107 行
period = budgetService.currentPeriod() → "2026-09"
→ UPDATE budget SET used_amount = used_amount + ?
WHERE dept_id=? AND period=? AND status=1
AND total_amount - used_amount >= ? ← M2 的核心
影响行数 0 → throw BudgetBusinessException(BUDGET_NOT_ENOUGH)
→ ★★ 异常抛出 → 整个事务回滚 → 不产生任何单据、申领车不清空
影响行数 1 → 继续
【3.7】★ 生成申领单号(第 121-127 行)
dateStr = LocalDate.now().format(BASIC_ISO_DATE) → "20260930"
seq = Redis INCR order:number:20260930
seq == 1 → Redis EXPIRE 2 天(仅首次设 TTL,避免每天残留永久键)
number = "PO" + dateStr + "-" + String.format("%06d", seq)
→ "PO20260930-000137"
※ 修复前是 String.valueOf(System.currentTimeMillis()):
毫秒时间戳,同毫秒并发会重复;且 orders.number 无唯一索引
【3.8】组装并落库主表(第 113-135 行)
BeanUtils.copyProperties(dto, orders) ← DTO → 实体
orders.setAmount(amount) ← 用重算值覆盖前端值
orders.setOrderTime(LocalDateTime.now())
orders.setPayStatus(Orders.PAID) = 1 已扣减(注释:预算已预扣成功)
orders.setStatus(Orders.TO_BE_CONFIRMED) = 2 待审批
orders.setAddress(addressBook.getDetail())
orders.setPhone(addressBook.getPhone())
orders.setConsignee(addressBook.getConsignee())
orders.setUserId(userId)
orders.setRequesterDept(deptId)
orders.setIsReturned(0)
OrderMapper.insert(orders) → MySQL,useGeneratedKeys 回填 orders.id
【3.9】循环组装明细(第 137-146 行)
for (ShoppingCart cart : shoppingCartList)
BeanUtils.copyProperties(cart, orderDetail) ← cart → OrderDetail
orderDetail.setOrderId(orders.getId())
OrderDetailMapper.insertBatch(orderDetailList)
※ 一条 INSERT ... VALUES (...),(...),... 批量插入;
因为 3.3 已保证申领车非空,这里不会产生空的 VALUES 语法错误
【3.10】ShoppingCartMapper.deleteByUserId(userId) → MySQL,清空申领车
【3.11】返回 OrderSubmitVO{id, orderNumber, orderTime, orderAmount}
④ Result.success(vo)
事务边界:@Transactional 在 submitOrder 上(第 65 行)。Redis 的三次操作(shop:status 读、order:submit 写、order:number INCR)全部在事务的语义之外——MySQL 回滚不会撤销它们。这是理解”混合存储的一致性”最好的示例。
并发:预扣靠 InnoDB 行锁(M2);防重靠 Redis SET NX;两者解决的是不同问题(一个防超支,一个防重复建单)。
缓存:shop:status 是唯一的缓存参与。
★★★ 链路 2:员工撤销 / 管理员驳回 / 管理员取消(三条链路同一模式)
这三条链路的结构完全一样,是同一个”读-判断-写”反模式的三份拷贝:
① OrderMapper.getById(id) → MySQL,读取当前单据
② 判空 + 状态校验(+ 归属校验,仅员工端)
③ releaseBudget(ordersDB) → 可能扣回预算(M2)
④ orderMapper.update(orders{status=6, ..., payStatus=2}) ← 只按 id 更新
| 链路 | 入口 | 归属校验 | 状态校验 | 事务 | releaseBudget |
|---|---|---|---|---|---|
| 员工撤销 | userCancelById:232 | ✅ assertOwner | status > 2 拒绝 | @Transactional(rollbackFor=Exception.class) | ✅ |
| 管理端驳回 | rejection:434 | ❌ 不需要 | !status.equals(2) 拒绝 | 同上 | ✅ |
| 管理端取消 | cancel:463 | ❌ 不需要 | status == 5 或 6 拒绝 | 同上 | ✅ |
⚠️ 三条链路都没有”状态条件更新”,所以第 ② 步的状态校验和 ④ 步的写入之间存在一个窗口。这在单用户顺序操作下没问题(第二次进来状态已经变了,被 ② 挡住),但在并发下会出问题。
具体演示(这是 M3 最值得准备的追问答案):
时刻 请求 A:员工撤销 (PUT /user/order/cancel/100)
请求 B:管理员驳回 (PUT /admin/order/rejection {id:100})
─────────────────────────────────────────────────────────────────
t1 getById(100) → status=2, payStatus=1
t2 getById(100) → status=2, payStatus=1
t3 status=2 不 >2,通过
t4 status=2 == 2,通过
t5 releaseBudget: payStatus==1 → 返还预算 32
t6 releaseBudget: payStatus==1 → 再返还 32 ⚠️ 重复返还
t7 UPDATE ... WHERE id=100 → cancel_reason="员工撤销申领", status=6, pay_status=2
t8 UPDATE ... WHERE id=100 → rejection_reason=..., status=6, pay_status=2
─────────────────────────────────────────────────────────────────
结果:预算被返还了两次(M2 已知缺口的实际触发场景),且最终单据的
cancel_reason 和 rejection_reason 同时存在,审计信息混乱
注意这里两个请求都写 status=6,所以”状态”看起来是对的,掩盖了重复返还。如果换成 confirm(写 status=3)和 rejection(写 status=6)并发,结果就是最终状态取决于谁后写,而预算可能已经被退了——单据显示”采购中”,钱却退回去了。
正确修法(和 M2 的思路完全一致:把判断下推到 WHERE):
<update id="updateStatus">
update orders
set status = #{newStatus}, pay_status = #{payStatus}, cancel_time = #{cancelTime}, ...
where id = #{id} and status = #{expectedStatus} ← 状态约束进 SQL
</update>int rows = orderMapper.updateStatus(order, Orders.TO_BE_CONFIRMED);
if (rows == 0) {
throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR); // 已被别人处理
}
// 只有影响行数 = 1 才继续 releaseBudget() —— 状态迁移成功即"认领处理权"这一句话是 M3 最重要的面试答案:把状态迁移做成条件更新,用影响行数裁决,只有成功迁移的那一个请求才有权动预算。
★★ 链路 3:审批通过 / 发货 / 核销 / 确认收货(四个”流转”方法)
四个方法结构一样,而且都没有 @Transactional:
// confirm:408 —— 审批通过 【无 @Transactional】
Orders ordersDB = orderMapper.getById(dto.getId());
if (ordersDB == null || !Orders.TO_BE_CONFIRMED.equals(ordersDB.getStatus()))
throw new OrderBusinessException(ORDER_STATUS_ERROR);
Orders orders = Orders.builder()
.id(dto.getId()).status(Orders.CONFIRMED)
.approveUser(BaseContext.getCurrentId()) ← 审批人(从 ThreadLocal 取)
.approveTime(now)
.checkoutTime(now) ← ★ 复用"结账时间"字段记录审批时间
.build();
orderMapper.update(orders);
// delivery:491 —— 发货 校验 status == CONFIRMED(3) → 置 DELIVERY_IN_PROGRESS(4)
// complete:513 —— 核销 校验 status == DELIVERY_IN_PROGRESS(4) → 置 COMPLETED(5) + deliveryTime
// confirmReceipt:537 —— 员工确认收货
// 校验 status == 4 + 【二次归属校验】BaseContext.getCurrentId().equals(ordersDB.getUserId())
// → 置 COMPLETED(5) + deliveryTime三个技术点:
-
confirm无@Transactional是正确的:它只有一条 UPDATE,单语句自带原子性。不要为了规范乱加。 -
checkoutTime被复用记录审批时间(第 421 行,注释写明”复用checkout_time记录审批通过时间”)。但confirm同时写了approveTime和checkoutTime两个字段存同一个时间值——这是冗余写入,属于改造时”既想保留新语义字段又怕前端不认旧字段”的中间状态。面试被问”为什么写两次”只能承认是冗余。 -
confirm对已审批的单据会”成功”(status变成 3 后再调confirm,Java 校验会拦住 → 抛状态错误。等等,这里我要重新确认——)⚠️ 重新核对:
confirm的校验是!Orders.TO_BE_CONFIRMED.equals(ordersDB.getStatus())→ 第二次调用时status=3,不等于 2,会抛异常。所以顺序重复是被拦住的。只有并发重复拦不住(两个请求都读到 status=2)。我在项目地图和前面 M2 里说的”重复审批会成功”表述不准确,这里更正为:顺序重复被 Java 校验拦住;并发重复拦不住。
-
confirm没有归属校验,是有意的——管理端接口,审批人可以是任何 ADMIN/PURCHASER(@RequireRole({ADMIN, PURCHASER}))。
链路 4:员工端分页查询(pageQuery4User —— N+1)
GET /user/order/historyOrders?page=1&pageSize=10&status=2
↓ user/OrderController.page
↓ OrderServiceImpl.pageQuery4User(pageNum, pageSize, status) ← 第 170 行
├─ PageHelper.startPage(pageNum, pageSize) ← ThreadLocal 存分页参数
├─ dto.setUserId(BaseContext.getCurrentId()) ← 强制只查自己的(无越权风险)
├─ dto.setStatus(status)
├─ Page<Orders> page = orderMapper.pageQuery(dto)
│ SQL: SELECT * FROM orders
│ <where> userId / status 动态条件 </where>
│ ORDER BY order_time DESC
│ ← PageHelper 拦截后自动加 LIMIT
│ ⚠️ user_id / status / order_time 都【无索引】→ 全表扫描 + filesort
├─ if (page != null && page.getTotal() > 0)
│ for (Orders orders : page) {
│ ★ List<OrderDetail> orderDetails = orderDetailMapper.getByOrderId(orderId)
│ ← ★ N+1:每页 10 条就多发 10 次 SQL
│ OrderVO orderVO = new OrderVO();
│ BeanUtils.copyProperties(orders, orderVO) ← Orders → OrderVO
│ orderVO.setOrderDetailList(orderDetails)
│ }
└─ return new PageResult(page.getTotal(), list)两个缺口:
- N+1 查询:10 条单据 = 1 + 10 = 11 次 SQL。修法是一次
SELECT * FROM order_detail WHERE order_id IN (...)再在内存按orderId分组。 - ⚠️ 潜在的 NPE:
return new PageResult(page.getTotal(), list)在if块外面。如果page为null,这里直接 NPE。
实际上 PageHelper 的Page不会为 null(查询无结果时返回空Page),所以现在不会触发。但这是脆弱的写法——一旦以后把pageQuery换成返回普通List,page.getTotal()立刻 NPE。面试被问”这里会不会空指针”时,要能答出”PageHelper 保证了非空,但把getTotal()放在判空块外是脆弱的”。
链路 5:管理端条件搜索(conditionSearch —— 另一处 N+1 + 字符串拼接)
GET /admin/order/conditionSearch?page=1&pageSize=10&number=PO&status=2&beginTime=...&endTime=...
↓ OrderServiceImpl.conditionSearch:331
├─ PageHelper.startPage(dto.getPage(), dto.getPageSize())
├─ Page<Orders> page = orderMapper.pageQuery(dto) ← 同一个 SQL,条件更全
└─ getOrderVOList(page) ← 第 342 行
for (Orders orders : page) {
OrderVO orderVO = new OrderVO()
BeanUtils.copyProperties(orders, orderVO)
String orderGoods = getOrderGoodsStr(orders) ← ★ N+1 在这里
orderVO.setOrderGoods(orderGoods) ← "A4纸*2;签字笔*1;"
orderVOList.add(orderVO)
}
getOrderGoodsStr(orders) ← 第 368 行
List<OrderDetail> list = orderDetailMapper.getByOrderId(orders.getId())
list.stream().map(x -> x.getName() + "*" + x.getNumber() + ";")
.collect(Collectors.toList())
return String.join("", orderGoodsList)这比员工端的分页更值得说:它查出明细不是为了返回结构化的明细列表,而是为了拼成一个字符串给管理端表格显示。这说明:
- 技术上:在 SQL 层做字符串聚合(
GROUP_CONCAT)比拉回内存拼接更合适,一次查询就能带出所有单据的物资摘要; - 设计上:它暴露了接口设计的问题——管理端列表要显示物资摘要,就应该在 SQL 里聚合出来,而不是为了 UI 展示在应用层做 N 次查询 + 字符串拼接。
面试标准答法:
「这里有两个问题。一是 N+1,每页 10 条要发 11 次 SQL。二是它查明细不是为了返回明细结构,而是为了拼一个
"物资名*数量;"的字符串给表格展示——这个拼接本可以在 SQL 层用GROUP_CONCAT一次完成,或者干脆用一次WHERE order_id IN (...)批量查回来在内存分组,两种做法都能把 11 次查询降到 2 次。」
链路 6:订单统计(statistics —— 三次 COUNT 可以合并)
GET /admin/order/statistics
↓ OrderServiceImpl.statistics:387
├─ orderMapper.countStatus(Orders.TO_BE_CONFIRMED) → SELECT count(id) WHERE status=2
├─ orderMapper.countStatus(Orders.CONFIRMED) → WHERE status=3
├─ orderMapper.countStatus(Orders.DELIVERY_IN_PROGRESS) → WHERE status=4
└─ OrderStatisticsVO{toBeConfirmed, confirmed, deliveryInProgress}三次独立查询,可以合并成一次:
SELECT status, count(id) FROM orders WHERE status IN (2,3,4) GROUP BY status而且 status 也没有索引,三个 COUNT 都是全表扫描。
注意 status 的索引缺失在这里更尴尬:countStatus 的 SQL 是 WHERE status = #{status},唯一条件就是 status,却偏偏没有索引。加一个 status 索引就能把三次全表扫描变成三次索引范围扫描。
★ 链路 7:催办(reminder —— Redis 限流的正确用法示例)
GET /user/order/reminder/{id}
↓ OrderServiceImpl.reminder:595
├─ orderMapper.getById(id) → MySQL
│ null → throw OrderBusinessException(ORDER_STATUS_ERROR) ← 【问题】应该报 ORDER_NOT_FOUND
├─ assertOwner(ordersDB) → 归属校验
├─ if (!Orders.TO_BE_CONFIRMED.equals(status)) throw ORDER_STATUS_ERROR
│ ← 只有待审批才需要催办
├─ ★ Redis 限流(第 610-618 行)
│ remindKey = "order:reminder:" + id
│ Boolean first = SET NX remindKey, userId, TTL=5min
│ if (!TRUE.equals(first)) {
│ Long ttl = GETEXPIRE remindKey, SECONDS ← 读出剩余时间
│ throw new OrderBusinessException(ORDER_REMINDER_TOO_FREQUENT
│ + "(剩余 " + (ttl == null ? "约 5 分钟" : ttl + " 秒") + ")")
│ }
└─ orderMapper.update(orders{id, remindTime=now})这是全项目 Redis 限流用得最规范的一处,值得单独讲:
- 单条原子命令:用
setIfAbsent(key, value, timeout, unit)一条命令完成”判断 + 设置 + 过期”,而不是SETNX+ 单独EXPIRE两步(两步之间进程崩溃会留下永不过期的锁,是经典事故); - 先业务校验、后限流:
assertOwner和状态校验都在setIfAbsent之前。顺序很重要——如果先限流,那么一个非法请求(比如催办别人的单据)也会消耗掉 5 分钟的额度,把正常请求挤掉; - 限流提示带剩余时间:读 TTL 返回”剩余 N 秒”,用户体验好;
- 不用主动删除:等 TTL 自然过期。催办是低频操作(每单 5 分钟一次),不需要额外的清理逻辑。
唯一的瑕疵:orderMapper.getById(id) == null 时抛的是 ORDER_STATUS_ERROR(“申领单状态错误”)而不是 ORDER_NOT_FOUND(“申领单不存在”)——单据根本不存在,提示却说状态错误。这是错误码复用不当,同一文件里 details、userCancelById、cancel、returnOrder 都正确用了 ORDER_NOT_FOUND,只有 reminder 用错了。
四、为什么这么设计(核心部分)
4.1 状态流转为什么不用”条件更新”——这是 M3 的核心问题
【业务场景】
一张申领单要被审批、驳回、取消、发货、核销、确认收货,多个操作者(员工 + 采购专员 + 管理员)可能同时操作同一张单。
【原始方案(即当前代码)】
Orders o = orderMapper.getById(id); // ① 读
if (!TO_BE_CONFIRMED.equals(o.getStatus())) // ② 判断(JVM 内存里)
throw new OrderBusinessException(...);
orderMapper.update(orders{id, status = CONFIRMED}); // ③ 写:WHERE id = ?【原始方案的问题】
① 读 和 ③ 写 之间有一个没被任何锁保护的窗口。在窗口内:
- 另一个请求可以读到同样的旧状态,通过同样的校验;
- 两个请求都会执行 ③,后写的覆盖先写的(最后写赢 last-write-wins)。
后果分三类:
| 场景 | 后果 |
|---|---|
| 两个请求做同样的流转(审批 × 2) | 状态看起来正常,但 approve_user / approve_time 被后者覆盖——审批人可能记错人 |
| 两个请求做不同的流转(审批 + 驳回) | 最终状态取决于谁后写;且驳回可能已经退了预算 → 单据显示”采购中”,钱却退回去了 |
| 流转 + 撤销(员工撤销 + 管理员取消) | 预算被重复返还(M2 的已知缺口就是这个场景) |
【当前方案的”防线”及其真实强度】
当前代码实际上依赖三层”看起来安全”的东西,但没有一层是原子的:
| 依赖 | 实际强度 |
|---|---|
Java 里的状态 if 校验 | 只能防顺序重复(第二次读到的状态已变),防不住并发 |
@Transactional(三条返还链路有) | 保证单次操作内部的原子性(预算 + 单据同时成功或同时失败),不提供互斥 |
payStatus 状态机守卫(M2) | 只能防顺序重复返还,并发下两个请求都可能读到 payStatus=1 |
【替代方案对比】
| 方案 | 实现 | 优点 | 缺点 | 是否适用本项目 |
|---|---|---|---|---|
| A. 状态条件更新(推荐) | UPDATE orders SET status=? WHERE id=? AND status=?,用影响行数裁决 | 一次往返;状态迁移原子化;不需要锁;和 M2 的预扣同一个思路,架构一致 | SQL 数量会增加(每个流转一条带条件的 SQL,而不是复用一条通用 update) | ✅ 最优,改动小、收益明确 |
B. 复用现有 update + 在 <set> 加 status 条件 | 同上,但 SQL 复用 | 改动更小 | 通用 update 要加 expectedStatus 参数,语义会变含糊 | ✅ 折中 |
C. SELECT ... FOR UPDATE 悲观锁 | 读的时候加锁,锁内判断+更新 | 逻辑留在 Java、可表达复杂规则 | 持锁时间变长;必须包在事务里,delivery/complete/confirmReceipt 现在都没有 @Transactional,加了锁还得加事务,改动面变大 | 可行但不是首选 |
| D. 乐观锁(version 列) | 加 version,UPDATE ... WHERE version=? | 无锁等待 | 单据是热点吗?不是——同一张单被并发操作是低频事件。乐观锁的失败重试在这里是纯负担 | ❌ 冲突模式不匹配 |
| E. 分布式锁 | 锁 order:{id} | 跨服务可用 | 单库单行,过度设计(同 M2 的论证) | ❌ |
| F. 数据库唯一约束(状态迁移表) | 建 order_transition(order_id, from, to) 唯一索引 | 数据库层绝对防重 | 多一张表;本项目太轻 | ❌ 过度 |
【为什么选 A】
「这个问题的本质是状态机迁移的原子性。当前是”读出状态 → 在 JVM 里判断 → 无条件写回”,这在顺序操作下没问题,并发下就是典型的 read-modify-write 竞态。
修法和 M2 的预算预扣完全一致:把状态约束写进
WHERE,用影响行数判断这次迁移是否成功。UPDATE orders SET status=3 WHERE id=? AND status=2返回 1 才说明”我把这张单从待审批推到了采购中”,返回 0 说明已经被别人处理过了。这样做有三个好处:一是不需要锁,数据库的行锁就够;二是和项目里已有的并发方案保持一致,不用引入新模式;三是顺带解决了重复返还——只有成功迁移状态的请求才有权调
releaseBudget,天然就是幂等的。」
4.2 金额为什么必须后端重算
【业务场景】 员工提交申领单时,前端已经算好了总价并显示在页面上。
【原始方案(改造前的真实缺陷)】
BigDecimal amount = ordersSubmitDTO.getAmount() == null ? BigDecimal.ZERO : ordersSubmitDTO.getAmount();【原始方案的问题】
amount 完全来自请求体,从不按 shopping_cart 重算。前端提交的正是 amount: Number(this.totalAmount)——前端算好钱,告诉后端扣多少。三个攻击面:
- 传
amount = 0→ 零成本提交申领单,used_amount += 0,单据金额为 0; - 传
amount = -100000→ 预扣 SQL 的total_amount - used_amount >= -100000恒成立,used_amount = used_amount + (-100000)反向下减已用金额 → 凭空增加可用额度; - 项目接口文档已经导出(
采云台-接口文档-用户端.json),任何拿到接口的人都能直接打,不需要篡改前端。
【当前方案】
BigDecimal amount = shoppingCartList.stream()
.map(c -> c.getAmount().multiply(BigDecimal.valueOf(c.getNumber())))
.reduce(BigDecimal.ZERO, BigDecimal::add);【为什么这么设计】
- 权威数据源原则:价格和数量都存在服务端(
shopping_cart.amount是加入申领车时从dish.price抄的快照,number是服务端维护的),没有任何理由信任客户端传的钱; - 防御式设计:
BudgetServiceImpl.deduct入口还有第二道amount.signum() <= 0 → return false。两道防线独立成立——即使将来有人改动重算逻辑漏掉负数,deduct也拦得住。
【替代方案对比】
| 方案 | 评价 |
|---|---|
| A. 服务端按明细重算(当前方案) | ✅ 唯一正确做法。服务端掌握权威数据就必须自己算 |
| B. 前端算 + 后端校验”与明细一致” | 等价于 A,但多一次比较,没有理由不比 A 更直接 |
| C. 前端算 + 后端只做范围校验(>0 且 < 上限) | ❌ 防不住”把 320 改成 32”,仍可少付 |
| D. 签名/加密金额 | ❌ 治标不治本,密钥在前端就是公共知识 |
面试可延伸的一句:「这个缺陷的本质是信任边界划错了——把价格的权威性交给了客户端。项目里同类问题还有一处:‘采购开关’shop:status 也是前端不感知的(前端从未调用 /user/shop/status),所以关闭采购后前端界面还是照常显示的,只是提交时才被拦。前端该同步的状态,后端得推给它。」
4.3 单号为什么用 Redis INCR 而不是时间戳
【业务场景】 申领单号是业务主键,要对账、要跟外部沟通,绝对不能重复。
【原始方案】
orders.setNumber(String.valueOf(System.currentTimeMillis()));【原始方案的问题】
- 毫秒级时间戳,同毫秒并发提交会生成完全相同的单号;
orders.number当时没有唯一索引,数据库也不会拦,重复单号直接落库。
【当前方案】
String dateStr = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
String numberKey = RedisKeyConstant.ORDER_NUMBER_PREFIX + dateStr;
Long seq = stringRedisTemplate.opsForValue().increment(numberKey, 1);
if (seq != null && seq == 1) {
stringRedisTemplate.expire(numberKey, 2, TimeUnit.DAYS); // 仅首次设 TTL
}
orders.setNumber("PO" + dateStr + "-" + String.format("%06d", seq));得到 PO20260930-000137。
【为什么这么设计】
| 设计点 | 理由 |
|---|---|
INCR 而不是 GET + SET | INCR 是 Redis 单线程模型下的原子命令,天然并发安全,和 M2 用行锁是同一个思路 |
日期分片 order:number:{yyyyMMdd} | 单号每天重置,长度可控、可读、可按天排查;也避免单个 key 无限增长 |
| 序号补零到 6 位 | 定长单号,字符串排序等价于时间排序 |
seq == 1 时才 EXPIRE | 只给首次创建的 key 设过期。如果每次都 EXPIRE,过期时间会被无限刷新,key 就变相永生了 |
| 2 天 TTL | 跨天之后当天的 key 就没用了,2 天留出容错窗口 |
配套 uk_orders_number 唯一索引 | 数据库层最后兜底(见下面已知边界) |
【已知边界 —— 必须主动说】
Redis 被清空 / 重启且无持久化时,序列从 1 重新开始。如果当天已经出过 PO20260930-000001,新单号会撞唯一索引 → 插入失败,用户提交不了申领单。
- 当前状态下未触发(改造后单号格式变了,库里存量都是旧格式);
- 有 2 天 TTL 收敛残留;
- 彻底方案:首次
INCR返回 1 时,用SELECT MAX(number) FROM orders WHERE number LIKE 'PO20260930-%'兜底初始化序列;或者捕获DuplicateKeyException后重试。
这个”主动说出边界”的答法比硬说”解决了唯一性问题”更有说服力,因为面试官很可能就是想问这里。
4.4 防重复提交为什么放 Controller 层、为什么用 Redis 而不是唯一索引
【业务场景】 用户手抖连点两下”提交”,会生成两张内容一样的申领单,扣两次预算。
【原始方案】 无任何防护。
【当前方案】 user/OrderController.submit 里:
Long userId = BaseContext.getCurrentId();
String submitKey = RedisKeyConstant.ORDER_SUBMIT_PREFIX + userId;
Boolean first = stringRedisTemplate.opsForValue().setIfAbsent(submitKey, "1", 5, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(first)) {
throw new OrderBusinessException(MessageConstant.ORDER_REPEAT_SUBMIT);
}【为什么用 Redis 而不是”去重表 + 唯一索引”】
| 方案 | 优点 | 缺点 |
|---|---|---|
A. Redis SET NX + TTL(当前) | 一条原子命令;TTL 自动过期,无需清理;不写库、无 IO 压力 | 是尽力而为的(Redis 挂了就失效);会误伤”失败后马上重试”(即第零部分的纠正) |
| B. 去重表 + 唯一索引 | 强一致,数据库层保证 | 要建表、要清理历史数据、每次提交多一次写库 |
| C. 前端按钮置灰 | 零成本 | 只防”手抖”,防不住直接打接口/脚本 |
D. 业务幂等键(客户端传 requestId,服务端唯一索引) | 真正的幂等,重试安全 | 需要前端配合生成并传 requestId;需要一张幂等记录表 |
【为什么这么设计】
关键在于去重的语义是”短时间窗口”而不是”永久唯一”:用户 5 秒内重复点击是误操作,5 秒后重新提交是正常行为。Redis TTL 天然匹配这个语义——过期自动失效,不需要任何清理逻辑。用唯一索引做这件事反而麻烦:你得决定”多久算重复”,然后定期删历史记录。
【这个方案的真实缺陷(面试要主动讲)】
- 粒度是
userId而不是”某一次提交”:所以它拦的是”这个人的第二次提交”,不是”这一次提交的重复”。5 秒内用户提交了 A 单、想立刻再提交 B 单,也会被拦住; - 在事务外、且失败不回滚:一次业务失败后 5 秒内无法重试,提示还是”请勿重复提交”;
- Redis 是单点:Redis 挂了防重就失效(降级为无防护)。
如果要做得更严,应该是 D 方案(客户端幂等键 + 数据库唯一索引):前端每次提交生成一个 requestId,服务端把 requestId 写进 orders(或一张幂等表)并建唯一索引,重复请求会插入失败返回同一个单据。这才是真正的幂等而不是”限流”。
4.5 越权校验(assertOwner)—— 为什么”返回不存在”而不是”无权限”
【业务场景】 员工端接口都是”按 id 操作一张单”,如果只按 id 查、不校验归属,任意登录员工遍历 id 就能读到别人的单据、撤销别人的单、甚至把别人的明细灌进自己的申领车(IDOR,越权访问)。
【原始方案的缺陷(已修复)】
details、userCancelById、repetition、reminder 四个方法只按 id 查单据,不比对 BaseContext.getCurrentId() 和 ordersDB.getUserId()。而 confirmReceipt 和 returnOrder 里有这个校验——说明是漏改四个接口,不是有意设计。
【当前方案】 抽出私有方法统一复用(第 285-289 行):
private void assertOwner(Orders orders) {
if (!BaseContext.getCurrentId().equals(orders.getUserId())) {
throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND);
}
}调用点:details:213、userCancelById:240、repetition:304、reminder:603。
【为什么抛 ORDER_NOT_FOUND(“申领单不存在”)而不是 NO_PERMISSION(“无操作权限”)】
这是一个有意的安全设计,值得单独讲:
- 如果返回”无权限”,攻击者就确认了这个 id 对应的单据真实存在——可以靠响应差异枚举出系统里有多少单据(ID 存在性探测);
- 返回”不存在”,越权和不存在在响应上完全一样,攻击者拿不到任何额外信息。
这个原则叫「不要向未授权方泄露资源是否存在」,是权限设计的标准做法。
【注意一处脆弱写法】 BaseContext.getCurrentId().equals(...) 把 getCurrentId() 放在前面。如果 getCurrentId() 返回 null(比如这个 Service 被其他非 HTTP 入口调用、拦截器没执行),会 NPE。更稳的写法是 orders.getUserId().equals(BaseContext.getCurrentId()),或者在方法开头判空。
五、M3 复习优先级文件清单(第五部分 · M3 切片)
M3 只有 627 行、一个文件,但不要从头读。按下面的顺序看,约 40 分钟。
A 类:必须能默写关键片段、能手画状态机
| 优先级 | 文件 / 位置 | 看什么 | 为什么 |
|---|---|---|---|
| P0 | OrderServiceImpl.java 第 65-160 行 | submitOrder 全流程(金额重算 → 预扣 → 单号 → 落库 → 清明细) | 全项目最重要的一段代码。要能按顺序口述 6 个步骤 |
| P0 | 同上 第 231-260 行(userCancelById)+ 第 271-283 行(releaseBudget) | 撤销链路 + 预算返还的双防线 | 与 M2 的交界处,也是”重复返还”追问的落点 |
| P0 | 同上 第 408-425 行(confirm)+ 第 434-455 行(rejection) | 审批通过 vs 驳回 | 对比着看:两者都是”读-判断-写”,且都无状态条件更新。这是 M3 缺口的现场 |
| P0 | OrderMapper.xml 第 27-63 行(update 整块,where 在第 61 行) | where id = #{id} | 就这一行说明了 M3 的全部问题。要能指着它说”status 是无条件写入的目标值,不是校验条件” |
| P0 | Orders.java 前 40 行 | 6 个状态常量 + 3 个预算状态常量 | 状态机的地图,必须背下来 |
| P0 | user/OrderController.java 第 48-61 行 | Redis 防重(SET NX) | 要能说清它在事务之外这个事实及其后果 |
B 类:知道流程和存在的问题即可
| 文件 / 位置 | 看什么 |
|---|---|
OrderServiceImpl 第 170-199 行 pageQuery4User | N+1 + page.getTotal() 放在判空块外的脆弱写法 |
OrderServiceImpl 第 331-380 行 conditionSearch + getOrderGoodsStr | 更严重的 N+1(为了拼字符串查明细) |
OrderServiceImpl 第 387-399 行 statistics | 三次 COUNT 可合并、status 无索引 |
OrderServiceImpl 第 595-626 行 reminder | Redis 限流最规范的一处(值得看,但只需记住 4 个要点) |
OrderServiceImpl 第 537-586 行 confirmReceipt / returnOrder | 归属校验的正确写法 + 退换不返还预算 |
OrderMapper.xml pageQuery(第 64-85 行) | 动态条件查询的写法 |
sql/migration_fix_defects_20260929.sql | uk_orders_number 唯一索引的建立方式(可重复执行的动态 SQL) |
C 类:不用看
OrdersDTO(全项目无引用,死代码)、OrdersConfirmDTO.status(声明了但从不读取)OrderVO/OrderSubmitVO/OrderStatisticsVO/OrderDetail—— Lombok 数据类OrderMapper.xml的insert/sumByMap/countByMap—— 标准 SQL(sumByMap/countByMap属于 M12 工作台)OrderMapper.getByNumberAndUserId/getByStatusAndOrderTimeLT—— 全项目无引用,是原项目的遗留方法
40 分钟复习顺序
1. Orders.java 状态常量 ← 2 分钟,背下来
2. OrderMapper.xml 第 27-63 行(where 在第 61 行) ← 3 分钟,指着 where id 说出问题
3. submitOrder 第 96-160 行 ← 12 分钟,能按顺序口述 6 步
4. releaseBudget + userCancelById ← 6 分钟,理解双防线
5. confirm vs rejection 对比 ← 6 分钟,找出并发窗口
6. OrderController 第 48-61 行 ← 4 分钟,防重 + 事务外的事实
7. 自己讲一遍"状态流转为什么会有并发问题" ← 7 分钟,这是必问
六、M3 面试追问链(10 层)
追问链 1:并发下的状态流转(M3 最可能被问的一条)
【面试官问题 1】
你这个申领单有好几个状态,审批、驳回、取消、发货都要改状态。这些操作并发的时候会有问题吗?
【推荐回答】
会有,这是我这个模块最大的一个缺口。 我的所有状态流转都是同一个模式:先 getById 读出单据,在 Java 里判断状态对不对,然后执行 UPDATE orders ... WHERE id = ?。读和写之间没有任何锁保护,而且这条 UPDATE 里也没有状态条件,所以两个并发请求可能都读到同一个旧状态、都通过校验、都写入——最后的结果取决于谁后写。
【回答关键词】 主动承认 · read-modify-write 竞态 · 缺状态条件更新 · last-write-wins
【可能继续追问】 能举个具体的例子吗?
【面试官问题 2】
具体会出什么问题?
【推荐回答】
最严重的是”审批 + 驳回”并发。假设采购专员 A 点审批通过、B 点驳回:
- 两个请求都
getById读到状态是 2(待审批),都通过校验; - B 的驳回链路会先返还预算,再把状态写成 6;
- A 的审批链路把状态写成 3。
如果 B 先写、A 后写,最终单据是**“采购中”(3),但预算已经退回去了**——单据和账目不一致,而且从单据状态上看完全正常,没有报错、很难发现。反过来如果两个都是驳回或撤销,就是预算被返还两次。
【回答关键词】 审批 vs 驳回 · 状态与账目不一致 · 预算重复返还 · 静默失败
【可能继续追问】 怎么修?
【面试官问题 3】
那这个怎么修?
【推荐回答】
把状态约束写进 WHERE,用影响行数做裁决。现在是:
UPDATE orders SET status = 3 WHERE id = ?改成:
UPDATE orders SET status = 3, approve_user = ?, approve_time = ?
WHERE id = ? AND status = 2业务层看影响行数:返回 1 说明”我成功把这张单从待审批推进到采购中”,返回 0 说明它已经被别人处理过了,直接抛状态错误。只有影响行数为 1 的那个请求才有权继续去动预算,这样既解决了状态覆盖,也顺带把重复返还解决了。
【回答关键词】 状态条件更新 · 影响行数裁决 · 认领处理权 · 顺带解决幂等
【可能继续追问】 这跟你扣预算的方案是同一个思路吗?
【面试官问题 4】
这和你扣预算的方案是同一个思路吗?
【推荐回答】
是的,本质完全一样:都是把”判断”从 Java 内存下推到 SQL 的 WHERE 里,让”判断 + 写入”变成一条原子语句,业务层用影响行数判断成败。区别只是扣预算用的是数值比较(total_amount - used_amount >= amount),状态流转用的是状态等值比较(status = 2)。两者的并发保证都来自 InnoDB 的行锁 + UPDATE 的当前读。所以整个项目的并发方案是一致的一种模式,不是东一块西一块。
【回答关键词】 同一种模式 · 判断下推 · 行锁 + 当前读 · 方案一致性
【可能继续追问】 那为什么当初没这么写?
【面试官问题 5】
既然是想通的,为什么代码里没这么写?
【推荐回答】
两个原因。一是改造的优先级:预算是资金安全,P0;状态覆盖是数据一致性,虽然也重要但当时排在后。二是成本考虑:现在 OrderMapper.update 是一条通用动态 SQL 被所有流转复用,要加状态条件就得给每个流转方法传一个 expectedStatus 参数,或者拆成多条专用 SQL——改动面比改预算那条 SQL 大得多。所以当时先修了预算,状态这块留在了已知问题里。
这个取舍我认,但如果现在让我重做,我会把状态流转也做成条件更新,因为它是幂等性的基础,而且越晚改,依赖这条通用 SQL 的地方越多。
【回答关键词】 优先级取舍 · 通用 SQL 改动面 · 承认决策 · 会重做
【可能继续追问】 那 @Transactional 能解决这个问题吗?
【面试官问题 6】
@Transactional 能不能解决这个并发问题?
【推荐回答】
不能。 @Transactional 保证的是单个事务内部的原子性——要么全成功要么全回滚,比如”返预算”和”改单据状态”这两步要么都做要么都不做。但它不提供互斥,两个事务可以同时读到同一行数据然后都去改。要解决并发覆盖,得靠锁(悲观锁 / FOR UPDATE)或者条件更新 / 乐观锁,事务本身解决不了。
这也是一个常见误解:很多人以为加了 @Transactional 就”并发安全了”,其实它只解决”部分成功”的问题,不解决”互相覆盖”的问题。
【回答关键词】 原子性 ≠ 互斥性 · 事务不解决竞态 · 需要锁或条件更新 · 常见误解
【可能继续追问】 那你怎么验证这个问题存在?
【面试官问题 7】
你怎么证明这个问题真的存在?
【推荐回答】
写并发测试就能复现:起两个线程,用同一个单据 id 同时打 /admin/order/confirm 和 /admin/order/rejection,然后查库看 status 和 budget.used_amount——预期能看到”状态是采购中但预算被退了”这种组合。
说实话,我现在的回归脚本覆盖不到这个场景。regress_api_test.sh 是纯 bash + curl 的顺序接口测试,第 20 节验证的是”重复撤销不造成重复返还”,那是顺序重复(第二次进来被 pay_status 挡住了),并发场景脚本测不出来。要真正验证得写多线程的压测代码。这是我测试覆盖上的一个缺口,我知道它没被验证过。
【回答关键词】 并发测试才能复现 · 脚本是顺序测试 · 主动承认测试缺口
【可能继续追问】 那你觉得这个缺口的严重程度如何?
【面试官问题 8】
这个缺口影响大吗?值得修吗?
【推荐回答】
取决于并发量,但这个缺口比 M2 那个更值得修,理由是触发门槛低得多:
- M2 的重复返还缺口需要”同一张单被两个请求同时返还”,而审批和驳回本身就是两个不同的人在操作同一张单,这是业务上正常存在的并发,不需要恶意构造。采购专员可能真的有两个人同时在看待审批列表;
- 后果是静默的数量不一致——没有报错、单据状态看起来正常,只能靠对账发现,而项目恰好没有预算流水表(M2-2),所以对账还得反查
orders求和。
所以如果要我排修复优先级:状态条件更新 > 预算流水表 > 退换闭环。
【回答关键词】 触发门槛低 · 业务上正常的并发 · 静默不一致 · 无流水表难发现 · 排优先级
【可能继续追问】 好,那你再说说你这个单号是怎么生成的?
追问链 2:单号生成(高频、层次清晰)
【面试官问题 1】
申领单号是怎么生成的?
【推荐回答】
用 Redis 的 INCR 加日期分片。key 是 order:number:{yyyyMMdd},INCR 之后把返回值补零到 6 位,拼成 PO20260930-000137 这样的格式。只有在 INCR 返回 1(也就是这个 key 第一次被创建)的时候设置 2 天的过期时间。
【回答关键词】 Redis INCR · 日期分片 · 补零定长 · 首次 INCR 设 TTL
【可能继续追问】 为什么不用时间戳?
【面试官问题 2】
为什么不用时间戳?很多项目都那么写。
【推荐回答】
时间戳是毫秒级的,两个请求落在同一毫秒就会生成完全相同的单号。而且当时 orders.number 没有唯一索引,数据库也不会拦,重复单号就直接落库了。单号是对账和对外沟通的业务主键,重复的后果很严重。用 Redis INCR 靠的是 Redis 单线程执行命令这个特性,天然并发安全。
【回答关键词】 毫秒碰撞 · 无唯一索引兜底 · INCR 原子性 · 单线程模型
【可能继续追问】 为什么只在第一次 INCR 时设置过期时间?
【面试官问题 3】
为什么只在 INCR 返回 1 的时候设置过期时间?
【推荐回答】
因为 EXPIRE 是重新设置过期时间,不是”如果没有才设置”。如果每次 INCR 都跟着一个 EXPIRE,那只要当天还有单在提交,过期时间就会被不断刷新,这个 key 就变相永生了,永远不回收。只在首次创建时设一次,才能保证它在最后一次使用后的 2 天内过期。
这是一个很常见的坑:限流、计数器场景里常常写成”每次 INCR 都 EXPIRE”,看起来是”续期”,实际上让 TTL 失去了意义。同样的原则我在登录失败计数里也用到了——只有 INCR 返回 1 才设 15 分钟的过期。
【回答关键词】 EXPIRE 是覆盖不是新建 · 避免 TTL 被无限刷新 · 首次才设 · 同类应用(登录计数)
【可能继续追问】 那 Redis 挂了会怎样?
【面试官问题 4】
如果 Redis 挂了,或者数据丢了,会怎样?
【推荐回答】
这是这套方案的已知边界。如果 Redis 被清空或重启且没有持久化,序列会从 1 重新开始。当天如果已经出过 PO20260930-000001,新单号就会撞上 orders.number 的唯一索引,插入失败——用户端表现为提交申领单报错。
当前这个风险还没被触发,因为改造后单号格式变了(加了 PO 前缀),库里存量都是旧格式的时间戳;另外 2 天 TTL 也收敛了残留。
彻底的修法有两种:一是首次 INCR 返回 1 时,用 SELECT MAX(number) FROM orders WHERE number LIKE 'PO{date}-%' 从数据库读当前最大序号来兜底初始化;二是捕获 DuplicateKeyException 后重试一次(重新 INCR)。我倾向于第一种,因为它是”事前修正”而不是”事后补救”。
【回答关键词】 主动承认边界 · 撞唯一索引 · 兜底初始化 MAX · 或捕获重试 · 事前优于事后
【可能继续追问】 那为什么还要建唯一索引?
【面试官问题 5】
既然用 Redis 保证唯一了,为什么还要给 number 建唯一索引?
【推荐回答】
因为唯一索引是最后一道防线,而且它是兜底而不是主手段。 Redis 是外部依赖,可能丢数据、可能被清、可能有人手工 FLUSHDB;唯一索引是数据库层的硬约束,任何路径想写入重复单号都会被拒绝。
更重要的理由是失败行为:如果没有唯一索引,上面说的 Redis 丢数据场景会静默地写入重复单号,等到对账时才发现;有了唯一索引,它会在写入瞬间就失败,错误暴露在第一现场。
这也是我改造这个缺陷时的思路:INCR 解决”正常情况下不重复”,唯一索引解决”异常情况下不静默出错”。两者不是二选一,而是分层的。
【回答关键词】 分层防御 · 不静默出错 · 失败暴露在第一现场 · 主手段 vs 兜底
【可能继续追问】 (通常转向事务边界)
追问链 3:事务边界与”部分成功”(高频,考察对 Spring 事务的理解)
【面试官问题 1】
你这个提交申领单的方法里做了很多事情——扣预算、写主表、写明细、清空申领车。事务边界在哪?
【推荐回答】
事务边界就是 submitOrder 这一个方法,标了 @Transactional。方法里的四次数据库写操作——budget.deduct(UPDATE)、orders.insert、order_detail.insertBatch、shopping_cart.deleteByUserId——都在同一个事务里,要么全成功,要么全回滚。
【回答关键词】 方法级事务 · 四次写操作同事务 · 原子性
【可能继续追问】 为什么扣预算要放在写单据之前?
【面试官问题 2】
为什么先扣预算再写单据?反过来的话不行吗?
【推荐回答】
放在前面是刻意的,因为它是一个”失败要尽早”的校验步骤。
- 先扣预算:扣失败就立刻抛异常,事务回滚,此时什么都还没写——不会产生单据、申领车也不清空,用户改一下就能重提交;
- 如果先写单据再扣预算:扣失败了虽然事务也会回滚、单据也会被撤销,但中间多了一次无用的 INSERT,而且如果这个方法的后续逻辑被别处复用、漏了事务注解,就会留下”有单据但没扣钱”的脏数据。
校验前置、写操作后置是这个方法的基本结构,和”先查地址、先查申领车”是同一个原则。
【回答关键词】 失败尽早 · 校验前置 · 减少无用写入 · 防御未知调用方
【可能继续追问】 那 Redis 的操作在事务里吗?
【面试官问题 3】
你这个方法里也用了 Redis——读采购开关、INCR 生成单号。它们也在事务里吗?
【推荐回答】
不在,这是关键的一点。 @Transactional 只管 JDBC 连接上的事务,Redis 的操作是通过独立的连接发的,不受 Spring 事务管理。所以:
- Redis 的操作不会因为数据库回滚而被撤销;
- 数据库的写也不会因为 Redis 操作失败而回滚(除非异常抛出)。
具体到我的代码,有两个实际后果:
INCR生成的序号在回滚后不会退回,会留下”跳号”。这可以接受——单号不连续不是问题,唯一才重要;- 更值得注意的是 Controller 层的防重键:它在事务外面写的,业务失败回滚后这个键还在,所以 5 秒内用户改好了也提交不了,提示还是”请勿重复提交”。这是一个真实的体验缺陷。
【回答关键词】 Redis 不受 Spring 事务管理 · 独立连接 · 序号跳号可接受 · 防重键不回滚是缺陷
【可能继续追问】 那如果想让 Redis 也参与事务怎么办?
【面试官问题 4】
如果想让 Redis 的操作和数据库事务保持一致,有什么办法?
【推荐回答】
几种思路,各有代价:
- 把 Redis 操作挪到事务提交之后——用
TransactionSynchronizationManager.registerSynchronization,在afterCommit里做。适合”成功后才需要通知/清理”的场景; - 回滚时主动补偿——在
afterCompletion里判断状态是STATUS_ROLLED_BACK就主动DEL掉那个防重键。这正好能修我上面说的那个缺陷; - 不在事务里做 Redis 的事——最省事,但要接受不一致;
- 真正要强一致就不能靠 Redis——用数据库表 + 事务,比如把防重做成”幂等键 + 唯一索引”,让数据库事务来保证。
我现在是第 3 种(接受不一致),因为防重键的设计本身就是”5 秒短窗口、过期即失效”,容忍这点误差。但如果要修那个体验问题,第 2 种最直接——几行代码在 afterCompletion 里删键。
【回答关键词】 afterCommit / afterCompletion 钩子 · 主动补偿 · 或改回数据库 · 权衡
【可能继续追问】 你这个方法会不会有长事务的问题?
【面试官问题 5】
这个事务里有 SQL、有 Redis 操作,会不会事务太长?
【推荐回答】
当前不会,但值得说明。 事务里的四步都是本地操作——三次数据库写、一次 Redis 读(在方法开头),没有远程调用、没有 MQ 发送、没有文件 IO。整个事务的持锁时间就是几次本地数据库操作,毫秒级。
不过有两处需要注意:
employeeMapper.getById(userId)、addressBookMapper.getById()、shoppingCartMapper.list()这些查询也在事务里。查询本身不加锁(普通快照读),但会让事务持续时间略长。如果要优化,可以把这些只读校验挪到事务开始之前;budget那一行的 X 锁会一直持有到事务提交。所以同一部门的并发提交是串行的,持锁时间 = 后续所有操作的时间(插入主表、批量插入明细、清空申领车)。这是 M2 和 M3 交界处一个值得说的点:扣减放在事务末尾会更优,因为持锁时间最短。
【回答关键词】 无远程调用 · 持锁时间毫秒级 · 只读校验可前置 · 扣减放末尾可缩短持锁
【可能继续追问】 那受检异常为什么用 rollbackFor?
【面试官问题 6】
我看到你的 rejection、cancel 都写了 @Transactional(rollbackFor = Exception.class),为什么要显式指定?直接 @Transactional 不行吗?
【推荐回答】
因为这三个方法声明了 throws Exception,抛的是受检异常。 Spring 的默认回滚规则是:只在遇到 RuntimeException 和 Error 时回滚,遇到受检异常(Exception 的非运行时子类)默认不回滚。
这三个方法如果按默认规则,一旦 orderMapper.update() 抛出一个受检异常,事务就会提交——变成”预算已经退回去了,但单据状态还是待审批”,这是资金口径不一致。
所以必须显式写 rollbackFor = Exception.class,让所有异常都回滚。
对比一下:submitOrder 只标了 @Transactional 没有 rollbackFor,它是对的——因为 submitOrder 抛的都是 OrderBusinessException / BudgetBusinessException 这类运行时异常(它们继承自 BaseException,而 BaseException 是 RuntimeException 的子类),默认就会回滚。所以两处不一样是因为抛出的异常类型不一样,不是写法不统一。
【回答关键词】 默认只回滚 RuntimeException · 受检异常需显式 rollbackFor · submitOrder 抛运行时异常所以不用 · 差异有原因
【可能继续追问】 那你怎么验证回滚真的生效了?
【面试官问题 7】
你怎么确认事务回滚真的生效了?
【推荐回答】
有三层证据。
代码层面:BaseException 继承 RuntimeException,submitOrder 抛的预算不足异常走默认回滚规则,一定会回滚。
功能层面:我的回归脚本第 21 节专门验证了”超额拦截后预算未变动”——先把部门余额压低到 100,然后提交一张 199 元的单,断言接口返回 code=0(失败)、并且预算的 used_amount 完全没有变化。这就证明了扣减失败时事务确实回滚了。
但我要诚实说一个缺口:这个断言验证的是”扣减失败这个分支没有留下副作用”,没有验证”扣减成功之后、后续步骤失败”时的回滚。比如”预算扣成功了、插入订单时失败”,理论上事务会回滚把预算还回去,但我的脚本没有构造这个场景(要让 orderMapper.insert 抛异常,得注入故障或者故意造一个约束冲突)。这是测试覆盖上的一个空白,我知道它没被验证过——要验证的话得写一个会失败的插入,或者用 Mock 让 Mapper 抛异常。
【回答关键词】 异常继承关系 · 回归脚本 21 节实证 · 主动承认未验证的分支 · 如何补测
【可能继续追问】 (通常转向查询性能)
追问链 4:查询性能与 N+1(高频,考察索引和查询设计)
【面试官问题 1】
员工端看”我的申领单”这个列表,SQL 是怎么执行的?
【推荐回答】
SELECT * FROM orders WHERE user_id = ? AND status = ? ORDER BY order_time DESC,PageHelper 在前面拦一下自动加 LIMIT。然后这里有两个问题:一是 user_id、status、order_time 一个索引都没有,所以是全表扫描加 filesort;二是查出来每一条单据之后,我又在循环里逐条去查明细,N+1。
【回答关键词】 主动暴露 · 无索引 · 全表扫描 + filesort · N+1
【可能继续追问】 索引该怎么加?
【面试官问题 2】
那索引应该怎么加?
【推荐回答】
建一个复合索引 idx_orders_user_status_time (user_id, status, order_time DESC)。 理由按最左前缀原则:
user_id放最左:员工端查询必然带上它,而且区分度高——每个员工只看自己的单;status放中间:它是可选条件(不传 status 就查全部),放在user_id后面仍然能用上(只是等值部分少了),不会让索引失效;order_time放最后:它只用于排序,放在最后才能让 MySQL 直接按索引顺序取出、消掉 filesort。
这里要特别说清一个点:ORDER BY 用上索引的前提是前面所有的等值条件都命中。如果只传 user_id 不传 status,status 这一列就断了,order_time 的排序就用不上索引——这就是”最左前缀”的边界。如果这个查询模式很关键,可以考虑按”是否传 status”走两条不同的 SQL,或者再建一个 (user_id, order_time) 的索引。
【回答关键词】 复合索引 · 最左前缀 · 等值列在前排序列在后 · 消 filesort · 可选条件的边界
【可能继续追问】 那 N+1 怎么解决?
【面试官问题 3】
N+1 怎么解决?你在项目里别的地方也遇到过吗?
【推荐回答】
用 IN 批量查回来再在内存分组:先把这一页的 orderId 收集成一个列表,然后一次 SELECT * FROM order_detail WHERE order_id IN (...) 查出所有明细,再用 Collectors.groupingBy(OrderDetail::getOrderId) 按单据分组,最后填回每个 VO。从 1+N 次查询降到 2 次。
项目里有三处同类问题:
OrderServiceImpl.pageQuery4User(第 189 行)——员工端分页,循环查明细;OrderServiceImpl.getOrderGoodsStr(第 370 行)——管理端分页,而且这个更特别:它查明细不是为了返回明细结构,而是为了拼一个"物资名*数量;"的字符串。这种情况其实用 SQL 的GROUP_CONCAT一次就能聚合出来,连内存分组都省了;GoodsServiceImpl.listWithSpecs(第 222 行)——物资列表,循环查规格。
第 3 处最严重,因为员工端物资目录是高频读,虽然结果进了缓存,但每次缓存重建都要打 N+1 次库。
【回答关键词】 IN + groupingBy · 两次查询 · 三处同类 · GROUP_CONCAT 更优 · 高频读放大了影响
【可能继续追问】 那你怎么知道哪个索引真的有效?
【面试官问题 4】
你怎么确认索引真的被用上了?
【回答回答】
用 EXPLAIN 看几个关键列:type(最好到 ref 或 range,ALL 就是全表扫描)、key(实际用了哪个索引)、rows(预估扫描行数)、Extra(出现 Using filesort 说明排序没走索引,Using index 说明是覆盖索引)。
而且要诚实说:这个项目我没有做压测,也没有系统性地跑过 EXPLAIN 分析所有慢查询。 我说”这三个字段没索引”是根据表结构和 SQL 推断的,不是实测结论。如果要严谨,应该开慢查询日志、收集真实的慢 SQL,再用 EXPLAIN 逐条确认。
这也是我不想把项目说成”做了性能优化”的原因——索引这块我是识别出了问题,但没有度量过收益。
【回答关键词】 EXPLAIN 关键列 · 承认没有压测 · 靠推断不是实测 · 严谨做法是慢查询日志
【可能继续追问】 那 statistics 那个接口呢?
【面试官问题 5】
管理端那个各状态数量统计的接口,有什么问题?
【推荐回答】
两个问题。
一是三次查询可以合并成一次。 现在是分别对 status = 2、3、4 各做一次 SELECT count(id),其实一条 SELECT status, count(id) FROM orders WHERE status IN (2,3,4) GROUP BY status 就够了,返回三行在内存里组装。
二是更尴尬的——status 这个字段本身没有索引。 countStatus 的 SQL 是 WHERE status = #{status},唯一的条件就是 status,却偏偏没给它建索引,所以三次 COUNT 都是全表扫描。加一个 status 单列索引,就能把三次全表扫描变成三次索引范围扫描。
另外还有个语义问题:这个统计是全量历史的(没有时间条件),所以”待审批”里包含了几年前的旧单。如果业务上只关心近期的,应该像工作台那样加时间范围。当前是没加的,这也是一个可以改进的点。
【回答关键词】 三次 COUNT 合并为 GROUP BY · status 无索引 · 全量无时间范围 · 语义待确认
【可能继续追问】 (通常转向越权/IDOR)
追问链 5:越权与安全(5 层)
【面试官问题 1】
员工端有个”查询申领单详情”的接口,如果我把 id 改成别人的单号,能查到吗?
【推荐回答】
查不到,我用统一的方法做了归属校验。 在 details 里查出单据之后会调 assertOwner(orders),比对 BaseContext.getCurrentId() 和 orders.getUserId(),不一致就抛异常。
【回答关键词】 归属校验 · assertOwner · 统一复用
【可能继续追问】 你为什么返回”单据不存在”而不是”无权限”?
【面试官问题 2】
如果不一致,你返回什么?“无权限”还是”不存在”?
【推荐回答】
返回”申领单不存在”。 这是有意的:如果返回”无权限”,攻击者就确认了这个 id 对应的单据真实存在,可以通过响应差异枚举出系统里有多少单据、哪些 id 有效。返回”不存在”的话,越权和真的不存在在响应上完全一样,拿不到额外信息。
这是权限设计里”不要向未授权方泄露资源是否存在”的原则。
【回答关键词】 不泄露资源存在性 · 防 ID 枚举 · 幂等响应
【可能继续追问】 这个校验是每个方法都做了吗?
【面试官问题 3】
这几个员工端接口你都做了校验吗?
【推荐回答】
现在都做了。但这恰恰是改出来的——原来漏了四个。
最开始只有 confirmReceipt 和 returnOrder 做了归属校验,details、userCancelById、repetition、reminder 四个都漏了。漏的后果挺严重:遍历 id 就能读别人的单据详情(包含收货人、电话、地址、全部明细)、撤销别人的单据并触发预算返还、把别人的单据明细灌进自己的申领车、催办别人的单据。
修的时候我抽了个私有方法 assertOwner 统一复用,现在四个地方都调了它。这个教训是:权限校验必须有一个统一入口,一处一处写必然漏。
【回答关键词】 原来是漏改的 · IDOR 后果具体化 · 抽公共方法 · 统一入口原则
【可能继续追问】 那管理端接口呢?
【面试官问题 4】
管理端接口怎么控制权限?会不会有越权?
【推荐回答】
管理端用了两层:
- 认证层:
JwtTokenAdminInterceptor拦/admin/**,校验 JWT + Redis 白名单,并且查库确认这个员工不是EMPLOYEE角色、账号没被停用; - 授权层:
@RequireRole注解 +RoleAspect切面,判断当前角色在不在允许列表里。
但有一个设计取舍我必须说清:RequireRole 是未标注即放行的。RoleAspect 里第一句就是 if (requireRole == null) return;。所以新增一个管理端接口如果忘记加注解,默认是放行的。
当前覆盖率是管理端 27/28 个写接口都标了(只有 logout 没标,那是合理的),读接口按设计不标。但这依赖开发者记得加,是个隐患。更安全的做法是改成”默认拒绝、显式放行”——对管理端统一要求标注,或者干脆在拦截器层做一次兜底检查。
【回答关键词】 认证与授权分层 · 默认放行是隐患 · 覆盖率 27/28 · 应改默认拒绝
【可能继续追问】 那你怎么知道有没有漏的接口?
【面试官问题 5】
你怎么确认没有漏掉的接口?
【回答回答】
说实话,我主要是靠人工核对 + 回归脚本的抽样验证,没有一个自动化的保障机制。
回归脚本里验证了几条关键路径:普通员工不能登录管理端、员工 token 调管理端返回 401、采购专员不能新增部门、采购专员不能改预算、采购专员可以查部门列表。但这些都是抽样,不是全量覆盖——我没法证明每个管理端接口都被测过。
要真正做到有保障,应该:① 把 @RequireRole 改成默认拒绝,让”漏标”变成”访问被拒”而不是”静默放行”(让错误暴露出来比事后审计有效);② 写一个测试遍历所有 /admin/** 接口,用最低权限角色的 token 去打,断言全部返回拒绝。
第二点是这个项目没做的,属于我识别出来的测试缺口。
【回答关键词】 靠人工核对 + 抽样 · 无自动化保障 · 默认拒绝让错误暴露 · 遍历式权限测试
【可能继续追问】 (通常转向”防重复提交”)
追问链 6:防重复提交与幂等(6 层)
【面试官问题 1】
用户连点两次提交按钮,会不会生成两张单、扣两次钱?
【推荐回答】
不会,我在 Controller 层用了 Redis 的 SET NX。 key 是 order:submit:{员工id},TTL 5 秒。只有抢到锁的那次请求才会进 Service,第二次会抛”请勿重复提交申领单”。
【回答关键词】 SET NX · 5 秒 TTL · 按用户维度 · Controller 层拦截
【可能继续追问】 为什么用 Redis 不用数据库唯一索引?
【面试官问题 2】
为什么用 Redis 而不是建个去重表加唯一索引?
【推荐回答】
因为这个去重的语义是”短时间窗口”,不是”永久唯一”。5 秒内重复点是误操作,5 秒后重新提交是正常行为。Redis 的 TTL 天然匹配这个语义——过期自动失效,不需要任何清理逻辑。用唯一索引的话,得先定义”多久算重复”,然后定期删历史记录,反而更麻烦。
【回答关键词】 短窗口语义 vs 永久唯一 · TTL 自动过期 · 无需清理 · 语义匹配
【可能继续追问】 这个方案的缺点是什么?
【面试官问题 3】
那它有什么缺点?
【推荐回答】
三个,我都清楚。
一、粒度不对。 key 是 userId,所以它拦的是”这个人的第二次提交”,不是”这一次提交的重复”。如果用户 5 秒内想提交两张不同的单,第二张会被误拦。
二、在事务外,失败不回滚。 这个是更实际的缺陷:SET NX 在 Controller 里、事务之前执行。如果 Service 里因为”余额不足”回滚了,Redis 的键不会回滚,用户 5 秒内改好了重新提交,会被”请勿重复提交”挡住——提示和真实原因不符,体验很差。
三、Redis 是单点。 Redis 挂了,防重就完全失效,降级成无防护。这是”尽力而为”的方案,不是强保证。
【回答关键词】 粒度是用户不是请求 · 事务外不回滚 · 失败后无法立即重试 · 提示误导 · 单点降级
【可能继续追问】 那怎么才能做到真正的幂等?
【面试官问题 4】
怎么才算真正的幂等?
【推荐回答】
真正的幂等需要”客户端幂等键 + 服务端唯一约束”。 具体做法:前端每次生成一个新的 requestId(UUID)并在提交时带上;服务端把 requestId 一起写进单据表(或者一张幂等记录表),在 requestId 上建唯一索引。
这样重复提交时,第二次插入会撞唯一索引 → 服务端捕获后返回第一次创建的那张单据,而不是报错。这才是幂等:同一请求无论发多少次,结果都一样,而且是同一个结果。
区别在于:
- 我现在的
SET NX是限流/去重——它防的是”短时间内再次进入”,重复请求被拒绝; - 幂等键是幂等——重复请求被接受并返回同一个结果。
对于”提交申领单”这种场景,幂等键其实更合适,因为用户的诉求是”我不小心点了两下,但我要一张单”,而不是”我要被拒绝”。
【回答关键词】 幂等键 requestId · 唯一索引 · 返回同一个结果 · 幂等 vs 去重的区别 · 语义更合适
【可能继续追问】 那你为什么没这么做?
【面试官问题 5】
既然幂等键更合适,你为什么没做?
【回答回答】
因为它是跨前后端的改动,而当时我在做的是后端侧的缺陷修复。 幂等键需要前端配合生成和传递 requestId,还要决定 requestId 存在 orders 表里还是单独一张幂等表——如果存在 orders 表里要加列和唯一索引,如果单独建表还要考虑清理策略。
而且当时 Redis 防重已经能覆盖最主要的场景(手抖连点,两次点击间隔通常几百毫秒,远小于 5 秒)。所以我选了改动最小、见效最快的方案,同时清楚它是个”够用但不是最优”的解。
如果要继续优化,我的优先级是:先把”事务回滚后删除防重键”这个体验问题修掉(几行代码),再考虑上幂等键(需要前端配合)。
【回答关键词】 跨端改动 · 最小改动优先 · 覆盖主要场景 · 分优先级的改进计划
【可能继续追问】 那这个防重逻辑为什么放在 Controller 而不是 Service?
【面试官问题 6】
这段防重逻辑为什么写在 Controller 里,不写在 Service 里?
【推荐回答】
两个理由,但我要承认第二个理由更多是”当前的样子”而不是”最好的样子”。
理由一:防重是接入层的关注点,本质上是”拒绝重复的 HTTP 请求”,和业务逻辑无关。放在 Controller 层符合分层职责,而且能直接拿到 BaseContext.getCurrentId()。
但理由二是我要坦白的:它其实更应该放在 Service 里,因为:
- 放在 Service 里才能被事务同步机制管理——可以用
TransactionSynchronization在事务回滚时主动删键,解决上面说的”失败不能重试”的问题; - 放在 Service 里意味着所有调用入口都受保护,而现在如果将来有别的入口(比如内部调用、批量导入)直接调
submitOrder,就绕过了防重。
所以这是一个我可以承认的架构味道:Controller 里塞了业务关注点,Service 层拿不到这个保护。
【回答关键词】 接入层关注点 · 但事务同步能管理 · 所有入口受保护 · 承认架构味道
【可能继续追问】 (通常转向”退换为什么不退预算”)
追问链 7:退换单的预算(4 层,容易问出闭环缺口)
【面试官问题 1】
员工发起退换了,预算会退回去吗?
【推荐回答】
不会。这是有意的设计。 returnOrder 只做状态流转——把单据状态置成已取消、打上 is_returned = 1 标记,不调用 releaseBudget。
代码注释写明了理由:避免”退换即退款”造成资金口径混乱。因为退换的货物可能已经采购甚至已经用了,退换是否成功、退多少是线下协商的结果,不是系统能自动判定的。如果系统一提交退换就把预算退回去,员工可以”申请退换 → 预算退回 → 线下确认不退了”,形成预算套利。
【回答关键词】 有意不返还 · 防预算套利 · 线下协商不可自动判定 · 代码注释有说明
【可能继续追问】 那这笔预算最后怎么退?
【面试官问题 2】
那这笔预算最后要怎么退?管理端有对应操作吗?
【推荐回答】
这里确实有个闭环缺口,我得承认。 注释说”由管理端线下确认后处理”,但系统里其实没有这个入口。
原因是:returnOrder 把状态置成了 CANCELLED(6),而管理端 cancel 方法的守卫是 “status == COMPLETED 或 status == CANCELLED 就拒绝”。所以退换之后,管理员没法通过 /admin/order/cancel 来返还这笔预算——单据已经是 6 了,会被直接拒绝。
结果就是:退换单的预算在系统里永远退不回来,只能直接改数据库。这不是设计,这是改造时留下的缺口。
【回答关键词】 主动承认闭环缺口 · cancel 的守卫会拒绝 · 只能改库 · 是缺口不是设计
【可能继续追问】 那应该怎么改?
【面试官问题 3】
那应该怎么修?
【推荐回答】
给退换一个独立的中间状态,让管理员审批。 大致是:
Orders加一个状态常量RETURNING = 7(“退换待确认”);returnOrder把状态从COMPLETED(5)改成RETURNING(7),而不是直接跳到CANCELLED(6),同时记录is_returned = 1和原因;- 管理端加一个”确认退换”的接口,校验状态是 7,然后调
releaseBudget()返还预算并把状态推进到CANCELLED(6);再加一个”拒绝退换”的接口把状态退回COMPLETED(5)。
这样预算返还是一个显式的、有审批的管理端动作,既保留了”不自动退”的设计意图,又补上了闭环。
代价是状态机从 6 个状态变成 7 个,所有跟”终态判断”有关的地方都要检查一遍(比如 cancel 的守卫、前端的状态显示、统计接口)。所以这是我排在后面的原因之一。
【回答关键词】 加状态 7 · 退换待确认 · 管理端审批后返还 · 拒绝则退回已完成 · 状态机扩张的代价
【可能继续追问】 为什么当初不一起做?
【面试官问题 4】
这个缺口严重吗?为什么当时不一起做?
【回答回答】
严重程度中等:它不会导致数据被破坏,只是一笔钱退不回去,需要人工改库。而且触发频率低——退换本身是低频操作。
当初没做的原因是我在做”资金安全类”的缺陷修复,优先修的是那些”会导致钱算错”的问题(前端传金额、重复返还、越权撤销)。退换不返还预算不是”算错”,而是”少退”,风险等级低一档,所以排在了后面。
我认这个优先级判断。 但如果从”系统完整性”的角度看,一个有注释说明设计意图、却没有对应实现入口的功能,其实是比没有这个功能更糟的——因为它会让人以为这件事被处理了。
【回答关键词】 风险等级中等 · 不会算错只是少退 · 低频 · 优先级判断合理 · 但”有注释无实现”比没有更糟
【可能继续追问】 (通常收尾)
七、M3 容易被质疑的地方(严格视角,不强行合理化)
| # | 问题 | 面试官为什么可能质疑 | 当前代码实际情况 | 我应该怎么解释 | 是否建议修改 |
|---|---|---|---|---|---|
| M3-1 | 所有状态流转都无状态条件更新 | ”审批和驳回同时发生时,状态和预算哪个赢?” | ✅ 成立,M3 最大缺口。OrderMapper.update 的 WHERE 只有 id;confirm/delivery/complete/confirmReceipt/rejection/cancel/userCancelById 七个方法全是”读-判断-写” | 主动承认,并给出修法:把状态约束写进 WHERE,用影响行数裁决——UPDATE orders SET status=3 WHERE id=? AND status=2,只有影响行数=1 的请求才有权继续动预算。这与 M2 的预扣是同一个模式,架构上一致 | 建议修(优先级最高)。触发门槛低(两个采购专员同时处理同一张单是业务上正常的并发),后果是静默不一致 |
| M3-2 | user_id / status / order_time 无索引 | ”员工端最热的查询走什么索引?” | ✅ 成立。orders 只有 uk_orders_number、idx_orders_requester_dept、idx_orders_approve_user。而员工端核心 SQL 是 WHERE user_id=? AND status=? ORDER BY order_time DESC → 全表扫描 + filesort。countStatus 的 WHERE status=? 也没有索引 | 「应该建复合索引 idx_orders_user_status_time (user_id, status, order_time DESC)。列序是按”必带且区分度高 → 可选等值 → 排序”排的,这样等值过滤和排序都能走索引、消掉 filesort。但我必须说清:我没做压测、没跑过 EXPLAIN,这是根据表结构和 SQL 推断的,不是实测结论。」 | 建议加(这是最实在的性能优化项) |
| M3-3 | 防重键在事务外、失败不回滚 | ”提交失败了改一下再提交,会被拦吗?” | ✅ 成立。SET NX 在 Controller 层、@Transactional 之外。业务失败(余额不足等)回滚后,Redis 键仍在 5 秒 | 「会被拦,提示还是’请勿重复提交’,和真实原因不符。修法是用 TransactionSynchronization.afterCompletion 在回滚时主动 DEL 这个键,或者把防重挪到 Service 层让它受事务同步管理。」 | 建议修(几行代码,消除一个能稳定复现的体验缺陷) |
| M3-4 | 退换单的预算系统内退不回来 | ”退换之后钱怎么退?” | ✅ 真实闭环缺口。returnOrder 不调 releaseBudget(有意),但把状态置成 CANCELLED(6),而 cancel 的守卫拒绝已取消单据 → 管理员也没有入口能退 | 「不自动退是有意的,防预算套利。但注释说’管理端线下处理’,系统里却没有对应入口——因为状态已经是 6 了,/admin/order/cancel 会拒绝。修法是加状态 7(退换待确认),由管理员审批后才返还。」 | 建议修(补上状态 7 + 两个管理端接口) |
| M3-5 | pageQuery4User 和 getOrderGoodsStr 两处 N+1 | ”一页 10 条单据发了几次 SQL?” | ✅ 成立。各 1+N(每次 getByOrderId)。getOrderGoodsStr 更特别:查明细是为了拼展示字符串,这种情况 SQL 用 GROUP_CONCAT 一次就能出 | 「用 WHERE order_id IN (...) 一次批量查回来,再用 Collectors.groupingBy 内存分组,能从 1+N 降到 2 次。管理端那个拼接字符串的场景,更该在 SQL 层用 GROUP_CONCAT 聚合。」顺带说项目里三处同类(第三处 GoodsServiceImpl.listWithSpecs 影响最大,因为员工端物资目录是高频读) | 建议修(N+1 是最容易被追问的性能问题) |
| M3-6 | reminder 单据不存在时错误码用错 | ”催办一张不存在的单,提示什么?” | ✅ 成立。reminder 里 ordersDB == null 抛的是 ORDER_STATUS_ERROR(“申领单状态错误”),而同一文件的 details / userCancelById / cancel / returnOrder 都正确用了 ORDER_NOT_FOUND。只有这一处用错 | 「单据根本不存在,提示却说状态错误,错误码复用不当。同一文件其他四个方法都用了 ORDER_NOT_FOUND,这里是漏改。」 | 建议修(一行改动) |
| M3-7 | confirm 同时写 approveTime 和 checkoutTime | ”为什么两个字段写同一个时间?“ | ⚠️ 冗余写入,注释写明”复用 checkout_time 记录审批通过时间”。属于改造时”想引入新语义字段又怕动旧字段”的中间状态 | 「checkout_time 原本是’结账时间’,我复用它记录审批通过时间,同时又加了 approve_time 存同一个值,确实是冗余。干净的做法是只保留 approve_time,把 checkout_time 废弃;但要同步改前端和接口文档,所以当时没做。」 | 可选(数据冗余,不影响正确性) |
| M3-8 | pageQuery4User 把 page.getTotal() 放在判空块外 | ”如果 page 是 null 呢?“ | ⚠️ 脆弱但不触发。return new PageResult(page.getTotal(), list) 在 if (page != null ...) 块外;实际 PageHelper 的 Page 不会为 null,所以现在不会 NPE | 「PageHelper 保证了 Page 非空,所以现在不会空指针。但把 getTotal() 放在判空块外是脆弱的写法——如果以后 pageQuery 换成返回普通 List,这一行立刻 NPE。应该把 return 也挪进判空块,或者用三目兜底。」 | 建议修(防御性,零风险) |
| M3-9 | 管理端分页参数未校验默认值 | ”不传 page/pageSize 会怎样?“ | ⚠️ OrdersPageQueryDTO 的 page / pageSize 是原始类型 int,不传默认为 0。PageHelper.startPage(0, 0) 会返回空结果,不报错但也不给提示。而员工端 pageQuery4User(page, pageSize, status) 的入参也是 int | 「不传分页参数会静默返回空列表,因为 int 默认 0 而 PageHelper 对 0 不报错。改成包装类型 Integer 加默认值(比如 1 和 10)会更友好。」 | 可选(体验优化) |
| M3-10 | statistics 三次 COUNT 可合并、且全量无时间范围 | ”为什么查三次?统计的是哪个时间段?“ | ⚠️ 成立。三次独立 countStatus;且没有时间条件,统计的是全量历史,status=2 里包含几年前的旧单 | 「三次 COUNT 可以合并成 SELECT status, count(id) ... GROUP BY status。另外它统计的是全量历史,语义上’待审批’可能包含很久以前的单——如果业务只关心近期,应该像工作台那样加时间范围。这一点需要跟业务确认语义,不是纯技术问题。」 | 可选(合并查询 + 确认业务语义) |
| M3-11 | assertOwner 的写法有潜在 NPE | ”如果 getCurrentId() 返回 null 呢?“ | ⚠️ 成立。BaseContext.getCurrentId().equals(orders.getUserId()) 把可能为 null 的调用放在前面。经拦截器进入的请求不会为 null,但如果 Service 被其他非 HTTP 入口调用就会 NPE | 「经拦截器的请求不会为 null,但把 getCurrentId() 放在 .equals() 前面是脆弱的。反过来写 orders.getUserId().equals(BaseContext.getCurrentId()),或者方法开头判空更稳。」 | 建议改(防御性,零风险) |
| M3-12 | OrdersDTO 是死代码,OrdersConfirmDTO.status 声明未用 | ”这个 DTO 用在哪?确认接口的 status 有什么用?” | ✅ 成立。OrdersDTO 全项目零引用(仅定义在 pojo 里);OrdersConfirmDTO 有 status 字段但 confirm() 从不读取它,状态是硬编码 Orders.CONFIRMED | 「这两个是原项目遗留:OrdersDTO 已经没有任何引用了;OrdersConfirmDTO.status 声明了但服务端不用,状态是写死的。清理掉更干净,也避免读者以为可以从前端指定状态(那会是安全问题)。」 | 建议清理(尤其 status 字段——留着会让人误以为前端能指定目标状态) |
| M3-13 | OrderMapper 有两个无引用方法 | ”这两个方法是干什么的?” | ✅ 成立。getByNumberAndUserId、getByStatusAndOrderTimeLT 全项目无调用(原项目用于微信支付回调和自动取消超时订单,改造后支付环节移除、也没有定时任务) | 「这两个是原项目遗留的死方法。getByStatusAndOrderTimeLT 原来配合定时任务做’超时未支付自动取消’,但我的新流程里提交即预扣预算、没有支付环节,也没有定时任务,所以它没用了。应该删掉,否则会让人以为有超时取消机制。」 | 建议删除(避免”看起来有定时取消”的误解) |
明确不是问题、但可能被问的点
| 点 | 回答 |
|---|---|
confirm / delivery / complete / confirmReceipt 没有 @Transactional | 是正确的。每个方法只有一条 UPDATE,单语句自带原子性,不需要事务。只有涉及”多次写库”(预扣 + 落库 / 返还 + 改状态)的方法才需要事务,而它们都有 |
submitOrder 用 @Transactional 而 rejection 用 @Transactional(rollbackFor = Exception.class),写法”不统一” | 不统一是因为抛出的异常类型不同:submitOrder 抛的都是继承 RuntimeException 的业务异常,默认回滚;三个返还方法声明了 throws Exception、抛受检异常,默认不回滚,必须显式 rollbackFor。这是正确的差异,不是疏漏 |
| 状态 6 是”已取消 / 已驳回 / 已退换”三合一 | 有意的取舍:终态判断简化为 status == 6;代价是无法用索引按终态原因查询,且从状态码看不出原因(靠 cancelReason / rejectionReason / isReturned 区分) |
requester_dept 是冗余字段 | 合理的反范式:员工换部门后,历史单据应该归到当时花钱的部门,冗余正是为了这个语义。代码注释写明”便于按部门统计” |
orders.number 有唯一索引,为什么 INCR 还要做 | 分层防御:INCR 解决”正常情况下不重复”,唯一索引解决”异常情况下不静默出错”。两者不是二选一 |
员工端 pageQuery4User 强制 setUserId(BaseContext.getCurrentId()) | 正确的安全设计:不接受前端传 userId,避免员工查别人的单据列表 |
八、M3 关键数字与事实速查(面试前扫一眼)
| 事实 | 值 / 位置 |
|---|---|
| 文件规模 | OrderServiceImpl.java 627 行,全项目最大文件 |
| 方法总数 | OrderServiceImpl 18 个方法(含 3 个私有) |
| 接口数 | 员工端 8 个(/user/order/**)+ 管理端 7 个(/admin/order/**) |
| 状态常量 | 6 个(PENDING_PAYMENT=1 → CANCELLED=6);预算状态 3 个(UN_PAID=0 / PAID=1 / REFUND=2) |
| 唯一的 UPDATE SQL | OrderMapper.xml 第 27-63 行(where 在第 61 行),WHERE id = #{id},无状态条件 ← M3 的核心问题 |
| 事务注解 | submitOrder 用 @Transactional;userCancelById / rejection / cancel 用 @Transactional(rollbackFor = Exception.class);其余 4 个流转方法无事务(且正确) |
| 金额重算 | submitOrder 第 104 行,stream().map(...).reduce(...) |
| 单号格式 | PO{yyyyMMdd}-{6位序号},如 PO20260930-000137,来源 order:number:{date} 的 INCR |
| 防重 | order:submit:{empId},SET NX,TTL 5s,在事务外 |
| 催办限流 | order:reminder:{orderId},SET NX,TTL 5min,限流前先做业务校验(正确顺序) |
| 越权校验 | assertOwner(第 285 行),4 个调用点:details:213 / userCancelById:240 / repetition:304 / reminder:603;另有 confirmReceipt:544、returnOrder:573 内联校验 |
| 索引 | uk_orders_number、idx_orders_requester_dept、idx_orders_approve_user;user_id / status / order_time 无索引 |
| N+1 位置 | pageQuery4User:189、getOrderGoodsStr:370(另 GoodsServiceImpl.listWithSpecs:222) |
| 已知缺口 | ① 状态流转无 CAS ② 三字段无索引 ③ 防重键事务外 ④ 退换预算退不掉 ⑤ 两处 N+1 ⑥ reminder 错误码用错 |
| 死代码 | OrdersDTO(零引用)、OrdersConfirmDTO.status(不读)、OrderMapper.getByNumberAndUserId / getByStatusAndOrderTimeLT(零引用) |
| 回归验证 | regress_api_test.sh 第 10-21 节(提交 → 预扣 → 审批 → 发货 → 收货 → 完成不返还 → 驳回返还 → 撤销幂等 → 超额拦截)、第 13 节(催办写 remindTime)、第 14 节(员工 token 调管理端 401) |
九、如果只给我 3 分钟讲 M3
定位:申领单是项目主单据,627 行,承载状态机、金额、审批痕迹、组织归属四件事。员工端 8 个接口、管理端 7 个。
最核心的一段是
submitOrder,六步:① 读shop:status判采购开关;② 查地址簿和申领车(空则拒);③ 按申领车逐项重算金额,完全忽略前端传的amount——原来这里信任前端,可以传 0 元甚至负数(负数会让预扣 SQL 的>=恒成立,反向下减已用金额);④ 调 M2 的原子预扣,失败就抛异常让整个事务回滚,不产生单据;⑤ 用 RedisINCR加日期分片生成PO20260930-000137单号,配唯一索引兜底;⑥ 批量写明细、清空申领车。全部四次写库在同一个@Transactional里。安全上,员工端所有按 id 操作的接口都走
assertOwner做归属校验,越权时返回”单据不存在”而不是”无权限”,避免攻击者枚举出单据是否存在。并发上我要坦白一个缺口:所有状态流转都是”读出来判断、再无条件写回”,
OrderMapper.update的WHERE只有id,没有状态条件。顺序重复会被 Java 校验挡住,但并发挡不住——采购专员 A 点审批、B 点驳回同时发生,可能出现”单据状态是采购中,但预算已经被退回去了”这种静默的账实不一致。修法是把状态约束写进WHERE、用影响行数裁决,和 M2 预扣是同一个模式,而且只有状态迁移成功的请求才有权动预算,顺带就把重复返还也解决了。性能上已知两处 N+1(分页逐条查明细);更实在的问题是
user_id、status、order_time三个字段都没有索引,而员工端最热的查询正好用这三个字段——应该建(user_id, status, order_time DESC)复合索引。