Appearance
额度中心模块设计
额度中心是信贷域里唯一以"不变量"定义自身的模块:
used + frozen ≤ total,三者 ≥ 0,4 行表就是全部宪法。本文下探到代码级:三分额度的 DDL、无锁条件更新的并发推演、freeze→deduct 两阶段时序。与账务的最终一致边界、总控对账任务。三分模型的业务动因见信贷业务全景与领域模型,系统级职责划分见授信系统设计。
三分额度的数据结构(DDL 级)
sql
CREATE TABLE credit_account (
id BIGINT PRIMARY KEY, -- 雪花 id
customer_id BIGINT NOT NULL,
product_code VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL, -- ACTIVE / SUSPENDED / EXPIRED
total_amt DECIMAL(16,2) NOT NULL, -- 授信总额(调额流程改写)
used_amt DECIMAL(16,2) NOT NULL DEFAULT 0, -- 已用(放款 +,还款结清本金 −)
frozen_amt DECIMAL(16,2) NOT NULL DEFAULT 0, -- 冻结(支用受理 +,deduct/unfreeze −)
version BIGINT NOT NULL DEFAULT 0, -- 乐观锁(非主路径,见追问 Q3)
effective_date DATE NOT NULL,
expire_date DATE NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_cust_prod (customer_id, product_code) -- 一人一产品一额度行
);每一次额度变动都落一条流水(与账务"流水只插不改"同一铁律):
sql
CREATE TABLE credit_quota_log (
id BIGINT PRIMARY KEY, -- 雪花 id,单调递增
account_id BIGINT NOT NULL,
biz_no VARCHAR(48) NOT NULL, -- 支用单号/借据号/调额单号(对账关联回溯键)
op_type VARCHAR(16) NOT NULL, -- FREEZE / DEDUCT / UNFREEZE / RELEASE / ADJUST
delta_used DECIMAL(16,2) NOT NULL DEFAULT 0,
delta_frozen DECIMAL(16,2) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
KEY idx_account (account_id, id)
);设计要点:used/frozen 不设 UNIQUE 之外的业务索引(写热点行不做多余索引维护)。流水表 id 单调,是后面"余额连续性对账"的扫描主键。uk_cust_prod 保证一个客户一个产品永远只有一行额度,并发控制因此收敛到单行。
并发扣减:把判断压进 WHERE
扣减不是"先查余额再 UPDATE"(两步之间余额可能被并发改掉),而是把业务判断写进 WHERE,靠 InnoDB 行锁串行化:
sql
-- 可用额度 = total - used - frozen;不足(或状态/有效期非法)则影响行数为 0
UPDATE credit_account
SET used_amt = used_amt + #{amt}, version = version + 1
WHERE id = #{id} AND status = 'ACTIVE'
AND expire_date >= CURDATE()
AND total_amt - used_amt - frozen_amt >= #{amt};java
/** 直接扣减(无冻结阶段的小额场景)。返回 false = 额度不足/状态非法,绝不是异常。 */
public boolean deduct(long accountId, BigDecimal amt) {
return quotaMapper.deduct(accountId, amt) == 1;
}并发推演——可用 800,A 扣 500、B 扣 400 同时到达:
t1 事务A: UPDATE ... WHERE total-used-frozen >= 500
└─ 对该行加 X 锁(当前读),谓词成立(800≥500) → used+500,提交,锁释放
t2 事务B: UPDATE 同一行
└─ 等待 A 的行锁;A 提交后获得锁
└─ 关键:UPDATE 是当前读,重新评估 WHERE 时读的是 A 提交后的最新版本
available = 800-500 = 300 < 400 → 0 行 → 拒绝两个要点:不需要分布式锁——两个并发 UPDATE 同一行,行锁天然排队。后到者在锁释放后基于最新值重新判断,"这次扣减归谁"由影响行数一锤定音。不需要重试——条件更新失败只有一种含义:"以当前已提交状态看,扣不了"。重试再读还是 0 行,额度不足就是业务终态,直接返回失败而不是无限循环。这正是 star-site 支付单 casStatus 的同款思想(把 status = #{expectStatus} 压进 WHERE,回调并发防重)。区别只在谓词:那边是"状态谓词",这边是"守恒谓词"。
两阶段冻结:freeze → deduct 的时序
支用是两阶段的:受理时钱还没放出去。直接 used+ 会在放款失败后陷入"加回去"的困境(加回时可能已调额,见业务知识篇)。冻结段让"未决"显式存在:
sql
-- ① freeze:支用受理的本地事务内执行
UPDATE credit_account
SET frozen_amt = frozen_amt + #{amt}, version = version + 1
WHERE id = #{id} AND status = 'ACTIVE' AND total_amt - used_amt - frozen_amt >= #{amt};
-- ② deduct:放款成功确认后,冻结转已用
UPDATE credit_account
SET frozen_amt = frozen_amt - #{amt}, used_amt = used_amt + #{amt}, version = version + 1
WHERE id = #{id} AND frozen_amt >= #{amt};
-- ③ unfreeze:放款失败/超时,原路解冻
UPDATE credit_account
SET frozen_amt = frozen_amt - #{amt}, version = version + 1
WHERE id = #{id} AND frozen_amt >= #{amt};②③的 frozen_amt >= #{amt} 是幂等谓词。同一支用单的确认事件重复投递。第二多次执行时冻结余额已不够扣(或支用单状态 CAS 已不成立),0 行返回。修复动作与正常动作走同一条幂等路径,不引入新写路径。
支用单 放款域 额度账户(frozen / used) 收口
│
├─① 受理事务: freeze 800 ─────────────▶ frozen +800(守恒谓词把关)
│
├─② 立借据+放款(放款域本地事务)
│ │
│ ├─ 成功 ─▶ 支用成功事件 ─▶ ②' deduct: frozen-800, used+800
│ │ (消费前先 casStatus: LENDING→LEND_SUCCESS,仅一次)
│ └─ 失败 ─▶ 支用失败事件 ─▶ ③ unfreeze: frozen-800
│
└─③' 悬挂扫描(兜底): frozen 关联的支用单无终态 且 冻结时长 > 30min
└─ 主动查放款域终态 ─┬─ 成功 ─▶ 补走 ②'
└─ 失败 ─▶ 补走 ③事件丢失、 MQ 停用、进程崩溃,最终都汇入 ③' 悬挂扫描:frozen > 0 超过 N 分钟 本身就是巡检语义,不需要人去猜哪一步丢了。
额度事务边界:与账务的最终一致
额度与账务跨库,不做分布式大事务,用"冻结先行 + 事件驱动 + 对账兜底"收敛。关键是选对失败窗口的处理归属:
动作顺序 失败点 后果 兜底
─────────────────────────────────────────────────────────────────────────
直接 used+,放款失败后回退 回退撞调额/并发 凭空造额度/空气额度 ✗ 设计禁止
freeze → 放款 → deduct 事件 事件丢失 frozen 悬挂 悬挂扫描 ✓
freeze → 放款 → deduct 事件 事件重复投递 deduct 执行两次 frozen>=amt + 支用单 CAS ✓
freeze 成功但支用单落库失败 同一本地事务,整体回滚 无中间态 事务边界 ✓边界划定:freeze 与支用单落库同库同事务(额度中心自己的原子性)。放款域的借据+账务分录是另一个本地事务。账务是资金事实源,见账务系统设计-复式记账与日切的 Q4。两者之间只走事件,额度的 deduct/unfreeze 是事件的最终一致投影。
链路断点由两把兜底扫出。额度侧的悬挂扫描(frozen 悬挂)。对账侧的内部对账(额度 used vs 借据未还本金汇总,见幂等与对账-资金安全底座的②层)。
总控校验对账任务
额度不变量不能只靠写路径的 WHERE 谓词——旁路修数、bug、物理主从延迟都可能击穿它。日终(+ 准实时增量)跑三条总控 SQL:
sql
-- ① 宪法巡检:任何一行命中即 P0 事故,冻结额度域相关操作并告警
SELECT id FROM credit_account
WHERE used_amt < 0 OR frozen_amt < 0 OR total_amt < 0
OR used_amt + frozen_amt > total_amt;
-- ② 跨域核对:额度 used == 借据未还本金汇总(额度域是"承诺",借据域是"事实")
SELECT a.id, a.used_amt, COALESCE(SUM(l.principal_outstanding), 0) AS loan_sum
FROM credit_account a LEFT JOIN loan l
ON l.customer_id = a.customer_id AND l.product_code = a.product_code
AND l.status IN ('ACTIVE', 'OVERDUE')
GROUP BY a.id, a.used_amt
HAVING ABS(a.used_amt - loan_sum) > 0.00; -- 分为单位, 差 1 分也是差异单
-- ③ 流水连续性:期初 + Σ(delta) = 期末(防余额被绕过流水直接 UPDATE)
-- credit_quota_log 按 id 单调扫描,逐账户逐日重放,同账务"余额连续性"纵切①抓"不变量被击穿",②抓"额度与借据脱节"(事件丢失、重复扣减),③抓"状态被篡改"。三条全绿,额度中心才有资格说自己是"可用额度的唯一事实源"。对不上时产出差错单进对账差错处理模块设计的流程,额度行本身禁止自动修数。
追问链
Q1:为什么不用 SELECT FOR UPDATE(先锁再查再改)?
三步变一步,且语义更准。FOR UPDATE 锁住后的 SELECT 读到的是最新行,但"判断+写入"仍然分两条语句。中间任何异常都要小心锁释放路径。条件更新把判断和写入原子化,连接数占用、死锁面、代码量全部更小。FOR UPDATE 唯一的优势场景是"锁住后要做复杂业务计算再决定写什么"。额度扣减的判断一个谓词就写完了,用不上。
Q2:为什么不用 Redis 预扣减(decr + 回补)? Redis 扣减解决的是"单行热点吞吐",代价是引入第二个事实源。Redis 与 DB 的不一致(回补丢失、宕机窗口)每一种都是资损面。额度热点按 customer 维度天然分散——单客户的支用 TPS 极低,行锁毫秒级毫无压力。根本到不了需要预扣的量级。秒杀那种"百万并发打一行"才需要 Redis 预扣;滥用它等于花大钱买一个新的一致性问题。
Q3:有了守恒谓词,version 字段还要不要? 主路径(freeze/deduct/unfreeze/扣减)不需要——谓词已经是完整的并发控制。version 服务于两类"谓词表达不了"的场景:调额流程是"读旧值→风控复核→写新值"的读-改-写。多条 UPDATE 改 total 时用 WHERE version = #{readVersion} 防串行化丢失。对账器用它识别"两快照之间行被改过"。它是补充防线和审计锚点,不是主手段——面试里说"version 是主并发控制"反而暴露没推演过条件更新。
Q4:扣减返回 0 行,怎么给用户区分"额度不足"和"状态非法"? 0 行只是"谓词不成立",谓词是四个条件的与。回查一次该行:状态非 ACTIVE → "额度已冻结/过期"。守恒项不足 → "可用额度不足"。都不满足(主从延迟读到旧值)→ 按"额度不足"降级处理并告警。对外文案必须精确,"系统繁忙"会把额度不足的客诉升级成资损舆情。
Q5:还款释放 used 和新支用冻结 frozen 并发,不变量会不会有瞬时破坏? 不会。每条 UPDATE 的谓词求值与行修改在行锁下原子完成,不变量在语句粒度恒成立。不存在"used 先减、frozen 还没加"的中间态对其他事务可见(未提交写对当前读不可见,行锁挡住并发评估)。会出现的只是"语义上的先后":还款先提交,释放的额度立刻被新支用抢走。这是合法的,额度本来就是先到先得的池。
Q6:悬挂扫描的 30 分钟阈值怎么定,太短太长各有什么坏处? 下界 = 放款链路的 P99 时延 + 消息投递 P99 延迟(正常单不该被扫到)。上界 = 用户可感知的"额度没恢复"客诉容忍时长。太短:在途正常单被误扫,主动查单接口流量放大。太长:被冻结的可用额度迟迟不释放,用户侧"明明还款/失败了额度却少了"。生产从 30 分钟起步,按放款渠道的时延分布分渠道配置。
面试官视角小结
这一篇面试官通常从"额度并发扣减怎么实现"切入,真实考察点有三层。第一层看你是否知道"判断压进 WHERE"这个模式,并能画出两事务争锁的时序。答 SELECT FOR UPDATE 或 Redis 分布式锁的,停在第一层之下。第二层看你是否能自主推出"两阶段冻结"。从"直接扣减失败要回退,回退撞调额"这个失败窗口推到 frozen 中间态,是模块设计的分水岭。
第三层看最终一致的边界感:哪些放进本地事务、哪些走事件、事件丢了谁兜底(悬挂扫描 + 总控对账)。说不清兜底的最终一致就是"碰运气的一致"。加分项是主动对比 star-site 支付单 casStatus 的同构性——状态谓词与守恒谓词是同一个思想的两个实例。能跨域迁移说明是真懂而不是背题。
2026-10 新增(金融信贷域 · 模块设计层)。