Skip to content

额度中心模块设计 ​

额度中心是信贷域里唯一以"不变量"定义自身的模块: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 新增(金融信贷域 · 模块设计层)。