采云台 · M2 部门预算 · 深度分析
对应第三、四部分(完整执行链路 + 为什么这么设计)及第五、六、七部分中 M2 相关内容
依据:backend/cai-yun-tai实际源码逐行核验;mvn -o -DskipTests compile退出码 0
一句话定位:这是全项目唯一真正解决并发正确性问题的地方,而且是用零中间件解决的——没有 Redis 扣减、没有分布式锁,只有一条带条件的 SQL UPDATE。
零、先纠正项目地图里的两个错误
在深入之前,必须先修正我上一轮写错的两处。面试时说错这两点会被直接抓住:
| 项目地图里的说法 | 实际情况 | 源码依据 |
|---|---|---|
| 「M2 部门预算 ★★★★★ —— 部门月度额度」 | ✅ 对,但没有”月度自动重置”机制。每月需要管理员手工为每个部门创建一条 period=yyyy-MM 的记录,没有任何定时任务或自动续期 | 全项目无 @Scheduled、无 xxl-job;budget 表按 period 累积多行 |
「组合申领包有三条 @Transactional」 | ✅ 对 | ComboServiceImpl:48,88,122 |
| 「预算返还由三条链路调用」 | ✅ 对,是 3 条:rejection(管理端驳回)、userCancelById(员工撤销)、cancel(管理端取消)。returnOrder(员工发起退换)不返还预算——这是代码注释里写明的有意设计 | OrderServiceImpl:559-561 注释原文:「预算是否返还由管理端线下确认后处理,这里只做状态流转,避免”退换即退款”造成资金口径混乱」 |
另外补充两处地图没写、但 M2 范围内必须知道的事实:
BudgetMapper.getVOById只被BudgetServiceImpl.getVOByDeptAndPeriod调用,而管理端BudgetController没有”按 id 查单条预算”的接口(只有list)。员工端GET /user/budget/my是唯一用到它的入口。BudgetServiceImpl全程没有@Transactional,这是正确的——deduct/release都是单条 SQL,单表单语句自带原子性;save里的两次操作是「先 SELECT 查重 → 再 INSERT」,靠唯一索引兜底,也不构成”必须回滚”的复合写。
一、业务场景:为什么需要”预算”这个模型
1.1 原始痛点
企业办公物资采购在系统化之前是这样的:
员工 → 微信群/口头:"我需要一个显示器支架"
行政 → 记在 Excel 里,攒一批一起买
财务 → 月底做账时才发现:技术部这个月花了 3 万,超了 1 万
三个具体问题:
| 问题 | 后果 |
|---|---|
| 额度在提交时不受控 | 员工可以随便提,超支只能在报销/结算环节事后发现,此时货已经买了 |
| 部门成本无法归集 | 只知道公司总共花了多少,不知道哪个部门花的,无法分摊成本 |
| 审批责任不可追溯 | 谁批的、什么时候批的、为什么驳回,全在聊天记录里 |
1.2 采云台的解法
引入 budget 表 = (部门, 周期) → 总额 + 已用,并把它卡在提交申领单的必经路径上:
员工提交申领单
↓
从申领车逐项重算金额(不信任前端)
↓
【卡点】原子预扣本部门本周期预算
├─ 成功 → 继续落库,单据状态 = 待审批
└─ 失败 → 抛异常 → 整个事务回滚 → 不产生任何单据
↓
审批驳回 / 员工撤销 / 管理员取消 → 返还预算
审批通过 → 采购中 → 发货 → 已完成(预算**不返还**,这才是真实支出)
核心设计意图:把”额度够不够”的判断从事后对账提前到提交瞬间,并且是强一致的(不是”大概够”)。
1.3 为什么不用”支付”而用”预算”
原项目(苍穹外卖)这里走的是微信支付:下单 → 调微信支付 → 支付成功回调 → 订单转已支付。
改造时删掉了支付(application-dev.yml 注释明确说明移除了 sky.wechat),原因:
- 企业内部采购不走第三方支付,走的是部门预算内部核算;
- 但”需要一笔资金被预占、失败要回滚、成功后可能退还”这个业务形态完全一样。
所以 Orders.payStatus 这个字段被保留下来但语义重映射为预算扣减状态:
| 值 | 原语义 | 新语义 |
|---|---|---|
| 0 | UN_PAID 未支付 | 未扣减 |
| 1 | PAID 已支付 | 已扣减 |
| 2 | REFUND 已退款 | 已返还 |
这是面试可以讲的一个点:改造时的取舍是”复用字段 + 重映射语义”,好处是改动面小(不用动表结构、不用改前端 DTO),代价是字段名和语义不符(payStatus 里装的是预算状态),需要在代码注释和数据库列注释里说清楚。fix_comments.sql 就是专门干这件事的。
二、数据库设计
2.1 表结构
CREATE TABLE budget (
id BIGINT NOT NULL AUTO_INCREMENT,
dept_id BIGINT NOT NULL COMMENT '部门id',
period VARCHAR(7) NOT NULL COMMENT '预算周期,格式 yyyy-MM',
total_amount DECIMAL(12, 2) NOT NULL DEFAULT 0 COMMENT '预算总额',
used_amount DECIMAL(12, 2) NOT NULL DEFAULT 0 COMMENT '已使用金额',
status INT NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用',
create_time DATETIME NULL,
update_time DATETIME NULL,
create_user BIGINT NULL,
update_user BIGINT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_budget_dept_period (dept_id, period), -- ← 关键
KEY idx_budget_period (period)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门预算表';(来源:sql/migration_office_supplies.sql 第 89-104 行)
2.2 设计决策逐条解释
① 为什么是「部门 + 周期」而不是「部门 + 年 / 月 + 日」?
period 用 VARCHAR(7) 存 yyyy-MM,而不是拆成 year INT, month INT,也不是直接用 DATE。
理由:
- 一张表表达多期:
(dept_id, period)唯一,一个部门在库里会有多行(2026-08、2026-09、2026-10…),历史可追溯,不需要每月清空重来; - 字符串可以直接比较:
'2026-09' > '2026-08',ORDER BY period DESC天然按时间倒序,WHERE period = '2026-09'走索引等值查询; - 格式固定 7 字符、定长可比,是”可读性和可比较性”的折中。如果拆成两个 INT,查询要写
WHERE year=? AND month=?,唯一索引变成三列,且无法直接排序。
② 为什么 (dept_id, period) 要建唯一索引?
两重作用,都很关键:
- 业务约束:防止管理员给同一部门同一周期建两条预算(“到底哪条才算数”);
- 并发正确性的前提:这是
deduct那条 UPDATE 的驱动索引。有它,WHERE dept_id=? AND period=?能直接定位到唯一一行,加行锁的粒度就是这一行;如果没有,可能扫多行甚至锁范围。
对应的应用层校验在 BudgetServiceImpl.save:36:
Budget exist = budgetMapper.getByDeptAndPeriod(budgetDTO.getDeptId(), budgetDTO.getPeriod());
if (exist != null) {
throw new BaseException("该部门在该周期的预算已存在,请直接修改");
}但这里有个真实的并发缺口(后面”易被质疑处”会详细展开):getByDeptAndPeriod 的 SQL 带 AND status = 1,所以如果一个部门该周期的预算是停用状态,查重查不到 → 会尝试 INSERT → 撞唯一索引 → 抛 SQLIntegrityConstraintViolationException → 被全局异常处理器捕获,但解析分支只认 “Duplicate entry”,然后按空格切分第 3 段,得到的消息会是类似 '2-2026-09'已存在,对用户来说是个奇怪的提示而不是”该周期预算已存在(已停用状态)”。
③ 为什么金额用 DECIMAL(12,2)?
- 绝不能用
FLOAT/DOUBLE:二进制浮点无法精确表示 0.1,0.1 + 0.2 != 0.3,累加会漂移,账目对不上; - Java 侧对应
BigDecimal(Budget.totalAmount/usedAmount/Orders.amount全是BigDecimal); DECIMAL(12,2)上限 9,999,999,999.99,对企业部门月度预算足够。- 注意一个细节:
Orders.packAmount、tablewareNumber用的是int(原始类型),不是Integer。这在实体里是隐患(默认值 0 而不是 null,MyBatis 动态 SQL 判 null 会失效),但不在 M2 范围内。
④ 为什么 status 要单独一列?
停用预算 ≠ 删除预算。停用后:
deduct的 SQL 带status = 1,所以停用期间提交申领单会失败(走”余额不足”分支)——这是有意的业务开关;- 但
release不带status条件,所以停用后仍然能返还——这个设计是对的,否则停用预算会导致之前已扣的钱永远退不回去。
这里有不一致:deduct 判 status=1,release 不判。这是有意的不对称,但代码里没写注释说明,面试官如果细看会问。
⑤ 为什么没有 budget_log(预算流水表)?
当前项目未实现。这是 M2 最大的设计缺口,也是面试最容易被追问的点。
现在”预算花了多少”只有一个汇总数字 used_amount,没有明细。想要回答”这个月技术部的 3 万分别花在哪些单子上”,只能反查 orders 表(requester_dept = ? AND period(order_time) = ?)再求和。
问题:
orders表没有period列,要按order_time计算周期,无法走索引;- 想查”某笔预扣是否被返还过”只能看
orders.pay_status,而这是当前状态,不是流水——一旦有 bug 导致状态错乱,没有账本可以对。
面试标准答法(见第六部分追问链与第七部分):
“这里是汇总式设计,不是流水式。好处是并发控制简单——一条 UPDATE 加行锁就够了。代价是没有审计明细,对账要靠
orders反推。如果金额敏感度更高,我会加一张budget_log(budget_id, order_id, type, amount, create_time),扣减和返还各插一条,used_amount变成流水派生出来的冗余字段,这样既有账本又不失去原子性。“
三、完整代码执行链路
M2 共有 4 条独立链路,全部覆盖。
链路 1:管理员设置部门预算
POST /admin/budget
Body: {"deptId":2, "period":"2026-09", "totalAmount":50000, "status":1}
Header: token={JWT}
↓ JwtTokenAdminInterceptor.preHandle() ← 认证
├─ JwtUtil.parseJWT(adminSecretKey, token)
├─ Redis GET login:token:{JWT} ← 白名单(登出即失效)
├─ employeeMapper.getById(empId) ← 查库校验角色与状态
└─ BaseContext.setCurrentId / setCurrentRole
↓ RoleAspect.checkRole() @RequireRole(ADMIN) ← 授权(PURCHASER 也会被拒)
└─ 不通过 → 抛 BaseException(NO_PERMISSION) → 全局异常处理 → code=0
↓ admin/BudgetController.save(BudgetDTO) ← 第 44 行
↓
BudgetServiceImpl.save(BudgetDTO) ← 第 34 行,【无 @Transactional】
├─ BudgetMapper.getByDeptAndPeriod(deptId, period)
│ SQL: SELECT * FROM budget WHERE dept_id=? AND period=? AND status=1
│ 命中 → 抛 BaseException("该部门在该周期的预算已存在,请直接修改")
├─ 组装 Budget 实体(Builder 模式)
│ period 为空 → currentPeriod() 兜底为当前月 yyyy-MM
│ totalAmount 为空 → BigDecimal.ZERO
│ usedAmount → 硬编码 BigDecimal.ZERO ← 新建预算已用金额必为 0
│ status 为空 → StatusConstant.ENABLE (1)
├─ BudgetMapper.insert(budget)
│ ← @AutoFill(INSERT) 切面在此介入:反射填 createTime/createUser/updateTime/updateUser
│ 其中 currentId = BaseContext.getCurrentId()(来自拦截器)
└─ useGeneratedKeys + keyProperty="id" 回填主键
↓
Result.success()
关键对象流转:BudgetDTO(入参)→ Budget(实体)→ DB;没有出参 VO。
Redis 参与:仅拦截器的登录态校验(login:token:{JWT})。
MQ 参与:无。
事务:无(也不必要——单条 INSERT)。
并发:有缺口(见第七部分)。
链路 2:管理员修改预算 / 查询预算列表
PUT /admin/budget @RequireRole(ADMIN)
↓ admin/BudgetController.update:59
↓ BudgetServiceImpl.update:57
└─ 组装 Budget(id, deptId, period, totalAmount, status)
└─ BudgetMapper.update → XML 动态 <set>
⚠ usedAmount 不在 DTO 里 → 永远不会被前端改到 ← 这是有意的安全设计
⚠ 但 deptId / period 可以被改 → 有撞唯一索引的风险(见第七部分)
← @AutoFill(UPDATE) 切面填 updateTime / updateUser
↓ Result.success()
GET /admin/budget/list?deptId=&period=&status= @RequireRole({ADMIN, PURCHASER})
↓ admin/BudgetController.list:74
↓ BudgetServiceImpl.list:69
└─ BudgetMapper.list → LEFT JOIN department 带出 deptName
并计算 (total_amount - used_amount) AS remainAmount ← 余额在 SQL 里算
WHERE 动态:deptId / period / status
ORDER BY period DESC, dept_id ASC
↓ Result.success(List<BudgetVO>)
注意 remainAmount 的算法:它不是数据库里的列,而是 SQL 里的派生列 (b.total_amount - b.used_amount)。
- 好处:不存冗余、永远一致、不会出现”总额减已用不等于余额”的脏状态;
- 代价:无法直接对”余额”建索引,没法高效查询”余额小于 X 的部门”(当前业务没这个需求)。
链路 3:员工查询本部门预算(唯一使用 getVOById 的入口)
GET /user/budget/my
Header: authentication={JWT}
↓ JwtTokenUserInterceptor.preHandle() ← 员工端认证(不同密钥、不同请求头)
↓ user/BudgetController.my() ← 第 41 行
├─ BaseContext.getCurrentId() → empId
├─ EmployeeMapper.getById(empId) ← MySQL:拿 deptId
│ employee == null 或 deptId == null
│ → return Result.success() ← data 为 null,不报错
├─ BudgetService.currentPeriod() → "2026-09" ← LocalDateTime.now() 格式化
└─ BudgetService.getVOByDeptAndPeriod(deptId, period) ← 第 73 行
├─ BudgetMapper.getByDeptAndPeriod(deptId, period) -- 第一次查询,拿 id
│ 为 null → 返回 null(前端提示"未设置预算")
└─ BudgetMapper.getVOById(id) -- 第二次查询
SELECT b.id, b.dept_id, d.name AS deptName, b.period,
b.total_amount, b.used_amount,
(b.total_amount - b.used_amount) AS remainAmount
FROM budget b LEFT JOIN department d ON b.dept_id = d.id
WHERE b.id = ?
↓ Result.success(BudgetVO{deptId, deptName, period, totalAmount, usedAmount, remainAmount})
这条链路里有一个明显的冗余查询:getVOByDeptAndPeriod 先按 (deptId, period) 查一次拿 id,再按 id 查一次拿 VO。两次查询可以合并成一次:
SELECT b.id, b.dept_id, d.name AS deptName, b.period, b.total_amount, b.used_amount,
(b.total_amount - b.used_amount) AS remainAmount
FROM budget b LEFT JOIN department d ON b.dept_id = d.id
WHERE b.dept_id = ? AND b.period = ? AND b.status = 1这是一处可以主动承认的优化点(第七部分详述)。面试官看到 getVOByDeptAndPeriod 的实现方法体(先查 ID 再查 VO)很可能会问”为什么不一次查完”。
为什么它还没造成问题:员工端只在进入申领页时调一次,且 (dept_id, period) 走唯一索引,两次查询都是索引等值命中,代价很小。但属于可以避免的往返。
链路 4:预算预扣(提交申领单)—— 这是 M2 的核心
POST /user/order/submit
Body: {"addressBookId":1, "amount":32.00, ...} ← 注意 amount 会被后端覆盖
Header: authentication={JWT}
↓ JwtTokenUserInterceptor.preHandle()
↓ user/OrderController.submit() ← 第 50 行
├─ BaseContext.getCurrentId() → userId
├─ Redis SET NX order:submit:{userId} "1" TTL=5s ← 防重复提交(第一道幂等)
│ 未抢到 → 抛 OrderBusinessException(ORDER_REPEAT_SUBMIT)
└─ orderService.submitOrder(dto)
↓ OrderServiceImpl.submitOrder() @Transactional ← 第 65-66 行,【事务从这里开始】
│
├─【步骤 1】Redis GET shop:status
│ status != null && status == 0 → 抛 OrderBusinessException(SHOP_CLOSED)
│
├─【步骤 2】AddressBookMapper.getById(addressBookId) ← MySQL
│ null → 抛 AddressBookBusinessException
│ ※ 原项目的"百度地图配送范围校验"在这里被删除(企业内部无配送范围概念)
│
├─【步骤 3】ShoppingCartMapper.list(userId) ← MySQL
│ 空 → 抛 ShoppingCartBusinessException
│
├─【步骤 4】EmployeeMapper.getById(userId) ← MySQL,拿 deptId
│ employee == null 或 deptId == null
│ → 抛 BudgetBusinessException(BUDGET_NOT_FOUND)
│ deptId = employee.getDeptId()
│
├─【步骤 5】★ 金额重算(安全关键)
│ amount = shoppingCartList.stream()
│ .map(c -> c.getAmount().multiply(BigDecimal.valueOf(c.getNumber())))
│ .reduce(BigDecimal.ZERO, BigDecimal::add)
│ ※ 完全忽略 DTO 里的 amount。原缺陷是"前端算好钱告诉后端扣多少",
│ 可传 amount=0 零元提交,甚至传负数反向下减已用金额
│
├─【步骤 6】★ 预算预扣 —— M2 的核心
│ period = budgetService.currentPeriod() → "2026-09"
│ boolean deducted = budgetService.deduct(deptId, period, amount)
│
│ ↓ BudgetServiceImpl.deduct(deptId, period, amount) ← 第 90 行
│ 【防御式编程】
│ if (deptId == null || amount == null || amount.signum() <= 0) return false;
│ └─ amount.signum() <= 0 拦住 0 和负数(第二道防线,与步骤 5 互补)
│ ↓
│ int rows = BudgetMapper.deduct(deptId, period, amount)
│ ↓
│ 【BudgetMapper.xml 第 31-38 行】
│ UPDATE budget
│ SET used_amount = used_amount + #{amount}
│ WHERE dept_id = #{deptId}
│ AND period = #{period}
│ AND status = 1
│ AND total_amount - used_amount >= #{amount}
│ ↓
│ 【并发语义】(这里是最值得讲的地方)
│ · WHERE 用 uk_budget_dept_period 定位到唯一一行 → 对该行加 X 锁(排他锁)
│ · MySQL 默认 RR 隔离级别下,UPDATE 读的是【最新已提交版本】而不是快照
│ (SELECT 读快照,UPDATE 读当前读 current read)——这正是本方案能成立的根本原因
│ · 两条并发事务必然串行:后到者等锁释放后,在【新值】上重新核对 WHERE
│ · total_amount - used_amount >= amount 不成立 → 该行不匹配 → rows = 0
│
│ rows == 0 → log.warn 记录 deptId/period/amount → return false
│ rows == 1 → return true
│
│ deducted == false
│ → 抛 BudgetBusinessException(BUDGET_NOT_ENOUGH)
│ → ★ 异常向上抛出 → @Transactional 回滚 → 【不会产生任何单据、申领车不清空】
│
├─【步骤 7】生成申领单号
│ dateStr = LocalDate.now().format(BASIC_ISO_DATE) → "20260930"
│ Redis INCR order:number:20260930 → seq
│ seq == 1 → EXPIRE 2 天(仅首次设 TTL,避免每天残留永久键)
│ number = "PO" + dateStr + "-" + String.format("%06d", seq)
│ → "PO20260930-000137"
│
├─【步骤 8】落库(主表 + 明细)
│ orders.setAmount(amount) ← 用重算后的金额,不是前端的
│ orders.setPayStatus(Orders.PAID) = 1 已扣减
│ orders.setStatus(Orders.TO_BE_CONFIRMED) = 2 待审批
│ orders.setRequesterDept(deptId) ← 冗余存部门,便于按部门统计
│ orders.setIsReturned(0)
│ OrderMapper.insert(orders) → MySQL,useGeneratedKeys 回填 id
│ OrderDetailMapper.insertBatch(orderDetailList) → MySQL,n 行
│
├─【步骤 9】ShoppingCartMapper.deleteByUserId(userId) ← MySQL
│
└─【步骤 10】返回 OrderSubmitVO{id, orderNumber, orderTime, orderAmount}
这条链路的三个”防线”层次(面试时可作为结构化回答):
| 层次 | 位置 | 防什么 |
|---|---|---|
| 接入层 | Redis SET NX 5s | 同一个人 5 秒内重复点击 |
| 应用层 | 按申领车重算金额 + amount.signum() <= 0 | 前端篡改金额、零元/负数提交 |
| 数据库层 | UPDATE 的 WHERE 条件 + 行锁 + 唯一索引 | 并发超额扣减、重复建预算 |
链路 5:预算返还(三条链路共用同一段代码)
返还的入口有三个,但都收敛到同一个私有方法 releaseBudget:
【入口 A】管理端审批驳回
PUT /admin/order/rejection @RequireRole({ADMIN, PURCHASER})
↓ OrderServiceImpl.rejection() @Transactional(rollbackFor = Exception.class) ← 第 434 行
├─ orderMapper.getById(id)
│ null 或 status != TO_BE_CONFIRMED(2) → 抛 OrderBusinessException(ORDER_STATUS_ERROR)
├─ releaseBudget(ordersDB) ← 返还
├─ orderMapper.update(orders{status=CANCELLED(6), rejectionReason, cancelTime, payStatus=REFUND(2)})
└─ 提交事务
【入口 B】员工自助撤销
PUT /user/order/cancel/{id}
↓ OrderServiceImpl.userCancelById() @Transactional(rollbackFor = Exception.class) ← 第 232 行
├─ orderMapper.getById(id);null → 抛 ORDER_NOT_FOUND
├─ assertOwner(ordersDB) ← 归属校验(防 IDOR 越权)
├─ status > 2 → 抛 ORDER_STATUS_ERROR ← 只有"待审批"之前可自行撤销
├─ releaseBudget(ordersDB)
├─ orderMapper.update(orders{status=6, cancelReason="员工撤销申领", cancelTime, payStatus=2})
└─ 提交事务
【入口 C】管理员取消
PUT /admin/order/cancel @RequireRole({ADMIN, PURCHASER})
↓ OrderServiceImpl.cancel() @Transactional(rollbackFor = Exception.class) ← 第 463 行
├─ orderMapper.getById(id);null → 抛 ORDER_NOT_FOUND
├─ status == COMPLETED(5) 或 CANCELLED(6) → 抛 ORDER_STATUS_ERROR
├─ releaseBudget(ordersDB)
├─ orderMapper.update(orders{status=6, cancelReason, cancelTime, payStatus=2})
└─ 提交事务
──────────────────────────────────────────────────────────
三条链路共同调用的核心方法(第 271-283 行):
private void releaseBudget(Orders ordersDB) {
// ★ 第一道防线:业务状态机守卫
if (!Orders.PAID.equals(ordersDB.getPayStatus())) {
return; // payStatus 不是 1(已扣减) → 说明没扣过或已经返还过 → 直接跳过
}
Long deptId = ordersDB.getRequesterDept();
if (deptId == null) {
log.warn("申领单 {} 缺少申请部门,无法返还预算", ordersDB.getId());
return;
}
// ★ 关键:按【申领单自己的下单时间】反推周期,不是用当前月份!
String period = budgetService.periodOf(ordersDB.getOrderTime());
budgetService.release(deptId, period, ordersDB.getAmount());
// ↓ BudgetServiceImpl.release(deptId, period, amount) 第 110 行
// if (deptId == null || amount == null) return false;
// int rows = BudgetMapper.release(deptId, period, amount)
// rows == 0 → log.warn("预算返还失败(可能已返还过)") → return false
//
// ↓ BudgetMapper.xml 第 43-49 行
// UPDATE budget
// SET used_amount = used_amount - #{amount}
// WHERE dept_id = #{deptId}
// AND period = #{period}
// AND used_amount >= #{amount} ← ★ 第二道防线:条件更新
}
双防线设计(这是 M2 最值得讲的一个点):
| 防线 | 位置 | 防的场景 | 失效方式 |
|---|---|---|---|
| 第一道 | 应用层 payStatus == PAID 判断 | 业务性重复:员工撤销后又管理员取消(同一个单据走了两条链路) | 如果某个入口忘记把 payStatus 改成 REFUND,这道防线就失效 |
| 第二道 | SQL 层 used_amount >= amount | 并发性重复:两个请求同时返还同一单据(都读到了 payStatus=1) | 只能防”刷成负数”,如果额度充足仍会重复多退 |
为什么需要两道? 因为它们防的是不同的问题:
- 第一道防的是「同一单据被不同业务流程反复返还」——这是逻辑问题;
- 第二道防的是「同一单据被并发请求同时返还」——这是时序问题。
并且第二道的 SQL 条件不足以单独解决问题,这点必须说清楚:
假设部门可用额度充足(used_amount = 1000,要退 32),两个并发请求都通过了第一道防线(都读到 payStatus=1),那么:
- 请求 1:
used_amount = 1000 - 32 = 968✅ - 请求 2:加锁后重新校验
968 >= 32成立 →used_amount = 968 - 32 = 936❌ 多退了 32
所以严格来说,“双防线”这个说法在并发场景下是不严密的。 真正的保证来自:三个入口都有 @Transactional + 同一事务内会先更新 pay_status,加上 payStatus 是第一道守卫。如果要严格保证并发幂等,正确做法是让返还本身也变成单据状态驱动的条件更新:
-- 更严格的做法:把"是否已返还"变成一条原子状态迁移
UPDATE orders SET pay_status = 2, status = 6, ...
WHERE id = ? AND pay_status = 1; -- 影响行数=1 才执行预算返还
-- 影响行数=0 → 说明已被其他请求处理,直接返回,不动预算这是第七部分会详细展开的一个”易被质疑处”,面试时如果被追问”并发下会不会重复返还”,不能只说”有 SQL 条件兜底”,要主动说出上面这个缺口和自己想到的更严格方案。
链路 6:退换单不返还预算(有意设计)
POST /user/order/return/{id}?reason=xxx
↓ OrderServiceImpl.returnOrder() ← 第 565 行,【无 @Transactional,也无 releaseBudget】
├─ orderMapper.getById(id);null → 抛 ORDER_NOT_FOUND
├─ status != COMPLETED(5) → 抛 OrderBusinessException(ORDER_CANNOT_RETURN)
├─ 归属校验
└─ orderMapper.update(orders{status=CANCELLED(6), cancelReason="退换:xxx",
cancelTime, isReturned=1})
↑ payStatus 保持 1(已扣减),预算不退
代码注释(第 560-561 行)原文:
「预算是否返还由管理端线下确认后处理,这里只做状态流转,避免”退换即退款”造成资金口径混乱。」
这个设计对不对? 分两面看:
站得住脚的理由:退换的货物可能已经采购、甚至已经在用了,退换是否成功、退多少是线下协商结果,不是系统能自动判定的。如果系统一提交退换就把预算退回去,员工可以”申请退换 → 预算退回 → 线下不退了”,形成预算套利。所以让退换只做状态流转、由管理员线下确认后再处理,是把不可判定的决策交给人,符合”系统不该替业务做不了主的事做主”的原则。
站不住脚的地方:returnOrder 把单据置成了 CANCELLED(6),而 cancel 的守卫是”status == CANCELLED 就拒绝”。这意味着退换后管理员再也不能通过 /admin/order/cancel 来返还这笔预算了——因为单据已经是 6 了。所以”由管理端线下确认后处理”这句话在系统里没有对应的操作入口,只能去数据库改数据。
这是一个真实的闭环缺口,面试时如果被问”退换的预算怎么退”,唯一诚实的回答是:
“当前实现里退换只做状态流转、不返还预算,注释说由管理端线下确认,但系统里确实没有对应的返还入口——因为单据已经被置为已取消,
/admin/order/cancel会拒绝。这是个已知的闭环缺口,合理的修法是给退换加一个独立的待确认状态(比如 7),由管理员审批通过后才调用releaseBudget。”
(这个状态 7 不存在于当前代码里,Orders 只到 6。)
四、为什么这么设计(核心部分)
逐个技术点回答:业务场景 → 原始方案 → 问题 → 当前方案 → 为什么选它 → 替代方案 → 方案对比。
4.1 预算扣减
【业务场景】
员工提交申领单时要预占本部门月度额度。这是资金安全问题——超支意味着财务失控。
【原始方案】
先查余额,判断够不够,再更新:
// 反例,项目里没有这么写
Budget budget = budgetMapper.getByDeptAndPeriod(deptId, period);
if (budget.getTotalAmount().subtract(budget.getUsedAmount()).compareTo(amount) >= 0) {
budget.setUsedAmount(budget.getUsedAmount().add(amount));
budgetMapper.update(budget);
}【原始方案的问题】
典型的 check-then-act 竞态。两个员工(或同一员工连点两次)并发提交,额度只够一个人:
时刻 T1 T2
──────────────────────────────────────────────────────────
t1 SELECT → used=0, total=100
t2 SELECT → used=0, total=100
t3 0+80 <= 100 ✅ 判断通过
t4 0+80 <= 100 ✅ 判断通过
t5 UPDATE used = 80
t6 UPDATE used = 80 ← 丢失更新
──────────────────────────────────────────────────────────
结果:扣了两次 80(共 160),但 used_amount 只记了 80 → 超额 60 完全不记账
这个是”丢失更新”(lost update),而且比”只超额”更糟——账实不符,事后对账都查不出来。
【当前方案】
把判断下推到 SQL 的 WHERE 里,让”判断 + 更新”成为一条语句、一个原子操作:
<update id="deduct">
update budget
set used_amount = used_amount + #{amount}
where dept_id = #{deptId}
and period = #{period}
and status = 1
and total_amount - used_amount >= #{amount}
</update>业务层只用影响行数判断结果:
int rows = budgetMapper.deduct(deptId, period, amount);
if (rows == 0) { /* 余额不足或预算不存在 */ return false; }
return true;【为什么这样能解决】
三个要点,缺一不可,面试要能讲清:
- 行锁:
WHERE dept_id=? AND period=?命中uk_budget_dept_period唯一索引,InnoDB 直接定位到目标行并加排他锁(X 锁),锁的不是表、也不是索引区间,就是这一行。 - 当前读(current read):MySQL 默认隔离级别是 REPEATABLE READ,普通
SELECT读的是事务开始时的快照(一致性读),但UPDATE在加锁后会读取最新已提交版本(当前读)。所以第二个事务拿到锁后,看到的是第一个事务已经加完的used_amount,然后在新值上重新评估 WHERE。如果这里用的是SELECT,就会像原始方案那样读到旧快照——这是本案能成立的根本原因,也是面试最能体现深度的一点。 - 唯一索引保证定位到单行:唯一索引等值查询退化为行锁。如果
(dept_id, period)没有唯一约束而是普通索引,仍能定位到具体行,但如果有重复数据就会锁多行;更糟的情况是 WHERE 走不到索引,InnoDB 会对扫描到的所有行加锁甚至锁住间隙(gap lock),并发度急剧下降。
【替代方案对比】
| 方案 | 实现 | 优点 | 缺点 | 本项目是否适用 |
|---|---|---|---|---|
| A. 条件 UPDATE(当前方案) | 判断下推到 WHERE,看影响行数 | 一次数据库往返;无锁租约/续期/释放问题;行锁自动随事务释放;无额外组件 | 只能做”加/减”这类单调运算,无法表达复杂业务规则;逻辑散在 SQL 里 | ✅ 选它 |
B. SELECT ... FOR UPDATE 悲观锁 | 先加锁查,再判断,再更新 | 逻辑留在 Java 里,可读性好,能表达复杂规则 | 两次往返(查+改);锁持有时间变长(包含 Java 判断时间);要显式包事务,漏了 @Transactional 直接失效 | 可选,但没必要 |
C. Redis DECR / Lua 脚本 | 余额放 Redis,用原子命令扣 | 极快;不占数据库连接 | 余额会丢(Redis 非强持久);双写一致性问题(Redis 扣了 DB 没落);资金数据放缓存违背权威性原则 | ❌ 资金数据不适合 |
| D. Redisson 分布式锁 | 锁 budget:{deptId}:{period},锁内查改 | 跨服务、跨库都能用;业务逻辑完整 | 引入新组件;锁租约/续期/watchdog 复杂度;单库单行场景属于杀鸡用牛刀;锁失效或超时引入新的不确定性 | ❌ 过度设计 |
| E. 乐观锁(version / CAS) | 加 version 列,UPDATE ... WHERE version=? | 无锁等待;适合冲突少的场景 | 冲突高时大量失败重试;部门预算恰好是热点行(同部门员工同时提交),重试风暴风险大 | ❌ 冲突模式不匹配 |
| F. 消息队列串行化扣减 | 扣减请求进 MQ,单消费者顺序处理 | 天然串行;可削峰 | 引入 MQ;变成异步,前端拿不到即时结果(“够不够”要等回调);端到端延迟不可控 | ❌ 交互语义不允许 |
【为什么选 A 不选 D(分布式锁)——这是面试必问】
「这个场景是单库单行的并发正确性问题,不是分布式问题。数据只有一个权威副本在 MySQL,InnoDB 的行锁本身就提供了原子性,而且锁是随事务自动获取和释放的,没有租约过期、没有续期、没有锁误删的风险。引入 Redisson 只会把’数据库能保证的事’搬到应用层,增加故障面。分布式锁要解决的是跨多个资源/多个服务的互斥,这里不满足那个条件。」
【如果一定要用 Redis 扣减,怎么讲才不被扣分】
主动说清边界:
「如果预算数据要脱离 MySQL 单独承载高并发,可以考虑 Redis + Lua 做原子扣减,但必须解决『Redis 扣了、数据库落库失败』的一致性问题(比如用 Redis Stream 或本地消息表做补偿),而且资金数据以缓存为权威源本身就有可靠性风险。当前规模下没必要。」
4.2 预算返还的幂等
【业务场景】
驳回、撤销、取消三条链路都要返还预算。同一个单据可能被多条链路碰到(先员工撤销,管理员再想取消),也可能被并发请求同时碰到。
【原始方案】
每条链路直接调 UPDATE budget SET used_amount = used_amount - ?。
【原始方案的问题】
- 重复返还致账面虚增:员工撤销(退 32),管理员再取消(又退 32)→ 部门凭空多了 32 的可用额度;
- 并发返还刷成负数:两个请求同时退,
used_amount可能变成负数,之后新提交的申领单会误判为”额度充足”。
【当前方案】双防线
- 应用层:
if (!Orders.PAID.equals(ordersDB.getPayStatus())) return;——用单据状态机守卫; - SQL 层:
WHERE used_amount >= #{amount}——防刷成负数。
【为什么这么设计】
payStatus 是业务语义上的”这笔钱的状态”(0 未扣 / 1 已扣 / 2 已还),它比”某个接口是否被调用过”更稳定:无论从哪个入口进来,只要看到 payStatus=2 就跳过。SQL 条件则是最后兜底,防止应用层判断被绕过(比如以后有人加了新入口忘记判 payStatus)。
【替代方案对比】
| 方案 | 实现 | 评价 |
|---|---|---|
| A. 单据状态机 + 条件 UPDATE(当前方案) | payStatus 守卫 + used_amount >= amount | ✅ 选它。零额外组件,两道防线针对不同失效模式 |
| B. 预算流水表 + 唯一约束 | budget_log(budget_id, order_id, type),uk(budget_id, order_id, type) | 最严格,数据库层面绝对防重复;还能留下审计账本。代价是多一张表和一次插入;本项目未实现 |
| C. 把返还做成 condition-update 的状态迁移 | 先 UPDATE orders SET pay_status=2 WHERE id=? AND pay_status=1,看影响行数决定是否返还 | 比当前方案更严格(把守卫也变成原子的),推荐作为改进方向。本项目未实现 |
| D. 分布式锁 | 锁单据 id | 过度设计,同上 |
4.3 金额为什么用 BigDecimal
【业务场景】 预算是钱,涉及加减、比较、累加。
【原始方案】 double / float。
【问题】 二进制浮点无法精确表示十进制小数。0.1 + 0.2 = 0.30000000000000004。累加几千笔后误差可能到分级别,对账对不上。
【当前方案】 Java 侧 BigDecimal,数据库侧 DECIMAL(12,2)。
【关键细节】 BigDecimal 自己的坑:
- 必须用字符串构造
new BigDecimal("0.1"),用new BigDecimal(0.1)会引入 double 的误差; equals比”值和 scale”,compareTo只比”值”——BigDecimal("1.0").equals(BigDecimal("1.00"))是 false。所以判断金额相等要用compareTo() == 0;- 除法必须指定精度和舍入模式,
divide()不指定在无限循环小数时会抛ArithmeticException。
本项目的实际用法是安全的:c.getAmount().multiply(BigDecimal.valueOf(c.getNumber()))——BigDecimal.valueOf(long) 是安全的(内部走 Long.toString),multiply 不丢精度。没有用到除法,所以没有舍入问题。
【面试可以主动提的点】 unitPrice = turnover / validOrderCount 在 WorkspaceServiceImpl:75 用的是 Double(不是 BigDecimal),因为那是统计报表的展示值、不参与账务,用 double 可以接受。能区分”钱”和”统计值”两种精度要求,是加分的。
4.4 为什么 period 用 String("yyyy-MM") 而不是 Date
| 方案 | 优点 | 缺点 |
|---|---|---|
A. VARCHAR(7) = “2026-09”(当前方案) | 唯一索引简单;可直接字符串比较和排序;ORDER BY period DESC 即时间倒序;跨库/跨语言无歧义 | 无法做”按天”粒度;格式靠约定(无数据库层校验) |
B. DATE 存每月 1 号 | 类型安全;可以用日期函数 | 要防”有人存成 9 月 5 号”这种脏数据,需要额外约束;WHERE 要用范围查询而不是等值,索引效率略差 |
C. INT 存 202609 | 紧凑、可比 | 可读性差,202609 是什么日期要靠人脑解析;格式化要额外代码 |
D. 拆 year INT, month INT | 语义最清晰 | 唯一索引变三列;无法直接 ORDER BY 时间;查询要写两个条件 |
选 A 的核心理由:这是”业务周期标识”,不是”时间点”。它只需要”可比较、可等值匹配、可读”,不需要日期运算。
⚠️ 但要主动说清一个缺陷:格式校验只在应用层(DateTimeFormatter 生成),管理员如果手工传一个不合法的 period(比如 "2026/09" 或 "abc"),BudgetServiceImpl.save 会原样入库(budgetDTO.getPeriod() == null ? currentPeriod() : budgetDTO.getPeriod()),不会报错。之后员工端用 currentPeriod() 生成的标准格式去查,永远查不到这条预算 → 员工看到”未设置预算”。当前项目未做 period 格式校验,这是一处真实的健壮性缺口。
4.5 为什么 deduct 判 status = 1 而 release 不判
【业务场景】
- 停用预算 = 管理员”这个部门这个月不再批新申领”;
- 但已经发生的扣减必须能退回。
【当前方案】 有意的不对称:
deduct带AND status = 1→ 停用期间提交申领单会失败(走”余额不足”提示);release不带status条件 → 停用后仍能返还。
【如果反过来会怎样】
假如 deduct 不判 status:停用形同虚设,管理员关掉预算后员工照样能提交。
假如 release 判 status=1:管理员在员工撤销之前停用了预算 → 返还失败 → 员工的钱永远退不回来,只能改数据库。
【替代方案】 更严谨的做法是让两条路径读同一个”预算是否可变更”的语义,并在停用时级联处理在途单据(要么禁止停用,要么停用后仍允许返还但记录原因)。当前项目是”不对称 + 靠 SQL 条件自然区分”,简单但依赖读者理解这段不对称的意图——代码里没有一行注释说明这个不对称,属于隐性知识,面试时是自己主动讲出来的加分点。
【面试可以补一句】 顺带指出这里的一个体验问题:预算不存在、预算停用、余额不足,三种情况在 deduct 里全部返回 rows = 0,业务层统一抛 BUDGET_NOT_ENOUGH(“部门预算余额不足,无法提交申领单”)。无法区分”没设置预算”和”钱不够”。更好的做法是先查一次预算存不存在(或者让 SQL 返回不同的错误码/用 RETURNING),给用户更准确的提示。MessageConstant.BUDGET_NOT_FOUND(“未查询到该部门的预算,请联系管理员设置”)这个常量已经定义好了,但在 deduct 路径上永远不会被触发——它只在”员工没有 dept_id”时才用(OrderServiceImpl:101)。这是”常量定义了但语义没用上”的一个小痕迹,可以直接指出。
五、M2 复习优先级文件清单(第五部分 · M2 切片)
如果面试前只有 2 小时,M2 只需要打开这 4 个文件、看这 6 段代码。
A 类:必须能背下来、能手画
| 优先级 | 文件 | 看什么 | 为什么 |
|---|---|---|---|
| P0 | BudgetMapper.xml | 第 31-38 行 deduct、第 43-49 行 release | 整个 M2 的技术核心,就这两条 SQL。要能默写 WHERE 条件并解释为什么这么写 |
| P0 | BudgetServiceImpl.java | deduct:90、release:110(各 10 行) | 理解”业务层只看影响行数”这个模式;注意 amount.signum() <= 0 的防御 |
| P0 | OrderServiceImpl.java | submitOrder 的 第 96-110 行(金额重算 + 预扣)、第 271-283 行 releaseBudget | 预扣的调用上下文 + 双防线的第一道 |
| P0 | sql/migration_office_supplies.sql | 第 89-104 行 budget 建表 | 唯一索引是方案成立的前提,必须能说清 |
B 类:知道调用了什么、流程怎么走即可
| 文件 | 看什么 |
|---|---|
| admin/BudgetController.java | 3 个接口 + @RequireRole 的角色分工 |
| user/BudgetController.java | my() 里 employee == null 时返回空 data |
| BudgetMapper.java | @Select 注解式 vs XML 式混用的现象 |
sql/regress_api_test.sh | 第 128-131 行(提交前预算)、第 156-158 行(预扣 32)、第 194-205 行(驳回返还)、第 207-215 行(撤销返还 + 重复撤销幂等)、第 217-227 行(超额拦截) |
C 类:不用看
- Budget.java / BudgetDTO.java / BudgetVO.java —— Lombok 数据类,扫一眼字段即可
BudgetMapper.xml的insert/update/list—— 标准 MyBatis 动态 SQLDepartment相关 —— 属于 M10/M11 范畴
2 小时复习顺序建议(M2 部分约 25 分钟)
1. BudgetMapper.xml 的 deduct 和 release ← 5 分钟,默写 WHERE 条件
2. BudgetServiceImpl.deduct / release ← 3 分钟,记住"看影响行数"
3. OrderServiceImpl 第 96-110 行 ← 5 分钟,能口述预扣上下文
4. OrderServiceImpl.releaseBudget ← 5 分钟,理解双防线
5. budget 建表语句的唯一索引 ← 2 分钟
6. 用自己的话讲一遍"为什么不用分布式锁" ← 5 分钟,这是必问
六、M2 面试追问链(10 层)
追问链 1:并发正确性(最可能被问的一条)
【面试官问题 1】
你这里提交申领单要扣部门预算,怎么保证不会超支?
【推荐回答】
我把”额度够不够”的判断直接下推到了 SQL 的 WHERE 条件里,用一条带条件的 UPDATE 同时完成”判断”和”扣减”,业务层只看这条 SQL 的影响行数——返回 1 就是扣成功,返回 0 就是余额不足,然后抛异常回滚整个事务。这样判断和更新是一个原子操作,不存在”查完了再改”的中间窗口。
【回答关键词】 条件 UPDATE · 判断下推 · 影响行数 · InnoDB 行锁 · 原子性
【可能继续追问】 为什么”先查再改”不行?
【面试官问题 2】
为什么不先查出来判断一下再更新?那样代码不是更直观吗?
【推荐回答】
那样会有 check-then-act 竞态。举个具体的:部门额度 100,两个人同时提交 80。两个请求都先 SELECT 到 used_amount = 0,都判断”0+80 <= 100,够”,然后都执行 UPDATE。虽然 InnoDB 会串行化这两个 UPDATE,但第二次 UPDATE 是 SET used_amount = 80(如果写成赋值而不是累加),最后账面只记了 80,实际却占了 160 —— 这是丢失更新,账实不符,事后对账都查不出来。
【回答关键词】 竞态 · check-then-act · 丢失更新 · 账实不符
【可能继续追问】 那你的 SQL 为什么能避免?
【面试官问题 3】
你这条 UPDATE 靠什么保证第二个请求不会读到旧数据?
【推荐回答】
靠两点。第一是行锁:WHERE dept_id=? AND period=? 命中了 (dept_id, period) 上的唯一索引,InnoDB 直接定位到唯一一行并加排他锁,第二个事务必须等锁释放。第二,也是更关键的一点:MySQL 默认隔离级别是 REPEATABLE READ,普通 SELECT 读的是事务开始时的快照,但 UPDATE 是当前读——加锁之后会读取最新已提交的版本,然后在这个新值上重新评估 WHERE 条件。所以第二个事务拿到锁时,看到的是第一个事务已经加完的 used_amount,total_amount - used_amount >= amount 就不成立了,影响行数是 0。
【回答关键词】 当前读 / current read · 一致性读(快照读) · RR 隔离级别 · 排他锁
【可能继续追问】 唯一索引在这里起什么作用?如果不是唯一索引呢?
【面试官问题 4】
如果 (dept_id, period) 上没有唯一索引,会怎么样?
【推荐回答】
两个问题。第一是业务上允许同一部门同一周期出现两条预算记录,那到底按哪条扣就不确定了。第二是锁的粒度和范围会变化:唯一索引等值查询会退化成行锁,锁的就是那一行;如果只是普通索引或者走不到索引,InnoDB 可能对扫描到的多行加锁,甚至在高并发下出现间隙锁导致更大范围的阻塞,并发吞吐会明显下降。所以这个唯一索引不只是业务约束,也是并发方案的前提。
【回答关键词】 唯一索引退化为行锁 · 普通索引锁多行 · 间隙锁 gap lock · 并发度
【可能继续追问】 为什么不用分布式锁?
【面试官问题 5】
听起来这是并发问题,为什么不用 Redis 或 Redisson 分布式锁?
【推荐回答】
因为这个场景不是分布式问题,是单库单行的并发正确性问题。预算数据只有一个权威副本在 MySQL 里,InnoDB 的行锁本身就能保证原子性,而且锁是随事务自动获取和释放的——没有租约过期、没有续期、没有锁误删、没有需要额外引入的组件。用分布式锁会把这个数据库本来就能保证的事情搬到应用层,只会增加故障面。分布式锁要解决的是跨多个资源或多个服务的互斥,这里不满足那个条件。
【回答关键词】 单库单行 · 不是分布式问题 · 锁自动释放 · 故障面 · 规模边界
【可能继续追问】 那如果是多实例部署呢?
【面试官问题 6】
如果后端部署了多个实例,你这条 SQL 还成立吗?
【推荐回答】
还成立,而且正是这种方案的优势。因为并发控制落在数据库这一层,不在应用进程里,多实例访问的是同一个 MySQL,行锁对所有连接一视同仁。反过来说,如果用的是 synchronized 或者 ReentrantLock 这种 JVM 级别的锁,多实例下就完全失效了——这是很多项目里的隐藏 bug。
【回答关键词】 数据库层并发控制 · JVM 锁在多实例下失效 · 无状态应用
【可能继续追问】 那资金数据放 Redis 扣减行不行?
【面试官问题 7】
有些项目会把余额放 Redis 用 DECR 扣,你觉得呢?
【推荐回答】
对资金类数据我不倾向这么做,主要是两个问题。一是权威性:Redis 默认是异步持久化,宕机可能丢数据,而余额丢了意味着账实不符;二是双写一致性:Redis 扣成功、MySQL 落库失败,或者反过来,都会导致两边不一致,得额外引入补偿机制。如果确实要承载极高并发,我会考虑 Redis + Lua 做原子扣减,但必须配套 Redis Stream 或本地消息表做可靠的落库补偿,而且要接受”缓存承担权威源”的可靠性风险。当前项目的数据量和并发量完全没必要走到这一步。
【回答关键词】 权威数据源 · 异步持久化丢数据 · 双写一致性 · 补偿机制 · 数据量边界
【可能继续追问】 那你这个方案有什么缺点?
【面试官问题 8】
你这个方案自己没有不满意的地方吗?
【推荐回答】
有几个我明确知道的问题。第一,业务逻辑跑到 SQL 里了,“额度够不够”这个规则只存在于 WHERE 里,可读性和可测试性不如写在 Java 里,规则复杂一点就表达不了。第二,错误信息不够精确——预算不存在、预算被停用、余额不足,这三种情况在 SQL 里都是影响行数 0,业务层统一报”余额不足”,用户分辨不出到底是哪种;我代码里甚至定义了 BUDGET_NOT_FOUND 这个提示常量,但在扣减路径上永远走不到。第三,没有预算流水表,used_amount 只是个汇总数字,要查”这笔钱花在哪张单子上”只能反查 orders 再求和。如果要改进,我会加一张 budget_log,扣减和返还各插一条流水,used_amount 作为流水派生出来的冗余字段。
【回答关键词】 逻辑泄漏到 SQL · 错误码不精确 · 缺流水表/审计账本 · 主动暴露短板
【可能继续追问】 (这时候面试官通常会转向别的模块,或者追问返还幂等)
追问链 2:返还幂等(第二条高概率链路)
【面试官问题 1】
审批驳回、员工撤销、管理员取消,三条路径都要退预算。怎么保证不重复退?
【推荐回答】
我用了两层。第一层在业务代码里:releaseBudget 一开始就判断 orders.pay_status 是不是 1(已扣减),不是就直接 return——因为返还完会把 pay_status 改成 2(已返还),所以哪怕同一张单据走了两条链路,第二次进来会被这个状态机挡住。第二层在 SQL 里:返还的 UPDATE 带了 AND used_amount >= amount 条件,防止并发或者漏判导致把已用金额刷成负数。
【回答关键词】 单据状态机 pay_status · 守卫 return · 条件 UPDATE 兜底 · 双防线
【可能继续追问】 这两层分别防什么?
【面试官问题 2】
为什么要两层?一层不够吗?
【推荐回答】
因为它们防的是不同性质的问题。第一层 pay_status 防的是业务性重复——同一张单据被不同业务流程反复返还,这是逻辑问题。第二层 SQL 条件防的是并发性重复——两个请求同时返还同一张单据,理论上两个都可能读到 pay_status=1,这是时序问题。所以这两层不是重复,是针对不同失效模式的。
【回答关键词】 业务性重复 vs 并发性重复 · 逻辑问题 vs 时序问题 · 不同失效模式
【可能继续追问】 那第二层能防住并发重复吗?
【面试官问题 3】
好,那我追问一下:并发下两个请求都读到 pay_status=1,你的 SQL 条件能防住重复返还吗?
【推荐回答】
严格说,防不住。 这是我这套方案的一个真实缺口,我得说清楚。假设部门可用额度还很充足,要退 32:两个请求都通过了 pay_status 判断,第一个把 used_amount 从 1000 减到 968,第二个拿到锁后重新校验 968 >= 32 仍然成立,于是减到 936 —— 确实多退了 32。used_amount >= amount 这个条件只能防止把余额刷成负数,不能防止额度充足时的重复扣减。
【回答关键词】 主动承认缺口 · 条件只能防负数 · 额度充足时仍会多退
【可能继续追问】 那怎么才能严格保证?
【面试官问题 4】
那要怎么改才能严格保证幂等?
【推荐回答】
把”是否已返还”这个判断本身也变成原子的状态迁移,而不是先查后判断。具体做法是先执行一条条件更新:
UPDATE orders SET pay_status = 2 WHERE id = ? AND pay_status = 1;看影响行数——只有返回 1 的那个请求才继续去调预算返还,返回 0 的说明已经被别的请求处理过了,直接返回。这样”认领处理权”和”执行返还”的先后关系就确定了,和扣减预算用的是同一个思路:把判断下推到 WHERE,用影响行数做裁决。更彻底的做法是加一张 budget_log 流水表,在 (order_id, type) 上建唯一索引,从数据库层面绝对拒绝重复插入。
【回答关键词】 状态迁移原子化 · 影响行数裁决 · 认领处理权 · 流水表唯一索引
【可能继续追问】 那你现在为什么没改?
【面试官问题 5】
既然你知道更严格的写法,为什么代码里没这么写?
【推荐回答】
因为在当前项目里,这个缺口实际上不容易被触发。三个返还入口都标了 @Transactional,返还和更新 pay_status 在同一个事务里,而且单张单据的并发返还需要”同一个人同时点撤销、同时管理员点取消”这种很难复现的时序。回归脚本第 20 节验证的是”重复撤销不造成重复返还”(顺序重复,被 pay_status 挡住了),覆盖不到这个并发场景。要真正验证得写并发压测。所以我的判断是收益不高、改动面不小,就留在已知问题里了 —— 但如果这个系统要上生产、金额再大一点,我会优先改这里。
【回答关键词】 事务边界锁住 · 触发条件苛刻 · 触发成本 vs 收益 · 优先级判断 · 诚实
【可能继续追问】 那你为什么不用 select for update?
追问链 3:表设计与替代方案(较常见,5 层)
【面试官问题 1】
预算表为什么这么设计?为什么 (dept_id, period) 是唯一索引?
【推荐回答】
budget 表的核心是 (部门, 周期) → 总额 + 已用。建唯一索引有两个原因:一是业务约束,同一个部门同一个周期只能有一条预算,否则”按哪条扣”就说不清;二是并发方案的前提——我的扣减 SQL 用 WHERE dept_id = ? AND period = ?,唯一索引能让 InnoDB 直接定位到唯一一行并加行锁,锁粒度最小。
【回答关键词】 业务约束 · 并发前提 · 唯一索引定位单行 · 锁粒度
【可能继续追问】 为什么 period 用字符串?
【面试官问题 2】
period 为什么用 VARCHAR(7) 存 "2026-09",不用 DATE 或者年份月份两个字段?
【推荐回答】
因为它是业务周期标识,不是时间点。yyyy-MM 定长 7 字符,可以直接做等值匹配和字符串比较——'2026-09' > '2026-08' 成立,ORDER BY period DESC 天然就是按时间倒序,索引也能直接用。如果用 DATE 存每月 1 号,得防止有人存成 9 月 5 号,而且查询要用范围;如果拆成 year 和 month 两个 INT,唯一索引变三列,还不能直接按时间排序。
【回答关键词】 业务周期标识 vs 时间点 · 定长可比 · 可直接排序 · 索引等值
【可能继续追问】 这个设计有什么风险?
【面试官问题 3】
有什么风险?
【推荐回答】
格式校验只在应用层。period 是管理员传进来的,BudgetServiceImpl.save 里只做了 null 判断——传空就用当前月兜底,但传一个不合法的格式(比如 "2026/09" 或者 "abc")会原样入库,不会报错。之后员工端用 currentPeriod() 生成的标准格式去查,永远匹配不上,员工会看到”未设置预算”,而管理员在列表里能看到这条预算存在 —— 变成一个很难排查的问题。合理的修法是加格式校验(正则或者 DateTimeFormatter 解析),或者干脆让前端用月份选择器而不是文本框。
【回答关键词】 应用层校验缺失 · 脏数据导致查不到 · 加格式校验/日期选择器
【可能继续追问】 那余额是怎么算的?
【面试官问题 4】
remainAmount 是数据库里的字段吗?
【推荐回答】
不是,它是 SQL 里的派生列 (total_amount - used_amount)。这样不存冗余、永远一致,不会出现”总额减已用跟余额对不上”的脏状态。代价是没法给余额建索引,如果以后要查”余额低于某个值的部门”就走不了索引。当前业务不需要这个查询,所以能接受。
【回答关键词】 派生列 · 不存冗余 · 永不失配 · 无法建索引
【可能继续追问】 如果要查”某笔预算的变更历史”呢?
【面试官问题 5】
如果管理员问”这个月技术部这 3 万都花在哪些单子上”,你怎么查?
【推荐回答】
现在只能反查 orders 表:按 requester_dept 和 order_time 落在本月的条件,把单据金额求和 —— 我刚查过,结果应该等于 used_amount。但这里有两个问题:一是 orders 表没有 period 列,按 order_time 算周期走不了索引;二是这只能得到一个和,看不到”每笔预扣和返还的过程”。根本原因是项目没有预算流水表,used_amount 只是个汇总数字,没有账本。
如果要改进,我会加一张 budget_log(budget_id, order_id, type, amount, create_time),扣减和返还各插一条,used_amount 变成由流水派生的冗余字段——这样既有审计账本,又保留了一个可以快速读取的汇总值。这属于当前项目的已知缺口。
【回答关键词】 反查 orders 求和 · 无 period 列无法走索引 · 缺流水表/审计账本 · 改进方案
【可能继续追问】 (转向”为什么不用消息队列”)
追问链 4:异步与 MQ(M2 范围内最容易被”劝你上 MQ”的追问)
【面试官问题 1】
提交申领单的时候,扣预算和写单据为什么不做成异步?用消息队列解耦不是更好吗?
【推荐回答】
因为交互语义不允许。员工点提交之后要立刻知道结果——扣成功了就返回单号,余额不足就直接告诉他”余额不足”。如果做成异步,接口只能返回”已受理”,员工不知道到底成没成功,前端也没法提示。而且扣预算失败必须让整个落库事务回滚、不能产生单据,这种”要么全成要么全不成”的强一致要求,做了异步反而要靠补偿去还原,复杂度上升、正确性下降。
【回答关键词】 同步交互语义 · 前端要即时结果 · 强一致/事务边界 · 异步反而要补偿
【可能继续追问】 那这个项目里有适合用 MQ 的地方吗?
【面试官问题 2】
那你项目里有没有什么地方其实适合用 MQ,但你没用?
【推荐回答】
有几个我识别到但目前没做的。一是催办通知:现在是写 orders.remind_time,如果以后要真正推送消息给采购专员(短信、企业微信),那就该走 MQ 异步,不应该卡在员工请求的主链路上。二是申领单完成后的下游动作,比如通知财务、同步 ERP、生成台账,这些都属于”主流程成功之后的附带动作”,适合用 MQ 解耦,失败了也不影响主流程。三是工作台统计,现在是每次打开都做全表聚合,可以用 MQ 把单据状态变更事件发出来,消费端增量更新统计表或者 Redis 计数器。
但我要说清楚:这三件事当前项目都没实现,我也没有引入 MQ。
【回答关键词】 识别适用场景但未实现 · 催办通知 · 下游动作解耦 · 统计增量更新 · 诚实边界
【可能继续追问】 如果真上了 MQ,怎么保证消息不丢、不重复消费?
【面试官问题 3】
如果真引入 MQ,扣预算这个动作怎么保证消息不丢、不重复消费?
【推荐回答】
这块我只是设计层面的理解,项目里没有实践。思路是:不丢靠生产端本地消息表或者事务消息——先把”要发消息”和业务数据写进同一个本地事务,再由定时任务或事务回查去投递,投递成功才标记,这样避免”数据库成功但消息没发出去”;消费端要在业务处理成功后才 ack,不能用自动 ack,否则消费失败消息就丢了。
不重复消费靠消费端幂等,不是靠 MQ 保证 exactly-once —— 主流 MQ 基本都是 at-least-once。幂等的做法和我现在预算返还的思路是一样的:用业务唯一键加唯一索引,或者做条件状态更新。比如预算扣减消息,我会在流水表上用 (order_id, type) 唯一索引,重复消费时插入冲突直接忽略。
【回答关键词】 本地消息表 / 事务消息 · 手动 ack · at-least-once ≠ exactly-once · 消费端幂等 · 唯一索引去重
【可能继续追问】 (如果面试官懂 MQ,会继续问具体选型)
七、M2 容易被质疑的地方(严格视角,不强行合理化)
| # | 问题 | 面试官为什么可能质疑 | 当前代码实际情况 | 我应该怎么解释 | 是否建议修改 |
|---|---|---|---|---|---|
| M2-1 | used_amount >= amount 不足以防并发重复返还 | 面试官会说”条件更新只防负数,额度够的时候照样重复退” | ✅ 质疑成立。两个并发请求都通过 pay_status 判断后,第二个仍能成功返还 | 主动承认:「这道 SQL 只能防刷成负数,不能防额度充足时的重复扣减。严格的修法是把返还做成 UPDATE orders SET pay_status=2 WHERE id=? AND pay_status=1,用影响行数裁决,只有影响行数为 1 的请求才去动预算。」 | 建议修(改动小、收益明确,是提升”并发幂等”理解深度的好素材) |
| M2-2 | 没有预算流水表,缺审计账本 | ”资金操作怎么追溯?出错怎么对账?” | ✅ 成立。used_amount 是汇总数字,无明细、无操作记录,Budget 表也无任何操作日志 | 「当前是汇总式设计,并发控制简单;代价是缺审计明细,对账只能反查 orders。金额敏感度更高的话应该加 budget_log 流水表,用 (order_id, type) 唯一索引同时解决幂等和可追溯。」 | 建议加(这是把 M2 从”能讲清”提升到”有完整设计”的关键一步) |
| M2-3 | 退换单的预算永远退不了 | ”员工发起退换,预算怎么退?” | ✅ 真实的闭环缺口。returnOrder 把状态置为 CANCELLED(6),但不返还预算;而 cancel 的守卫是”status == CANCELLED 就拒绝”,所以管理员也无法通过接口返还。注释说”由管理端线下确认后处理”,但系统里没有这个入口 | 「退换只做状态流转不返还预算是有意的,为了避免’退换即退款’的套利。但你说得对,注释说由管理端线下处理,系统里却没有对应入口——因为单据已经是已取消状态,/admin/order/cancel 会拒绝。合理的修法是加一个独立的’退换待确认’状态,由管理员审批通过后才返还。」 | 建议修(加状态 7,或允许退换单走 cancel 路径) |
| M2-4 | deduct 的三种失败原因无法区分 | ”预算不存在和余额不足,提示为什么一样?” | ✅ 成立。deduct 一律返回 rows=0,业务层统一抛 BUDGET_NOT_ENOUGH。MessageConstant.BUDGET_NOT_FOUND 只用于”员工无 dept_id”场景,在扣减路径上永远触发不到 | 「SQL 层只能区分’匹配到几行’,所以三种情况收敛成一个错误。改法是先查一次预算判断存在性(多一次往返),或者让 SQL 用不同返回值/分开两条语句。当前为了少一次往返牺牲了提示精度。那个 BUDGET_NOT_FOUND 常量确实没在这个路径上用到。」 | 可选(体验优化,优先级低于 M2-1/M2-3) |
| M2-5 | deduct 判 status=1,release 不判,且无注释 | ”为什么两条 SQL 条件不对称?“ | ⚠️ 不对称是有意的且正确(停用后不该阻止返还),但代码里一行注释都没有 | 「这个不对称是有意的:deduct 判 status=1 让停用生效,release 不判是因为’钱必须能退回去’——如果返还也判 status=1,管理员在员工撤销之前停用了预算,员工的钱就永远退不回来了。应该在这个不对称处加注释说明。」 | 建议加注释(零风险) |
| M2-6 | save 的查重 SQL 带 status = 1,与唯一索引语义不一致 | ”你的唯一索引是 (dept_id, period),但查重是 (dept_id, period, status=1),能对上吗?” | ✅ 存在不一致。如果该周期预算已是停用状态,getByDeptAndPeriod 查不到 → 应用层不报”已存在” → 尝试 INSERT → 撞唯一索引 → 抛 SQLIntegrityConstraintViolationException → 全局异常处理器走”Duplicate entry”分支,按空格切分第 3 段生成消息,得到类似 '2-2026-09'已存在 的奇怪提示 | 「这是个真实的不一致。应用层的查重带了 status=1,但数据库的唯一索引不带,所以停用状态的预算会让查重漏过、然后被数据库拒绝。修法有两种:查重去掉 status 条件,或者唯一索引改成带 status 的复合(但那会允许重复的历史停用记录)。我倾向查重去掉 status。」 | 建议修(一行改动,消除一个能复现的怪提示) |
| M2-7 | getVOByDeptAndPeriod 查了两次库 | ”为什么先查 id 再查 VO,不一次查完?” | ✅ 成立。方法体是”按 (deptId, period) 查 Budget 拿 id”+“按 id 查 BudgetVO”两次查询,而 getVOById 的 SQL 完全可以加上 dept_id/period 条件合并成一次 | 「这两次查询可以合并,getVOById 的 SQL 加上 WHERE dept_id=? AND period=? 就够了。当时是复用了已有的 getVOById 方法,没合并。代价很小——都走唯一索引等值命中,但这个往返确实是多余的。」 | 建议合并(改动小,消除一处明显冗余) |
| M2-8 | 没有月度自动续期机制 | ”新月份预算怎么来?忘了设置会怎样?” | ✅ 成立。全项目无 @Scheduled、无定时任务。新周期必须管理员手工逐部门创建。某部门当月没设置 → 员工提交时报”预算余额不足”(因为 deduct 的 WHERE 匹配不到行,影响行数 0),提示误导 | 「当前是手工维护,没有自动续期。这会导致两个问题:一是管理员漏设一个月,该部门当月完全无法提交;二是提示是’余额不足’,但实际原因是’没设置预算’,员工会去找管理员问’我们部门明明有钱’。合理的修法是加一个定时任务在月初按上月总额生成新周期预算,或者把 deduct 的失败原因细分。」 | 建议修(定时任务 + 错误码细分,是很实际的改进项) |
| M2-9 | 锁的是行,热点部门会串行 | ”同一个部门 100 个员工同时提交,性能怎么样?“ | ⚠️ 成立。同一部门同一周期的所有提交都竞争同一行的 X 锁,完全串行 | 「是的,同一部门同一周期的提交会串行化,因为都锁同一行。但这里的临界区只有一条 UPDATE,没有网络调用、没有复杂计算,单行 UPDATE 的持锁时间是微秒级的,单部门百人同时提交在我们这个场景是完全能接受的。真正的瓶颈不在这里。如果部门规模到了万人级,可以考虑把预算拆成多个’额度桶’分散热点行,但那会引入额度分配的新复杂度。」 | 不需要改(要说清规模边界,不要吹”高并发”) |
| M2-10 | periodOf 用下单时间反推周期,跨月返还会记到上月 | ”1 月 31 号提交、2 月 5 号驳回,退到哪个月?“ | ⚠️ 代码行为是退到 1 月(budgetService.periodOf(ordersDB.getOrderTime()))——这是有意的,因为扣的是 1 月的钱 | 「退到扣的那个月,这是有意的——钱从哪个月扣的就还到哪个月。但会产生一个业务上的困惑:部门 1 月的已用金额在 2 月才被更新,1 月的报表如果已经出了,就变成’事后变动’。严格来说应该用一条带周期的流水记录这个跨期调整。这是’汇总式设计’的又一个代价。」 | 可选(配合 M2-2 的流水表一起解决) |
| M2-11 | update 允许改 deptId / period,可能撞唯一索引 | ”修改预算的时候如果改了部门或周期,会怎样?“ | ⚠️ 成立。BudgetDTO 里有 deptId 和 period,BudgetMapper.xml 的 update 动态 <set> 会把它们写进去。如果改成已存在的 (dept_id, period) 组合,会撞 uk_budget_dept_period,抛出的又是那个”按空格切分”的怪提示 | 「dept_id 和 period 其实是预算的业务主键,不应该允许修改——修改预算应该只允许改 total_amount 和 status。当前 DTO 里带着这两个字段,前端如果传了就会被更新。这是应该收窄的:要么从 DTO 里去掉,要么在 update 里显式忽略。」注意:usedAmount 已经正确地不在 DTO 里,所以前端改不到已用金额,这一点是对的 | 建议修(收窄可更新字段,防止业务主键被改) |
| M2-12 | 金额重算与预算扣减之间无锁,理论上申领车内容可能已变 | ”重算金额和扣预算之间,申领车被改了怎么办?“ | ⚠️ 理论成立,实际风险极低。submitOrder 在同一事务内先 shoppingCartMapper.list() 再 deduct,但申领车表没有被锁,另一个并发请求理论上可以在两者之间修改申领车内容 | 「理论上是有的:金额是按 T 时刻读到的申领车算的,扣的是这个金额,如果申领车内容在中间变了,单据明细和金额可能不完全对应。但 DELETE FROM shopping_cart WHERE user_id=? 在事务末尾执行,而同一个用户的申领车修改通常是串行的(一个用户不会真的并发改自己的车),所以实际风险很低。严格的话可以给申领车查询加 FOR UPDATE,但那会扩大锁范围、影响并发,收益不明显。」 | 不建议改(要能说清为什么可接受) |
| M2-13 | budget.dept_id 无外键、save 不校验部门是否存在 | ”给一个不存在的部门设预算会怎样?” | ✅ 成立。BudgetServiceImpl.save 只做”同部门同周期是否已存在”的查重,从不校验 deptId 在 department 表里是否存在;budget 表也没有外键。而 DepartmentServiceImpl.deleteById 只拦”部门下还有员工”,不清理该部门的预算行 | 「会插入一条孤儿预算行——dept_id 指向不存在的部门。这不是假设:项目的回归脚本第 27 节专门写了一步清理”孤儿预算行”,注释里写明”历史上就因此留下过 dept_id 指向已删部门的孤儿预算行”。根因是没有外键 + 没有应用层校验。修法是在 save 里查一次部门是否存在(能复用 DepartmentMapper.getById),并在删部门时一并处理预算;要么就正式加外键约束。」 | 建议修(这是已经真实发生过的数据问题,不是理论推演,面试官问到时能拿”回归脚本里有清理步骤”作为证据,很有说服力) |
| M2-14 | total_amount - used_amount >= amount 有精度隐患 | ”DECIMAL 相减再和入参比较,会不会有精度问题?“ | ⚠️ 理论风险。total_amount/used_amount 是 DECIMAL(12,2),而 #{amount} 由 Java BigDecimal 传入,其 scale 取决于运算结果。本次重算走的是 multiply(BigDecimal.valueOf(int)),得到的是 2 位小数,所以当前没有触发问题 | 「amount 现在是 单价(2位) × 数量(整数),结果就是 2 位小数,和列定义 DECIMAL(12,2) 一致,没问题。但这个安全是巧合而不是设计保证——如果以后金额来源变成 divide(比如打折、按比例分摊),scale 可能变成 4 位甚至无限,那时 >= 的比较就可能出现’看起来相等却判不足’的边界情况,也会让 used_amount 存进超过 2 位的小数被静默截断。稳妥做法是在预算边界上统一 setScale(2, RoundingMode.HALF_UP)。」另外要注意:used_amount 是 DECIMAL(12,2),但预算是按部门汇总的,多个部门累加不会溢出;单部门超大额(>百亿)才会,实际不可能 | 可选(当前无实际风险,但答得上来说明对 BigDecimal 和 DECIMAL 的边界有感觉) |
明确不是问题、面试官可能问但可以理直气壮回答的点
| 点 | 回答 |
|---|---|
BudgetServiceImpl 没有 @Transactional | 它是正确的:deduct / release 是单条 SQL,本身就是一个事务;save 是”查重 + 插入”,靠唯一索引兜底,也不需要跨表回滚。不要为了”看起来规范”乱加 @Transactional |
| 扣的是”已用金额”而不是真的转账 | 这是内部核算模型,不是支付系统。used_amount 是部门成本的记账口径,真实付款走线下采购流程。这个边界要说清,别说成”实现了支付” |
为什么不用 SELECT ... FOR UPDATE | 两次往返(查+改),锁持有时间包含 Java 判断;本方案一次往返、锁持有时间最短。能对比出这一点是加分 |
| 为什么不用乐观锁 | 部门预算是热点行(同部门员工同时提交),乐观锁在冲突高时会有大量失败重试 |
八、M2 关键数字与事实速查(面试前扫一眼)
| 事实 | 值 / 位置 |
|---|---|
| 建表位置 | sql/migration_office_supplies.sql 第 89-104 行 |
| 唯一索引 | uk_budget_dept_period (dept_id, period) |
| 金额类型 | DECIMAL(12,2) ↔ Java BigDecimal |
| 周期格式 | VARCHAR(7),yyyy-MM,由 DateTimeFormatter.ofPattern("yyyy-MM") 生成(BudgetServiceImpl:24) |
| 核心 SQL 1 | BudgetMapper.xml 第 31-38 行 deduct(4 个 WHERE 条件) |
| 核心 SQL 2 | BudgetMapper.xml 第 43-49 行 release(3 个 WHERE 条件) |
| 扣减入口 | OrderServiceImpl.submitOrder 第 107 行(唯一扣减点) |
| 返还入口 | releaseBudget 第 271 行,被 rejection:444、userCancelById:249、cancel:474 三处调用 |
| 返还防线 | 应用层 payStatus == PAID(第 272 行)+ SQL used_amount >= amount |
| 事务注解 | submitOrder 用 @Transactional;三个返还入口用 @Transactional(rollbackFor = Exception.class)(受检异常必须显式声明) |
| 余额算法 | SQL 派生列 (total_amount - used_amount) AS remainAmount,不是表字段 |
| 接口权限 | 设置/修改预算 @RequireRole(ADMIN);查列表 {ADMIN, PURCHASER};员工端 /user/budget/my 无注解(员工端拦截器已保证登录) |
| 回归验证 | regress_api_test.sh 第 156-158(预扣 32)、194-205(驳回返还)、207-215(撤销 + 重复撤销幂等)、217-227(超额拦截,先压低余额到 100 再提交 199 元的单) |
| 定时任务 | 无(无 @Scheduled) |
| 流水表 | 无 |
dept_id 外键 | 无(曾因此产生孤儿预算行,见 M2-13 与回归脚本第 27 节) |
| 分布式锁 | 无 |
| MQ | 无 |
九、如果只给我 3 分钟讲 M2
问题:员工提交申领单要预占部门月度额度,写成”先查余额、判断够不够、再更新”会有并发竞态——两个人同时提交,都读到”够”,最后记了两次扣减但只落一次账,这是丢失更新,账实不符。
方案:我把额度判断下推到 SQL 的 WHERE 条件里,用一条 UPDATE 同时完成判断和扣减:
UPDATE budget SET used_amount = used_amount + ? WHERE dept_id = ? AND period = ? AND status = 1 AND total_amount - used_amount >= ?业务层只看影响行数,0 就是余额不足,抛异常回滚整个事务,不产生单据。
为什么成立:
(dept_id, period)上有唯一索引,InnoDB 能定位到唯一一行加排他锁;更关键的是 MySQL 在 RR 隔离级别下 UPDATE 是当前读,会读最新已提交版本,所以第二个事务拿到锁后是在新值上重新核对条件,必然失败。返还用两道防线:单据
pay_status状态机守卫 + SQL 的used_amount >= amount兜底,防重复返还。不用分布式锁是因为:这是单库单行的并发正确性问题,不是分布式问题。InnoDB 行锁本身就保证原子性,而且随事务自动释放,没有租约和续期的复杂度。
我知道的短板:①
used_amount >= amount只防刷成负数,防不住额度充足时的并发重复返还,严格做法应该是把返还也做成UPDATE orders SET pay_status=2 WHERE id=? AND pay_status=1的条件状态迁移,用影响行数裁决;② 没有预算流水表,缺审计账本;③ 退换单的预算实际上退不了,系统里没有对应入口;④ 没有月度自动续期,靠管理员手工建。