积分系统设计:账户、流水、幂等与对账
"余额不能为负、相同业务操作只形成一次已确认账务效果、余额等于流水之和"三条不变量决定表结构和事务形状。本设计与审核/机审共用版本与幂等思想;跨资源协议由跨资源事务与长流程权威讲授,热点与容量方法见容量推导与故障余量。
本文是教学设计,不代表个人上线经历。存储语义按 MySQL 8.0 / InnoDB 分析;SQL 示例需在隔离环境验证执行计划、锁等待及异常处理。
目标与题设
设计积分发放、扣减、退款和过期系统。假设有 1000 万账户,每天 2000 万笔变更,峰值 5000 次写入/秒;单账户可能被并发请求。积分使用整数单位,余额不能为负;相同业务操作重复提交只能形成一次已确认的账务效果。下游活动与通知允许延迟,账户和流水必须共同提交。
先问清退款是否跨过期批次、积分是否可以转让、历史规则是否追溯、余额读取是否要求读到本次写。这里选择不可转让,退款引用原扣减单,过期规则按授予批次冻结;写后余额走主库或具备相应一致性保证的读路径。报表、榜单与缓存不参与扣减裁决。
不变量与数据模型
三个不变量决定表和事务:余额非负;每个 (tenant_id, account_id, biz_type, biz_id) 至多一个终态业务结果;账户余额等于初始余额加所有已提交流水之和。第三项是可对账恒等式,不能只依赖一个缓存数值。
CREATE TABLE point_account (
tenant_id BIGINT NOT NULL, account_id BIGINT NOT NULL,
balance BIGINT NOT NULL, version BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (tenant_id, account_id), CHECK (balance >= 0)
);
CREATE TABLE point_operation (
tenant_id BIGINT NOT NULL, account_id BIGINT NOT NULL,
biz_type VARCHAR(24) NOT NULL, biz_id VARCHAR(64) NOT NULL,
request_hash BINARY(32) NOT NULL,
state VARCHAR(16) NOT NULL, delta BIGINT NOT NULL,
balance_after BIGINT NULL,
PRIMARY KEY (tenant_id, account_id, biz_type, biz_id)
);
CREATE TABLE point_ledger (
tenant_id BIGINT NOT NULL, account_id BIGINT NOT NULL,
biz_type VARCHAR(24) NOT NULL, biz_id VARCHAR(64) NOT NULL,
delta BIGINT NOT NULL, balance_after BIGINT NOT NULL,
PRIMARY KEY (tenant_id, account_id, biz_type, biz_id)
);request_hash 使用规范化后的账户、动作、金额、原单号等语义字段计算,不包含 traceId 或请求时间。相同幂等键、不同金额必须返回冲突,不能把“已经存在”一概当作成功。唯一键需在同一分片覆盖真实业务幂等域,JVM 内去重不能替代它。
扣减事务:沿调用链解释关键分支
应用入口为 deduct(command)。先校验身份和金额上限,再在事务中争夺业务唯一键、条件更新、写流水及 Outbox,最后提交。以下是教学伪代码,不是可直接复制的 Spring 完整实现:
BEGIN
INSERT operation(key, request_hash, state='PROCESSING', delta=-amount)
UPDATE account SET balance=balance-amount, version=version+1
WHERE tenant_id=? AND account_id=? AND balance>=amount
若 affectedRows != 1:登记 REJECTED,区分账户不存在与余额不足
否则:在本事务读取余额,写 ledger,登记 SUCCEEDED,写 outbox
COMMIT
返回已持久化的 state 与 balance_after同一账户更新由数据库行锁串行裁决;balance>=amount 在加锁更新路径检查,避免“先普通 SELECT 看够不够,再无条件 UPDATE”的竞态。唯一键冲突分支应退出当前创建路径,在明确事务边界内查询已提交业务单并核对 hash:已有终态返回原结果,尚在处理返回可查询的处理中状态。不能捕获所有异常后继续扣减,也不能在事务回滚后保留“幂等成功”缓存。
普通一致性读和加锁更新的语义不同,在 RR 下也不能把较早快照中的余额当作最新裁决。这里扣减使用条件更新,后续读需使用同一事务资源;复杂批次规则可先锁账户,再按固定顺序锁批次。对照 InnoDB 锁定读与隔离级别,推演冲突等待后条件如何重新判断。
从事务源码定位“提交了没有”
Spring Framework v6.1.12 的入口是 TransactionInterceptor.invoke → TransactionAspectSupport.invokeWithinTransaction。普通返回进入提交路径,异常按回滚规则处理;AbstractPlatformTransactionManager.commit/processCommit 再检查 rollback-only、调用同步器和实际提交,JDBC 管理器最终执行连接提交。
面试应区分方法返回、数据库提交与客户端收到响应。COMMIT 抛错不总能证明没有提交;数据库提交成功后连接断开,客户端看到的是未知结果。恢复必须按业务键查询,不能换键再次扣减。阅读固定版本事务协调实现中的 processCommit 和 cleanupAfterCompletion,区分物理提交、回调、资源清理。
故障矩阵与恢复
| 注入故障 | 权威状态 | 安全恢复 |
|---|---|---|
| 插入业务单后、更新前退出 | 未提交事务应回滚 | 同键重试,不依赖进程内标记 |
| 更新成功、流水插入失败 | 同事务整体回滚 | 检查没有孤立余额变化后同键重试 |
| COMMIT 成功、响应丢失 | 业务可能已成功 | 查同键终态,返回原结果 |
| Outbox 已发送、未标记时退出 | 下游可能收到 | 重发相同 event_id,下游去重 |
| 退款先到、原扣减不可见 | 顺序关系未满足 | 待核对,不凭缓存直接加积分 |
Outbox 与账务表同事务,解决本地提交与发送意图的双写窗口;relay 与外部消费者不共享事务。可靠投递仍需下游业务幂等、重试期限和对账。幂等键还要处理迟到请求与同键不同意图,参见 AWS 幂等 API 设计。
容量、热点与成本推导
2000 万笔/天平均约 231.5 次/秒,峰值 5000 是平均的约 21.6 倍。假设每笔账户、业务单、流水及 Outbox 逻辑写入共 1 KiB,则原始新增约 19.1 GiB/天,30 天约 572 GiB;索引、binlog、副本、备份另计,不能把这个数字直接当磁盘容量。
按账户 ID 分片可把单账户账务留在一个事务域。假设同配置验证的单分片持续能力为 800 次/秒,目标利用率 60%,则峰值需要 ceil(5000/(800×0.6))=11 个等价容量单位。这是模型计算,并非实测,也未自动包含某分片失效的容量。热点单账户仍受串行锁约束;增加分片无法消除热点。应缩短持锁事务、按账户限速或排队,观察队列等待如何消耗 deadline。
Redis 用于查询投影、限频与热点保护,扣减以账务事务为准。Redis 扣减后异步落库会增加丢写、重复补写、重放与恢复责任;必须另给账本和恢复协议,不能用“单线程”证明跨系统原子性。
过期、退款与审计
先到期先消费需要授予批次与消费分摊表。授予时冻结规则版本和 expiry,扣减按稳定顺序锁批次并登记分摊。过期只消除未消耗剩余;退款应按已确定规则退回原批次或新补偿批次,不能由定时任务临时决定。
退款有自己的幂等键并引用原单,约束累计退款不超过原扣减。使用追加冲正流水,而非修改旧流水,否则无法重建历史余额。对账采用同一截止水位的账户快照和流水,不能拿不断变化的余额与另一时刻的 SUM 比较。多租户键需贯穿授权、查询、幂等、缓存和对账;积分账务与敏感日志应分离访问权限。
验证练习
账户初始 100,两个数据库会话同时各扣 80。先以普通 SELECT 加无条件写复现错误,再以条件更新比较 affected rows;正确预期为一成功、一拒绝,最终 20。同一业务键重复 10 次只允许一条流水,改金额为 81 必须冲突。模拟“提交后客户端丢响应”,用原键查询,不新增流水。
同时检查账户、业务单终态、流水数量、Outbox 数量和锁等待;HTTP 200 不是正确性的充分证据。记录自动提交设置、隔离等级和连接边界。此处提供实验步骤及预期,没有宣称已执行 MySQL 并发实验。
账务对账协议
本页讲了三个不变量与条件更新,但对账协议只有一句"同一截止水位对账"。评审需要一张可执行的检查:同水位快照、差异分类、处置与冲正,否则"每天对账"只是报表。
对账输入:账户余额快照(水位 1,000)与流水(按 seq 不大于水位、状态为 SUCCEEDED 的条目)。示例流水有重复业务单(K1 两条)、正常扣减与加分、以及一条 REVERSED;按规则重建余额 100−80+50=70,快照余额却是 20,差异 50。差异分类统计:重复 1 条、冲正 1 条、缺失期初 0 条。差异不为零时正确动作是冻结该账户写入并调查,而不是直接改余额。
为什么要按水位:拿不断变化的余额与另一时刻的 SUM 比较,永远对不上,也说不清差异是并发写入还是错误。水位让快照与流水对应同一时刻;水位之后的新写入单独统计,不参与本次差异。
差异分类决定处置:重复(同一业务键出现两次成功)说明幂等键或唯一约束失效,要先修约束再冲正;冲正(REVERSED)是合法业务动作,不应当作差异;缺失期初说明流水起点不完整,要补基线快照。每类差异都要记录修复动作与审计,不能"手动改数字"。
执行 python3 labs/system-design/design_points_models.py ledger_reconciliation:预期输出重建余额 70、快照 20、差异 50、分类 {duplicate:1, reversed:1, missing_opening:0},并给出"冻结写入、调查后修复"的下一步。脚本只做算术与分类,不连数据库;真实并发扣减与双会话未运行。
两个合理选择:A 每日总量对账,快但只能发现总量差;B 同水位分组对账加差异分类,慢但能定位到业务键。本例选 B,并规定差异未清零前该账户的扣减走人工或降级路径。
积分单位经济与热点成本
本页算了容量与分片数,但没有把热点账户的排队成本与单笔成本写进设计。积分系统的成本结构有两个特殊点:热点账户串行裁决,单笔成本随等待上升;分片只分摊账户,不消除单个账户的热点。
容量:5,000 写/秒、最大账户占 20% 即 1,000 写/秒落在同一账户;单笔交易 8ms,则该账户的服务能力只有 1,000/8=125/秒。需求是能力的 8 倍,排队时间趋于无穷——模型输出 utilization=8.0、wait_ms=null、over=true。把分片从 11 加到 22 不会改善这个账户:同一账户的写仍然串行在同一个分片上。
热点成本的可执行结论:单账户写入必须限速或排队,超过安全等待(本例 20ms)就拒绝或转异步;业务上把高频账户拆成子账户(按业务类型)或合并批次,是改变串行度的唯一路径。扩容只对"多账户均匀"有效,对热点无效。
单笔成本口径:写路径的 CPU、存储、索引、binlog 与副本放大是固定部分;热点导致的等待与重试是可变部分。报表要分开列,否则"扩容后单笔成本下降"可能只是把热点账户的失败请求排除在分母外。
执行 python3 labs/system-design/design_points_models.py points_hotspot_cost:预期输出热点写 1,000/秒、服务能力 125/秒、利用率 8.0、等待 null、over=true。脚本只做排队近似,不模拟真实锁等待;双会话并发与死锁观察未运行。
两个合理选择:A 继续加账户分片,简单但对热点无效;B 单账户限速/排队加业务拆分子账户。本例选 B,并规定热点账户清单与限速阈值进入容量表,随季度复查。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
架构师连续追问
第一轮要求跨设备立刻看到新余额:比较读主、会话一致性与副本等待预算;TTL 缩短不能证明读到本次写。
第二轮加入异步副本切主:幂等单和余额必须共同恢复,明确成功应答的持久化与复制承诺、RPO 和丢失提交的补偿。客户端重试不能自动恢复整个被丢弃的事务。
第三轮允许账户间转让:原先单账户分片不再提供跨账户原子性。比较同分片事务、冻结/转移状态机与分布式协调,说明中间态、超时恢复和对账责任。