日期:2026-10-29 · 主题:金融系统、分布式数据库、账务与迁移
假设一个支付平台,每天处理大量订单。用户打开 App,要查看自己的交易记录;商户要按商户订单号查询支付状态;客服要通过交易号定位问题;运营和风控还要按时间、地区和状态统计全平台交易。
最初,一张订单表和一套数据库可以完成所有工作。随着数据持续增长,系统可能逐渐遇到瓶颈:索引越来越大,备份和恢复时间变长,高峰期写入争抢资源,统计查询影响实时交易。
团队开始讨论分库分表。
但金融系统的难点不只是把数据放进不同数据库。订单和账务一旦分散,还需要回答:一笔操作如何确保只生效一次?两个账户在不同分片时如何转账?数据迁移到一半发生退款怎么办?新旧库切换后如何证明没有漏账?
分库分表是一种扩展数据处理能力的方法,同时也会引入分布式系统的复杂性。金融系统尤其需要先确定业务不变量,再决定数据如何切分。
本文使用虚构支付与账务平台作为教学案例,数据规模和分片数量均为示例,不代表任何现有金融系统或性能实测。具体账务规则、清算制度、监管要求和业务保障应根据实际产品和管辖范围设计。
一、什么时候需要分库分表?
不是表中有一亿条数据,就一定需要分库分表。
数据量是信号之一,更重要的是系统是否出现了难以通过较简单方式解决的瓶颈:
| 问题 | 观察指标 | 可能的先行处理 |
|---|---|---|
| 查询慢 | 慢查询、执行计划、p95/p99 延迟 | 优化索引、SQL 和访问模式 |
| 读压力高 | CPU、连接数、读 QPS、缓存命中 | 缓存、只读副本、连接池 |
| 历史数据太大 | 索引大小、归档和备份时间 | 归档、单库按时间分区 |
| 写入成为瓶颈 | 锁等待、WAL、IO、事务耗时 | 减小事务、减少热点、优化硬件 |
| 单节点容量或恢复无法满足目标 | 容量增长、备份与恢复演练结果 | 水平分片或其他数据库架构 |
数据库分区、冷热分离和分库分表可以组合使用,但它们解决的问题不同。
分表通常指将逻辑表拆成多个物理表或分区;如果仍在同一数据库实例,很多资源和故障域仍然共享。
分库将数据分散到不同数据库实例,能够扩展存储或负载,也引入跨库路由、跨库事务和运维复杂度。
以 PostgreSQL 为例,原生分区可以让符合分区键条件的查询跳过无关分区,但分区数量过多也可能增加规划时间和内存开销。是否有收益,需要根据具体查询计划和负载验证。PostgreSQL:Table Partitioning
先问清楚瓶颈是什么,再选择方案。如果问题只是一个缺失索引,分库分表可能让它变成几十个数据库上的缺失索引。
二、先列业务操作,再选分片键
假设平台的数据包括账户、支付订单、账务分录、商户、退款和外部支付渠道事件。
主要访问方式:
| 操作 | 查询条件 | 期望 |
|---|---|---|
| 用户查看自己的订单 | user_id + 时间范围 | 快速定位 |
| 商户查询订单状态 | merchant_id + merchant_order_no | 不允许返回其他商户数据 |
| 客服查询平台订单 | order_id | 快速定位或通过可信索引定位 |
| 账户余额变更 | account_id | 同一账户并发修改保持正确 |
| 风控统计 | 时间范围 + 多个条件 | 可容忍的延迟需单独定义 |
| 渠道回调 | provider + provider_event_id | 重复通知不得重复入账 |
分片键应该从这些访问路径和事务边界中选择,而不是只追求“哈希之后看起来均匀”。
按用户分片
bucket = hash(user_id) mod 64
优点是同一用户的订单容易定位;缺点是按商户订单号或平台订单号查询时可能缺少 user_id,需要额外定位方式。
按账户分片
bucket = hash(account_id) mod 64
适合以账户为中心的余额修改与流水查询。但两个账户之间转账仍可能跨分片;同一用户拥有多个账户时,也未必处于同一分片。
按时间分区
按月或按日整理交易流水,有利于按时间查询、归档与数据生命周期管理;但最新时间段可能集中接收写入,且只知道订单号时不一定能推断时间分区。
一种常见组合是:按账户或用户进行水平路由,在每个数据库内部再按时间对流水分区。这是一个架构选项,不是所有业务的标准答案。
三、逻辑分片、物理数据库和路由版本
初学时常把“64 个分片”理解成“64 台数据库”。它们并不相同。
account_id
↓
稳定哈希
↓
逻辑 bucket 0..63
↓
路由表版本 v1
↓
物理数据库 A / B / C / D
例如每个数据库承载 16 个 bucket。未来扩容时,可以将部分 bucket 迁到新数据库,并更新路由映射,而不必把所有用户重新按新数据库数量取模。
这里的 64 和 4 个数据库只是示例。实际 bucket 数量需要结合数据规模、迁移成本、连接数和运营能力确定。
简单取模有一个容易忽略的问题:将 hash(account_id) mod 4 直接改成 mod 5,很多账户的归属都会改变。已有数据不会自动出现在新的位置,老请求、延迟回调和历史订单也不会自动知道新规则。
因此需要明确:
- 哈希算法、字符编码和 ID 规范化方式。
- 路由版本与生效时间。
- bucket 到物理库的映射。
- 历史请求、缓存和消费者如何使用新版本。
- 多个版本并存时,谁有权写入。
不同语言的默认 hash 实现可能不同,某些实现还会因进程重启而变化。用于持久路由的哈希必须有明确规范,并通过跨语言测试用例验证。
四、订单号、幂等键和全局唯一,不是一回事
金融交易中至少存在几类标识:
| 标识 | 用途 | 注意事项 |
|---|---|---|
| 平台订单号 | 标识平台内部订单 | 需要定义全局唯一和查询路径 |
| 商户订单号 | 标识商户发起的一笔业务 | 通常与商户身份一起构成业务唯一范围 |
| 幂等键 | 标识一次重试中的同一业务意图 | 同一键不同金额或对象应冲突 |
| 渠道事件号 | 标识外部通知 | 重复投递需要去重和核验 |
| 路由键 | 找到数据所在分片 | 不能自动代替业务唯一性 |
假设订单按 user_id 分库,但商户发起支付时使用 (merchant_id, merchant_order_no) 防重复。
如果只在每个用户分片上建立这个组合的唯一索引,不同用户分片仍可能各自插入同一商户订单号。分片内唯一,不等于全局唯一。
可能的方案包括:
- 将商户订单唯一性注册在按商户路由的权威位置。
- 改变订单归属,使相关唯一性与订单写入处于同一事务边界。
- 使用专门的全局注册服务,并定义它与订单服务之间的恢复协议。
这些方案各有取舍。全局注册表如果与订单分别写入,也会产生“注册成功但订单写入失败”的中间状态,必须有状态机、幂等恢复和清理规则;不能只加一个映射表就宣称跨库原子性。
PostgreSQL 声明式分区也有类似约束:分区表上的唯一或主键约束需包含全部分区键列,具体还受分区键形式限制。PostgreSQL:分区约束限制
平台订单号可以设计为全局唯一,并携带或映射到路由信息;但不能仅凭订单号格式推断它已获得授权。查询时依旧需要验证调用方是否有权访问订单。
五、金额、余额和账务不变量
分片之前,先定义金融数据的正确性条件:
一次被接受的业务意图,不应重复产生有效账务效果。
账务变更必须可追溯到对应业务事件。
账户余额与已确认分录之间必须满足约定的核对关系。
同一笔分录必须有明确币种、金额、方向和状态。
冲正通过新分录记录,不直接抹去已确认历史。
不同系统的余额定义可能包括可用余额、冻结余额、待清算余额等,不能假设一个 balance 字段就足以描述全部资金状态。
金额也不应使用普通浮点数直接做资金运算。可以使用符合币种精度要求的整数最小单位或定点数,并明确币种、舍入规则和换汇处理。跨币种分录不能把不同币种的数字直接相加来检查平衡。
同一账户的操作为什么要聚合?
假设账户余额为 100,两个并发请求都要扣 80。若两个请求都读到 100,再分别写入结果,可能出现超额扣款或丢失更新。
同一账户相关的关键写操作应由权威账务服务在同一个事务边界内使用适当并发控制。例如行锁、条件更新或乐观版本控制,具体选择取决于数据库与业务。
下面仅展示条件更新的思路:
UPDATE account
SET available_minor = available_minor - :amount,
version = version + 1
WHERE account_id = :account_id
AND currency = :currency
AND :amount > 0
AND available_minor >= :amount
AND version = :expected_version;
必须检查受影响行数,并把幂等记录、必要的账务分录和余额变更放进同一事务。上面的单条 SQL 不是完整账务实现,也没有展示冻结、权限、回滚和对账。
数据库隔离级别不是一句“开启事务”就解决所有问题。以 PostgreSQL 为例,Read Committed 与 Serializable 的行为不同,Serializable 事务还需要处理可能发生的序列化失败并安全重试。PostgreSQL:Transaction Isolation
六、跨分片转账:不能只把两次更新包进应用代码
假设账户 A 位于数据库 1,账户 B 位于数据库 2,用户发起一笔 100 元的转账。
最直觉的实现:
数据库 1:A 扣 100
数据库 2:B 加 100
如果 A 已经扣款,B 更新前服务崩溃,两个数据库可能处于不一致状态。一个应用层 try/catch 无法让两个独立数据库自动回滚到统一结果。
方案一:严格分布式事务
在环境支持且业务确实需要时,可以考虑 XA / 2PC 等方案,追求参与方的原子提交。
但要评估协调器、参与方故障恢复、锁持有时间、超时和可用性。不同中间件、数据库与部署模式的支持范围不同。例如,ShardingSphere 文档明确列出了 LOCAL、XA 等模式的具体限制,不能假设它为所有跨库故障提供相同保证。ShardingSphere:Transaction limitations
方案二:业务状态机与清算中间账户
另一类设计通过显式状态和可对账的中间账户处理跨域转账:
REQUESTED → SOURCE_POSTED → DESTINATION_POSTED → COMPLETED
↓
待恢复 / 对账
例如:源侧事务中,A 的账户分录减少 100,同一侧的清算中间账户分录增加 100;目标侧事务中,再按协议处理清算与 B 的入账。每个本地账务动作都必须符合该账本的借贷平衡规则,并关联同一转账业务 ID。
这里仅说明思路。真实系统需要定义清算账户归属、跨账本映射、失败后的资金状态、超时处理、补偿限制与人工处理路径。最终一致不等于过程中可以丢钱,也不等于可以无记录地修改余额。
两种方案都不能免除对账。选择取决于产品承诺、监管环境、吞吐、可用性和下游系统能力。
七、Outbox、重试和 UNKNOWN:外部支付接口的现实
假设系统调用外部支付渠道:
T1:提交退款请求。
T2:渠道已受理退款。
T3:网络断开,应用没有收到响应。
此时应用只知道“没有收到响应”,并不知道“退款失败”。如果立即生成新的退款请求,可能产生重复业务效果。
更合理的状态通常是:
PENDING → SUCCESS / FAILED / UNKNOWN
UNKNOWN 需要结合渠道能力处理:
- 使用稳定的业务请求 ID 或渠道支持的幂等键。
- 查询原请求状态或等待回调。
- 重试时遵守渠道的幂等范围和有效期。
- 无法确认时进入对账或人工处理。
Stripe 的幂等请求文档展示了一种为网络重试提供幂等支持的接口契约;实际能力和保留期限需要以各渠道文档为准。
事务 Outbox 可以把“本地业务记录”和“待发布事件”放进同一个数据库事务,再由后台发布。但它不保证消息只投递一次。消费者仍然要基于稳定事件 ID 做去重,并在一个事务中持久化消费结果和本地业务效果。
去重记录保存多久,也取决于消息重放、渠道补发和业务追溯窗口;过早清理可能让历史消息重新生效。
八、跨分片查询和分页:功能可用,不代表成本可控
按 account_id 分片后,用户查自己的流水通常可以定位一个分片。但运营需要查看“所有商户昨天失败的退款”,查询可能涉及全部数据库。
简单做法是并行查询所有分片,再合并结果。这会带来:
- 扇出请求放大,分片越多,下游负载越高。
- 一个慢分片拖慢整体查询。
- 全局排序和分页需要合并多个有序结果。
- 分片中途失败时,需要定义结果是不完整、失败还是可重试。
- 多分片并发读未必代表同一时刻的一致快照。
深分页的坑
假设跨 64 个分片查询第 1,000 页,直接使用 OFFSET 可能要求各分片扫描或返回大量前序数据。更可控的方式通常是设计稳定的排序键和游标分页,并明确新数据写入期间的可见性语义。
排序需要唯一的次级键,例如 (created_at, transaction_id);如果时间戳相同而没有稳定次序,翻页可能重复或漏数据。跨分片游标还可能需要保存各分片的位置或使用专门的查询索引,不能只拼一个普通页码。
对于管理报表和风控检索,可以建立基于事件流或 CDC 的查询模型;但必须说明它的更新延迟、去重、回放和权限控制。异步查询模型不能被默认当作实时资金余额的权威来源。
还有一个容易忽略的 SQL 细节:如果分片中间件无法从查询表达式提取分片键,可能触发全路由。ShardingSphere 文档举例说明了这种限制;实际支持能力应以部署版本为准。ShardingSphere:Sharding limitations
九、热点账户:分得均匀,不代表写得均匀
哈希能让普通账户数量大致分散,却不能让所有账户的交易量均匀。
一个大型商户或清算账户可能贡献大量请求。无论它落在哪个分片,热点仍然存在;如果需要对同一余额保持严格正确性,同一账户的并发更新还会争抢同一个事务资源。
常见处理思路包括隔离大客户、按业务域拆分、队列削峰、批量处理和单独容量配置。采用任何方案之前,都需要确认:
- 顺序要求是什么?
- 可用余额能否被拆分为多个独立余额?
- 批处理是否改变到账时间?
- 是否会产生超额扣款或账务不平衡?
简单地给同一余额随机加盐,分散到多个分片,再事后汇总,可能破坏“余额不能被并发重复使用”的约束。性能优化必须围绕不变量设计,而不能先拆开再假设总和一定正确。
十、历史迁移:最危险的是切换边界
从单库迁移到分库时,平台不能仅仅复制一份历史数据,然后修改数据库连接。复制期间仍然可能发生支付、退款、冲正和回调。
一种可讨论的迁移方案如下:
1. 固定新路由规则和数据版本。
2. 从一致性快照开始全量复制,记录对应的 CDC 起点。
3. 从该位置持续消费增量变更,包括更新与删除。
4. 按版本、主键与事件顺序幂等应用增量。
5. 对账:记录数、金额、状态、关键抽样和账务不变量。
6. 处理追平水位与切换屏障,阻止旧写入继续越过切换点。
7. 灰度切换读写,持续监控与核验。
8. 保留明确的回退和再次追平方案。
步骤 2 的一致性快照和 CDC 起点必须形成可靠对应;如果先复制再随意启动增量消费,窗口中的变化可能丢失。CDC 也必须覆盖删除、重试、乱序处理和目标端幂等。
为什么双写不是完整答案?
先写旧库成功,再写新库失败,两边已经不同。即使并行写,也仍有部分成功的问题。双写可以是迁移的一部分,但必须配合权威写入源、可靠日志、失败重放、对账和切换协议。
切换后为什么回退不简单?
如果新库已经接受了新交易,而旧库没有同步这些新写入,直接把流量切回旧库,就可能丢失切换后的业务事实。
回退方案必须事先说明:
- 切换后哪一侧是权威写入源?
- 新写入如何回流旧库,或如何重新追平?
- 是否需要短暂停写?
- 如何防止新旧两个写入者同时生效?
- 何时停止支持回退?
路由版本、租约和 fencing 等机制可以用于拒绝过期写入者,但前提是存储或业务服务真正校验这些版本。只在配置中心修改一个路由值,并不能阻止已经持有旧连接的进程继续写入。
十一、对账不是最后才加的报表
金融系统应从设计阶段就定义对账对象与差异处理路径。
至少需要考虑:
| 对账类型 | 目的 |
|---|---|
| 业务订单与账务 | 已确认订单是否产生正确的账务记录 |
| 内部账务与渠道 | 渠道成功、失败、处理中状态是否一致 |
| 余额与分录 | 是否符合系统定义的余额计算关系 |
| 新旧分片 | 迁移期间数据、状态与金额是否一致 |
| Outbox 与消费者 | 是否有未发布、未消费或重复处理的事件 |
对账不只是比较记录数量。两边都有 100 条记录,也可能金额、币种、状态或业务归属不一致。还要定义对账时点、水位、延迟容忍和差异归因,否则正常的异步延迟也可能被当成错误。
发现差异后,系统需要能够定位原始业务 ID、分片、渠道记录和状态流转;对已确认账务的修正应通过受控的补记或冲正机制,保留可审计历史。
十二、上线前如何验证?
下面是一组建议的故障演练,不代表本文已经在真实系统执行。
| 场景 | 验证目标 |
|---|---|
| 同一请求并发提交 | 业务效果不重复,冲突参数被拒绝 |
| 账户并发扣款 | 不违反可用余额约束 |
| 外部请求成功但响应丢失 | 进入 UNKNOWN,查询或对账后恢复 |
| 渠道回调重复和乱序 | 合法状态流转、幂等处理 |
| 某一分片不可用 | 单账户、跨分片查询与故障响应符合约定 |
| CDC 延迟、重放、删除 | 目标侧不漏、不重复,进度可追踪 |
| 切换时旧 worker 继续写 | 过期写入被可靠阻止或隔离 |
| 新库切换后执行回退 | 切换期间的新交易不被丢弃 |
| 热点账户压测 | 延迟、错误率和资金不变量同时满足目标 |
| 备份与恢复 | 在约定的恢复目标下完成演练与账务核验 |
除了平均延迟,还应观察 p95/p99、分片负载倾斜、数据库锁等待、CDC 延迟、对账差异、重复动作拒绝和 UNKNOWN 持续时间。
故障注入应使用隔离环境与模拟渠道;生产系统的演练需要符合组织既有控制流程。
结语
分库分表能够帮助系统突破单节点容量和负载限制,但它把原来由单数据库提供的某些便利,转化成需要显式设计的路由、事务、迁移和恢复问题。
金融系统中最重要的设计顺序是:
先定义钱和业务状态必须始终满足什么条件,再根据访问模式和实际瓶颈决定如何切分数据。
一个好的方案不仅能回答“订单写到哪个库”,还应能回答:网络超时后发生了什么、分片切换时是否丢失交易、重复消息是否会再次入账,以及出现差异后如何发现和修复。
参考资料
- PostgreSQL:Table Partitioning:分区裁剪、约束与分区设计限制。
- PostgreSQL:Transaction Isolation:隔离级别、并发异常与序列化失败。
- Apache ShardingSphere:Transaction limitations:事务模式的边界。
- Apache ShardingSphere:Sharding limitations:分片路由与 SQL 能力限制。
- Stripe:Idempotent requests:外部接口重试与幂等契约示例。
延伸阅读:理解 Agent Loop:状态、恢复与副作用。