项目案例

金融级支付中台架构:从单点到单元化的演进

一、业务全景:从一笔支付看完整链路

1.1 一笔扫码支付背后的 11 个系统

很多人对"支付"的理解停留在"扣钱→减库存→发货"这样的线性流程,但在金融级支付中台里,一笔扫码支付会穿越 11 个独立子系统,调用链深度超过 20 层,平均耗时 380ms。我们从最常见的线下扫码场景切入:用户在便利店扫商家二维码,输入金额 23.5 元,输入密码。看似简单的三步操作,在后端会触发"风控前置→订单创建→支付路由→账户冻结→资金扣减→积分增减→优惠券核销→库存扣减→商户结算→对账流水→审计归档"等环节。任何一环出错都意味着钱账不平——这是支付系统与其他业务系统的根本区别:交易类系统可以"事后修补",支付系统必须"账实一致、零差错、可追溯"。

子系统 核心职责 强一致要求 延迟预算
风控前置设备指纹、行为序列、规则匹配最终一致50ms
订单中心订单创建、状态机推进强一致30ms
支付路由渠道选择、费率最优、降级策略最终一致20ms
账户中心余额冻结、可用额度计算强一致40ms
资金核心复式记账、流水生成、账务平衡强一致60ms
清算对账与渠道对账、差错处理最终一致异步
合规审计大额报备、反洗钱、操作留痕强一致20ms
商户结算T+1 结算、手续费计算最终一致异步

1.2 三大不可能三角的取舍

支付中台的设计不是单纯追求"高可用"或"高性能",而是在多个互相制约的目标之间做精细的工程权衡。我把它归结为三个"不可能三角":强一致 vs 高可用(CAP 定理的直接体现——网络分区时必须二选一)、低延迟 vs 高吞吐(每次事务涉及 5-8 次数据库写,延迟和吞吐互相挤占)、监管合规 vs 系统性能(每笔交易都要落审计流水、加密存储、可追溯查询,合规成本至少吃掉 15% 的 TPS 预算)。在 15 年的支付架构实践中,我深刻认识到:架构的本质不是追求完美解,而是在约束条件下找到当下最合适的平衡点。

┌──────────────────────────────────────────────────────────────────┐
│              支付中台"三大不可能三角"取舍矩阵                        │
│                                                                    │
│    强一致 ◀──────────────────────▶ 高可用                          │
│      ▲                              ▲                              │
│      │          ╲                ╱   │                              │
│      │           ╲   支付中台   ╱    │                              │
│      │            ╲  平衡点   ╱     │                              │
│      │             ╲       ╱       │                              │
│      │              ╲   ╱         │                              │
│      │               ╳            │                              │
│      │              ╱ ╲           │                              │
│      ▼             ╱   ╲          ▼                              │
│   监管合规 ◀──────▶ 低延迟 ◀──────▶ 高吞吐                        │
│                                                                    │
│   决策原则:                                                        │
│   • 资金链路 → 强一致优先(牺牲 5% 可用性换取 100% 账实一致)         │
│   • 商户链路 → 高可用优先(牺牲 5% 一致性换取 99.99% 可用性)         │
│   • 查询链路 → 最终一致(异步补偿、读写分离、T+1 兜底)               │
└──────────────────────────────────────────────────────────────────┘

1.3 全链路调用关系图

下图展示了一笔扫码支付在生产环境中的完整调用关系。注意:账户冻结和资金扣减必须在同一个分布式事务里完成(TCC 模式),而积分增减、优惠券核销、商户结算都是"事后异步补偿"(可靠消息 + Saga)。这种"核心强一致 + 外围最终一致"的混合架构,是金融级支付系统的标准范式。

flowchart TB Start([用户扫码]) --> Pwd[输入金额和密码] Pwd --> GW[API网关 限流防重放加密] GW --> Risk{风控前置} Risk -->|通过| Order[订单中心 订单金额时间戳] Risk -->|拦截| Block[交易拒绝] Order --> Route[支付路由 选最优渠道] Route --> TCC{分布式事务TCC} TCC -->|Try| TryS[资金冻结 账户冻结额度预留流水] TCC -->|异常| Cancel[Cancel 解冻和冲正] TryS -->|成功| Confirm[Confirm 扣款 实际扣减记账] TryS -->|失败| Cancel Confirm --> MQ[可靠消息发送 MQ] MQ --> Saga[异步补偿 Saga 重试幂等补偿] Saga --> Points[积分服务] Saga --> Coupon[优惠券核销] Saga --> Settle[结算服务 T+1] Confirm --> Result([支付结果]) style Risk fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style TCC fill:#0a0e27,stroke:#7b61ff,stroke-width:2px style Confirm fill:#16213e,stroke:#00d4ff style MQ fill:#16213e,stroke:#7b61ff

💡 架构深挖点

  1. 为什么支付系统的"一致性"必须分场景讨论?什么场景下可以接受最终一致?(提示:从资金链路和商户链路的 SLA 差异切入)
  2. 如果让你设计一个支持 100 万 TPS 的支付中台,账户中心的存储方案会怎么选?纯分库分表够用吗?
  3. TCC 模式中,"Cancel"操作如果执行失败会怎样?三阶段的最大风险点是什么?

二、演进史:单点→主从→分库分表→单元化

2.1 阶段一:单库单表的"象牙塔"(2012-2014)

支付系统诞生之初,日交易量不到 10 万笔,团队 5 个人,用一套 MySQL 主库扛下了所有流量。那时候的架构简单得"令人发指":订单表、账户表、流水表全部塞在一个库里,应用层用 Spring + MyBatis 直连数据库,部署在一台 8 核 32G 的物理机上。这种架构在业务初期毫无问题——出问题了大不了重启,单库宕机了手工切流量。但随着业务增长(双十一首日交易量 200 万笔),单库的瓶颈开始集中爆发:磁盘 IO 被打满、连接数达到上限(默认 100 个)、主从延迟超过 5 秒导致读写分离失效、热点账户(某大商户日交易 50 万笔)单行锁等待严重。

┌──────────────────────────────────────────────────────────────────┐
│           阶段一:单库单表架构(2012-2014)                          │
│                                                                    │
│   ┌──────────┐                                                     │
│   │ Tomcat   │                                                     │
│   │  应用     │                                                     │
│   └────┬─────┘                                                     │
│        │ JDBC                                                      │
│        ▼                                                           │
│   ┌──────────────────────────┐                                     │
│   │    MySQL 单库 (8C32G)    │                                     │
│   │  ┌──────┐ ┌──────┐ ┌──────┐ │                                  │
│   │  │ 账户  │ │ 订单  │ │ 流水  │ │                                  │
│   │  │ table │ │ table │ │ table │ │                                  │
│   │  └──────┘ └──────┘ └──────┘ │                                  │
│   │  磁盘: SAS 600G × 2 (RAID1)│                                  │
│   │  连接数: 100 (打满)         │                                  │
│   │  QPS: 800 (极限)            │                                  │
│   └──────────────────────────┘                                     │
│                                                                    │
│   痛点:                                                            │
│   • 单库连接数成为瓶颈 (应用 30 个 + 管理 10 个 + 预留 60 个)         │
│   • 单盘 IOPS 上限 1800 (15K 转 SAS)                                │
│   • 热点账户单行更新引发严重锁等待                                   │
│   • 任何 DDL 都让整个支付系统瘫痪                                    │
└──────────────────────────────────────────────────────────────────┘

2.2 阶段二:主从分离与读写分离(2014-2016)

第一轮改造我们做了三件事:主从分离(1 主 2 从,主库写、从库读,用 binlog 同步)、读写分离(应用层通过 AbstractRoutingDataSource 动态选择数据源,强制读流量走从库)、连接池优化(Druid 替代 DBCP,连接数从 100 提升到 500,开启 PreparedStatement 缓存)。这套改造让系统支撑了 5000 TPS 的流量,但很快遇到新的问题:主从延迟。大促期间主库写入量激增,从库延迟最高达到 12 秒,导致用户付款后查询"账户余额"读到了旧值——这在金融场景是致命的。第二个问题是热点账户,某个大商户的账户行每秒被更新 2000 次,整个账户表都被锁住,所有人都在等。

阶段 时间 架构特征 峰值 TPS 核心痛点
单库单表2012-2014单 MySQL 8C32G800连接数/IO/热点账户
主从分离2014-20161主2从 + 读写分离5000主从延迟/热点账户未解
分库分表2016-201864库 × 16表 + Sharding30000分布式事务/跨库查询/运维复杂度
单元化2018-至今8单元 × 全栈独立200000+跨单元流量/数据迁移/冷启动

2.3 阶段三:分库分表的甜蜜与苦涩(2016-2018)

主从架构撑了两年后,我们进入了分库分表阶段。当时参考了阿里"裸金属+OceanBase"的方案,但团队规模和成本不允许,最终选择 Sharding-JDBC + 64 库 × 16 表的 MySQL 集群。账户表按 user_id 哈希,订单表按 order_id 哈希,流水表按 create_time 范围分片。这套架构让我们第一次冲破了 TPS 万级门槛——双十一当天最高 38000 TPS。但分库分表带来的"分布式事务问题"让我们吃尽了苦头:账户转账场景要跨两个库、订单创建要跨订单库和账户库、对账查询要跨十几个库 JOIN。我们先后尝试了 XA 两阶段提交(性能损耗 60% 直接放弃)、TCC 模式(开发复杂度极高但能落地)、最终一致 + Saga(异步补偿代码难维护)。

┌──────────────────────────────────────────────────────────────────┐
│          阶段三:分库分表架构(2016-2018)                           │
│                                                                    │
│   ┌─────────────────────────────────────────────────┐             │
│   │          应用层 (8 节点 Tomcat 集群)             │             │
│   │      Sharding-JDBC 透明分片路由                  │             │
│   └────────────────────┬────────────────────────────┘             │
│                        │                                           │
│   ┌────────────────────▼────────────────────────────┐             │
│   │           MySQL 中间件层 (Atlas)                │             │
│   │      SQL 解析 / 路由 / 归并 / 改写               │             │
│   └────────────────────┬────────────────────────────┘             │
│                        │                                           │
│   ┌────────────┬───────┴───────┬────────────┐                     │
│   ▼            ▼               ▼            ▼                     │
│  ┌────┐      ┌────┐           ┌────┐       ┌────┐                  │
│  │db_0│      │db_1│    ...    │db_62│      │db_63│                  │
│  │×16表│     │×16表│          │×16表│      │×16表│                  │
│  └────┘      └────┘           └────┘       └────┘                  │
│   ▲            ▲               ▲            ▲                      │
│   │            │               │            │                      │
│   └──── Sharding Key: user_id / order_id ────┘                     │
│                                                                    │
│   物理部署:                                                        │
│   • 4 个机房 (北京/上海/广州/深圳)                                  │
│   • 每机房 16 个库 (8 主 8 从, HA 高可用)                          │
│   • 数据总量: 120 亿条, 存储 80TB                                   │
│                                                                    │
│   痛点:                                                            │
│   • 分布式事务: 账户/订单/资金跨库, 一致性难保证                    │
│   • 跨库 JOIN: 对账查询性能崩塌, 引入 ES 宽表                       │
│   • 全局唯一 ID: 自增主键失效, 改用 Snowflake                       │
│   • 热点账户: 256 取模仍无法打散大商户                              │
│   • 容量扩展: 64 库扩到 128 库 = 全量数据重分布                      │
└──────────────────────────────────────────────────────────────────┘

2.4 阶段四:单元化——"去分布式"的终局方案(2018-至今)

2018 年双十一前,我们做了一个当时看来激进、现在看来正确的决定:从分库分表升级到单元化(Cell-based)架构。核心思路是——既然分库分表本质是"把单库拆成 N 个独立的小库",那为什么不直接"把整套支付系统复制 N 份"?这就是单元化的核心思想:每个单元都是一个完整可用的支付系统,包含应用、数据库、缓存、消息队列的全部独立副本;流量通过统一的接入层按 user_id 哈希路由到固定单元;单元内本地化处理,跨单元流量必须 < 1%。这套架构让我们的 TPS 从 3 万跳到了 20 万+,更重要的是——故障爆炸半径被限制在单元级别,单个单元故障只影响 1/N 的用户。

flowchart TB GSLB([GSLB 全局接入层
user_id % 8 = X]) subgraph CELL0["单元 0 (user%8=0)"] App0[App 应用集群] DB0[(MySQL 主从)] R0[Redis Cluster] MQ0[Kafka 集群] end subgraph CELL1["单元 1 (user%8=1)"] App1[App 应用集群] DB1[(MySQL 主从)] R1[Redis Cluster] MQ1[Kafka 集群] end subgraph CELL7["单元 7 (user%8=7)"] App7[App 应用集群] DB7[(MySQL 主从)] R7[Redis Cluster] MQ7[Kafka 集群] end subgraph CELLN["单元 N (新扩容)"] AppN[App 应用集群] DBN[(MySQL 主从)] RN[Redis Cluster] MQN[Kafka 集群] end GSLB --> CELL0 GSLB --> CELL1 GSLB --> CELL7 GSLB --> CELLN App0 --- DB0 App0 --- R0 App0 --- MQ0 App1 --- DB1 App1 --- R1 App1 --- MQ1 App7 --- DB7 App7 --- R7 App7 --- MQ7 AppN --- DBN AppN --- RN AppN --- MQN style GSLB fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style CELL0 fill:#16213e,stroke:#00d4ff style CELL1 fill:#16213e,stroke:#00d4ff style CELL7 fill:#16213e,stroke:#00d4ff style CELLN fill:#16213e,stroke:#7b61ff

💡 架构深挖点

  1. 分库分表和单元化最本质的区别是什么?为什么我说单元化是"去分布式"的?(提示:从耦合点的位置分析)
  2. 如果让你在阶段二(主从)和阶段三(分库分表)之间做架构选型,你的判断标准是什么?业务量到什么规模才需要分库分表?
  3. 从分库分表演进到单元化,最难的不是技术改造,而是哪一项?(提示:数据迁移、流量切换、灰度策略)

三、单元化架构:路由、流量与数据本地化

3.1 单元化的第一性原理

很多人把单元化简单理解为"数据库拆分的另一种形式",这是对单元化的最大误解。单元化的第一性原理是故障域隔离 + 流量本地化,数据库拆分只是实现这个目标的手段之一。一个真正的单元(Cell)应该是一个自治的、独立的、完整可用的系统实例:它有自己的应用集群、自己的数据库、自己的缓存、自己的消息队列、甚至自己的专线带宽。任意一个单元故障,其他单元不受影响;任意一个单元过载,流量可以被路由层主动调度到其他单元。这就是单元化与"分库分表 + 共享应用层"架构的根本区别——后者所有应用节点共享所有数据库,只是数据库层面做了拆分,应用的故障域仍然是全局的。

┌──────────────────────────────────────────────────────────────────┐
│               单元化的核心设计原则                                   │
│                                                                    │
│   原则一:自治性 (Autonomy)                                        │
│   ┌─────────────────────────────────────────┐                    │
│   │ 每个单元包含完整的应用 + 数据 + 中间件       │                    │
│   │ 单元间通过统一协议通信,不共享内部状态       │                    │
│   │ 单元可独立部署/升级/扩容/故障隔离            │                    │
│   └─────────────────────────────────────────┘                    │
│                                                                    │
│   原则二:本地化 (Localization)                                     │
│   ┌─────────────────────────────────────────┐                    │
│   │ 一次请求尽可能在单个单元内完成               │                    │
│   │ 跨单元调用必须 < 1%                         │                    │
│   │ 数据归属以路由键 (user_id) 为准             │                    │
│   └─────────────────────────────────────────┘                    │
│                                                                    │
│   原则三:可扩展 (Scalability)                                     │
│   ┌─────────────────────────────────────────┐                    │
│   │ 增加单元 = 线性扩容 (容量/吞吐)              │                    │
│   │ 数据按路由键重哈希自动分布                    │                    │
│   │ 缩容时数据反向回流, 过程对业务透明            │                    │
│   └─────────────────────────────────────────┘                    │
│                                                                    │
│   原则四:标准化 (Standardization)                                 │
│   ┌─────────────────────────────────────────┐                    │
│   │ 所有单元使用相同的应用镜像                    │                    │
│   │ 所有单元的库表结构完全一致                    │                    │
│   │ 单元规格统一 (CPU/内存/磁盘/带宽)            │                    │
│   │ 容量规划简单: 单元数 = 总QPS / 单元QPS       │                    │
│   └─────────────────────────────────────────┘                    │
└──────────────────────────────────────────────────────────────────┘

3.2 路由键设计:user_id hash vs range

单元化架构最关键的决策是路由键(Routing Key)的设计。我们对比过三种方案:user_id 取模(user_id % 8 = 单元号)、user_id 哈希(hash(user_id) % 8)、user_id 范围([0, 1亿) → 单元0,[1亿, 2亿) → 单元1)。

取模方式最简单,但 user_id 不连续时会数据倾斜;哈希方式分布最均匀,但扩容时数据迁移量是 (N-1)/N(从 8 单元扩到 16 单元,几乎要搬走 50% 的数据);范围方式扩容最简单(新增一个范围即可,零迁移),但热点数据会集中在某个范围。我们最终选择了一致性哈希 + 虚拟节点的方案:把 256 个虚拟节点均匀分布到物理单元上,每个 user_id 落到一个虚拟节点;扩容时只迁移 1/9 的数据(11.1%)而不是 50%。同时引入了二级路由机制——支付路由用 user_id,订单查询用 order_id(按时间分片),避免单一路由键的局限性。

路由方案 数据分布均匀性 扩容迁移比例 (8→16) 热点问题 实现复杂度 适用场景
user_id 取模中等 (依赖ID连续)87.5%严重ID 严格连续
user_id 哈希87.5%⭐⭐均衡优先
user_id 范围差 (热点集中)0%严重扩容优先
一致性哈希11.1%⭐⭐⭐弹性扩容
二级路由 (最终方案)11.1%⭐⭐⭐⭐生产级

3.3 流量路由层实现

流量路由层是单元化的"大脑",我们用 OpenResty + Lua + Redis 三件套实现了一套高性能路由网关。每个请求到达时,路由层完成四步操作:解析 user_id(从 JWT Token 或 URL 参数提取)→ 计算单元号(一致性哈希算法)→ 健康检查(剔除过载/不可用单元)→ 转发请求(HTTP/HTTPS 代理到目标单元)。整个路由过程延迟控制在 3ms 以内,对业务透明。下面是核心的路由决策代码:

-- 单元化路由层核心实现 (Lua + OpenResty)
local cjson = require "cjson.safe"
local resty_redis = require "resty.redis"
local consistent_hash = require "consistent_hash"

-- 单元化配置(动态从配置中心拉取)
local cell_config = {
    -- 256 个虚拟节点均匀分布到 8 个物理单元
    virtual_nodes = {
        [0]   = "cell-0",   [1]   = "cell-1",   [2]   = "cell-2",
        [3]   = "cell-3",   [4]   = "cell-4",   [5]   = "cell-5",
        [6]   = "cell-6",   [7]   = "cell-7",
        -- 每个物理单元对应 32 个虚拟节点
    },
    cell_endpoints = {
        ["cell-0"] = "10.0.1.10:8443",
        ["cell-1"] = "10.0.2.10:8443",
        ["cell-2"] = "10.0.3.10:8443",
        ["cell-3"] = "10.0.4.10:8443",
        ["cell-4"] = "10.1.1.10:8443",
        ["cell-5"] = "10.1.2.10:8443",
        ["cell-6"] = "10.1.3.10:8443",
        ["cell-7"] = "10.1.4.10:8443",
    },
    health_check_url = "/api/v1/health",
    circuit_breaker_threshold = 0.5,  -- 50% 错误率触发熔断
}

-- 解析用户ID(从 JWT 中提取)
local function parse_user_id()
    local auth_header = ngx.var.http_authorization
    if not auth_header then
        return nil, "missing authorization header"
    end

    local token = string.sub(auth_header, 8)  -- 去掉 "Bearer "
    local payload = jwt_decode(token)
    if not payload or not payload.user_id then
        return nil, "invalid token"
    end

    return payload.user_id
end

-- 一致性哈希计算单元号
local function get_cell_id(user_id)
    -- 使用 FNV-1a 哈希(更快且分布更均匀)
    local hash_value = fnv1a_hash(tostring(user_id))
    local virtual_index = hash_value % 256
    return cell_config.virtual_nodes[virtual_index]
end

-- 单元健康检查
local function is_cell_healthy(cell_id)
    local redis = resty_redis:new()
    redis:set_timeout(50)  -- 50ms 超时

    local ok, err = redis:connect("10.0.0.1", 6379)
    if not ok then
        ngx.log(ngx.ERR, "redis connect failed: ", err)
        return true  -- Redis 故障时降级为不熔断
    end

    -- 从 Redis 获取单元健康状态(健康检查服务每 5s 更新)
    local health_key = "cell:health:" .. cell_id
    local health_status = redis:get(health_key)
    redis:close()

    if not health_status then
        return true  -- 无数据时默认健康
    end

    local health = cjson.decode(health_status)
    return health.error_rate < cell_config.circuit_breaker_threshold
end

-- 灰度路由(按比例切流量)
local function get_cell_id_with_canary(user_id)
    local base_cell = get_cell_id(user_id)

    -- 检查是否在灰度名单中
    local canary_users = get_canary_users()  -- 内部员工、白名单
    if canary_users[user_id] then
        return "cell-canary"  -- 灰度单元
    end

    -- 按百分比灰度(如 5% 流量到新版本)
    local canary_rate = get_canary_rate("payment-service")
    if canary_rate > 0 and (fnv1a_hash(user_id .. "canary") % 100) < canary_rate then
        return "cell-canary"
    end

    return base_cell
end

-- 主入口:路由决策
function route_request()
    -- 1. 提取 user_id
    local user_id, err = parse_user_id()
    if not user_id then
        return ngx.exit(401, cjson.encode({code = "AUTH_FAIL", msg = err}))
    end

    -- 2. 计算目标单元(考虑灰度)
    local cell_id = get_cell_id_with_canary(user_id)

    -- 3. 健康检查
    if not is_cell_healthy(cell_id) then
        -- 降级到同机房备选单元
        local backup_cell = get_backup_cell(cell_id, user_id)
        ngx.log(ngx.WARN, "cell ", cell_id, " unhealthy, fallback to ", backup_cell)
        cell_id = backup_cell
    end

    -- 4. 转发请求
    local endpoint = cell_config.cell_endpoints[cell_id]
    if not endpoint then
        return ngx.exit(503, cjson.encode({code = "NO_CELL", msg = "no available cell"}))
    end

    -- 记录路由日志(用于全链路追踪)
    ngx.var.upstream_addr = endpoint
    ngx.var.cell_id = cell_id
    ngx.var.user_id = user_id

    -- 5. 代理到目标单元
    local res = ngx.location.capture("/proxy_cell", {
        args = { cell = cell_id, endpoint = endpoint }
    })
    return res
end

-- 启动路由
route_request()

3.4 数据本地化:把数据"装进"单元

路由键确定后,数据的归属也就确定了。我们制定了"三不原则":不跨单元写(任何写操作必须落到 user_id 所在单元)、不跨单元读(绝大多数读本地化,少数通过 ES 宽表/全局表访问)、不跨单元事务(单元内用本地事务,跨单元用最终一致+幂等补偿)。在数据迁移层面,我们开发了 Cell-Migrator 工具:扩容时按新路由键计算需要搬迁的用户集合,从旧单元导出数据 → 校验一致性 → 双写校验 → 切流量 → 清理旧数据,整个过程 7 天完成,对业务零感知。

sequenceDiagram autonumber participant T as 交易网关 participant TC as TCC 协调器 participant A as 付款方账户 participant B as 收款方账户 participant L as 事务日志 Note over T,L: Try 阶段 - 资源预留 T->>TC: 发起扣款请求 (orderId, amount) TC->>L: 写入事务记录 (状态: TRYING) TC->>A: Try: 冻结资金 (frozenBalance += X) A-->>TC: 冻结成功 TC->>B: Try: 预增额度 (available += X) B-->>TC: 预增成功 TC-->>T: Try 全部成功 Note over T,L: Confirm 阶段 - 实际执行 T->>TC: 确认执行 TC->>L: 更新状态 (TRYING -> CONFIRMING) TC->>A: Confirm: 实际扣款 (balance -= X, frozenBalance -= X) A-->>TC: 扣款成功 TC->>B: Confirm: 入账 (available -= X, balance += X) B-->>TC: 入账成功 TC->>L: 更新状态 (CONFIRMING -> DONE) TC-->>T: 事务完成 Note over T,L: Cancel 阶段 (任一失败时触发) T->>TC: 取消请求 TC->>L: 更新状态 (TRYING -> CANCELING) TC->>A: Cancel: 解冻资金 (frozenBalance -= X) A-->>TC: 解冻成功 TC->>B: Cancel: 回滚预增 (available -= X) B-->>TC: 回滚成功 TC->>L: 更新状态 (CANCELING -> CANCELED) TC-->>T: 事务已取消 Note over T,L: 三大边界保障
幂等性: orderId 唯一索引
空回滚: 状态机校验
悬挂: 延迟检查

3.5 跨单元流量控制:为什么必须 < 1%

单元化的一个核心 KPI 是跨单元流量占比,我们的红线是 1%。为什么会这么严格?跨单元调用本质上是通过公网/专线访问另一个单元的服务,延迟比单元内高 5-10 倍,更重要的是它会形成分布式调用——一个支付请求在 8 个单元之间穿梭,任何一个单元故障都会拖垮整个调用链。所以业务设计上必须想尽办法把跨单元调用降到最低:用户首次访问时锁定单元(避免后续切换)、跨单元数据预拉取(异步把"商户外的信息"拉到本单元)、数据冗余存储(如商户主数据全量同步到各单元的只读副本)。

跨单元场景 原始方案 本地化方案 流量占比目标
查询商户信息RPC 调用商户中心本地缓存 (5min TTL)<0.3%
跨用户转账跨单元分布式事务同步落本地 + 异步推送<0.1%
对账查询实时跨库 JOINES 宽表 + 异步构建<0.1%
全站搜索全单元数据聚合ES 集群独立存储<0.1%
商户结算实时计算T+1 异步批处理<0.05%

💡 架构深挖点

  1. 如果一个用户用同一手机号在两个浏览器登录,user_id 一致吗?如果不一致,单元化会路由到不同单元,怎么处理?(提示:思考登录态绑定和设备指纹)
  2. 一致性哈希在 8 单元扩到 16 单元时只迁移 11.1% 的数据,这 11.1% 的用户在迁移期间如何保证服务不中断?(提示:双写校验+灰度切换)
  3. 为什么我说单元化是"去分布式"的?它和微服务架构的关系是什么?

四、资金强一致:TCC + 可靠消息 + Saga 混合模式

4.1 为什么资金场景必须强一致

在电商系统里,"扣库存失败但订单已支付"是常见 bug——超卖几件商品,事后补发优惠券就能挽回。但在支付系统里,"钱已扣除但账户未到账"是绝对不能容忍的 P0 故障——用户投诉、监管处罚、品牌信任崩塌、法律责任,每一项都是致命的。这就是为什么支付系统的资金链路必须使用强一致的事务模型,而不是"先扣钱后通知"的最终一致方案。我们对比过 XA 两阶段提交、TCC、Saga、可靠消息四种方案,最终选择了TCC + 可靠消息 + Saga 混合模式——核心资金链路用 TCC(强一致),外围业务用可靠消息 + Saga(最终一致)。

┌──────────────────────────────────────────────────────────────────┐
│          四种分布式事务方案对比(支付场景适配度)                    │
│                                                                    │
│   ┌─────────────┬──────────┬──────────┬──────────┬──────────┐    │
│   │ 方案         │ 一致性    │ 性能     │ 复杂度   │ 适用场景 │    │
│   ├─────────────┼──────────┼──────────┼──────────┼──────────┤    │
│   │ XA 两阶段    │ 强一致    │ 差       │ 低       │ 小流量   │    │
│   │             │          │(-60%)    │          │ 内部系统 │    │
│   ├─────────────┼──────────┼──────────┼──────────┼──────────┤    │
│   │ TCC         │ 强一致    │ 中       │ 高       │ 资金链路 │    │
│   │             │          │(-15%)    │          │ 高频交易 │    │
│   ├─────────────┼──────────┼──────────┼──────────┼──────────┤    │
│   │ 可靠消息     │ 最终一致  │ 优       │ 中       │ 异步通知 │    │
│   ├─────────────┼──────────┼──────────┼──────────┼──────────┤    │
│   │ Saga        │ 最终一致  │ 优       │ 高       │ 长事务   │    │
│   └─────────────┴──────────┴──────────┴──────────┴──────────┘    │
│                                                                    │
│   支付系统的最终选择:                                                │
│   • 账户扣款 + 资金记账 ──→ TCC (强一致, 不能容忍一分差错)          │
│   • 订单状态推进 ──→ TCC (状态机, 强一致)                          │
│   • 积分变动 / 券核销 ──→ 可靠消息 (允许积分延迟到账)              │
│   • 跨单元转账 / 商户结算 ──→ Saga (异步补偿, 长期一致性)          │
└──────────────────────────────────────────────────────────────────┘

4.2 TCC 模式的资金扣款实现

TCC(Try-Confirm-Cancel)模式将一个分布式事务拆成三个阶段:Try 阶段预留资源(账户冻结、额度占用)、Confirm 阶段实际执行(资金扣减、流水落库)、Cancel 阶段释放资源(解冻、冲正)。TCC 的核心难点不是"Try/Confirm/Cancel 三步走"——那只是表象,真正的难点是幂等性、空回滚、悬挂问题三个边界场景。我们踩过的坑:网络超时导致 Try 重试,但实际 Try 已成功(幂等性问题);Try 未执行,Cancel 先到(空回滚);Cancel 执行后,Try 才到达(悬挂问题)。这三个问题任何一个没处理好,都可能造成资金损失。

/**
 * TCC 模式资金扣款实现(解决幂等/空回滚/悬挂三大问题)
 *
 * 核心要点:
 *   1. 每个分支事务必须记录事务状态 (INIT / TRYED / CONFIRMED / CANCELED)
 *   2. Confirm/Cancel 必须幂等 (基于事务ID去重)
 *   3. Cancel 必须先检查 Try 状态 (避免空回滚)
 *   4. Try 失败时立即 Cancel (避免悬挂)
 */
@Component
public class PaymentTccService {

    @Autowired
    private AccountDao accountDao;

    @Autowired
    private TransactionLogDao txLogDao;

    @Autowired
    private IdempotentLockService lockService;

    /**
     * Try 阶段: 冻结用户账户金额
     *
     * 关键点:
     *   - 写入事务日志, 状态 = TRYED
     *   - 增加冻结金额, 减少可用余额
     *   - 不直接扣减余额 (留给 Confirm 阶段)
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean tryFreeze(String txId, String userId, BigDecimal amount) {
        // 1. 幂等检查 (防止 Try 重试)
        TransactionLog existing = txLogDao.getByTxId(txId);
        if (existing != null && existing.getStatus() == TxStatus.TRYED) {
            log.warn("Try already executed, txId={}", txId);
            return true;  // 幂等返回成功
        }
        if (existing != null && existing.getStatus() == TxStatus.CONFIRMED) {
            throw new IllegalStateException("Transaction already confirmed");
        }

        // 2. 加分布式锁 (防止并发 Try)
        String lockKey = "tcc:try:" + txId;
        if (!lockService.tryLock(lockKey, 3000)) {
            throw new ConcurrentTryException("Try is in progress, txId=" + txId);
        }
        try {
            // 3. 校验账户余额
            Account account = accountDao.getByUserId(userId);
            if (account.getAvailableBalance().compareTo(amount) < 0) {
                throw new InsufficientBalanceException("余额不足, userId=" + userId);
            }

            // 4. 冻结金额 (可用 -X, 冻结 +X)
            int rows = accountDao.freezeBalance(userId, amount);
            if (rows != 1) {
                throw new FreezeFailedException("冻结失败, userId=" + userId);
            }

            // 5. 写入事务日志
            TransactionLog log = new TransactionLog();
            log.setTxId(txId);
            log.setUserId(userId);
            log.setAmount(amount);
            log.setStatus(TxStatus.TRYED);
            log.setTryTime(new Date());
            txLogDao.insert(log);

            log.info("TCC Try success, txId={}, userId={}, amount={}",
                     txId, userId, amount);
            return true;
        } finally {
            lockService.unlock(lockKey);
        }
    }

    /**
     * Confirm 阶段: 实际扣款 (从冻结金额中扣除)
     *
     * 关键点:
     *   - 必须幂等 (Confirm 可能被重试)
     *   - 即使 Confirm 失败, 也要通过事务协调器重试到成功
     *   - Confirm 阶段不应再抛出业务异常 (只能抛出技术异常)
     */
    @Transactional(rollbackFor = Exception.class)
    public void confirmDeduct(String txId) {
        // 1. 幂等检查
        TransactionLog txLog = txLogDao.getByTxId(txId);
        if (txLog == null) {
            throw new IllegalStateException("Try not executed, txId=" + txId);
        }
        if (txLog.getStatus() == TxStatus.CONFIRMED) {
            log.warn("Confirm already executed, txId={}", txId);
            return;  // 幂等返回
        }
        if (txLog.getStatus() == TxStatus.CANCELED) {
            throw new IllegalStateException("Transaction already canceled, txId=" + txId);
        }
        if (txLog.getStatus() != TxStatus.TRYED) {
            throw new IllegalStateException("Invalid status for confirm: " + txLog.getStatus());
        }

        // 2. 从冻结金额中实际扣减
        int rows = accountDao.confirmDeduct(txLog.getUserId(), txLog.getAmount());
        if (rows != 1) {
            throw new ConfirmFailedException("Confirm failed, txId=" + txId);
        }

        // 3. 更新事务状态
        txLog.setStatus(TxStatus.CONFIRMED);
        txLog.setConfirmTime(new Date());
        txLogDao.updateStatus(txLog);

        // 4. 写入资金流水 (复式记账: 借/贷)
        fundFlowService.recordDeduct(txLog);

        log.info("TCC Confirm success, txId={}", txId);
    }

    /**
     * Cancel 阶段: 解冻金额
     *
     * 关键点 (最重要的边界场景):
     *   - 必须检查 Try 是否执行 (空回滚防护)
     *   - 即使 Cancel 失败, 也要通过事务协调器重试到成功
     *   - Cancel 后, 不应再处理迟到的 Try (悬挂防护)
     */
    @Transactional(rollbackFor = Exception.class)
    public void cancelUnfreeze(String txId) {
        // 1. 检查事务状态
        TransactionLog txLog = txLogDao.getByTxId(txId);

        // 空回滚防护: Try 未执行, 直接返回成功
        if (txLog == null) {
            log.warn("Empty rollback detected, txId={}", txId);
            // 写入一条 CANCELED 状态日志, 防止后续 Try 形成悬挂
            TransactionLog emptyLog = new TransactionLog();
            emptyLog.setTxId(txId);
            emptyLog.setStatus(TxStatus.CANCELED);
            emptyLog.setCancelTime(new Date());
            txLogDao.insert(emptyLog);
            return;
        }

        // 幂等检查
        if (txLog.getStatus() == TxStatus.CANCELED) {
            log.warn("Cancel already executed, txId={}", txId);
            return;
        }

        if (txLog.getStatus() == TxStatus.CONFIRMED) {
            throw new IllegalStateException("Transaction confirmed, cannot cancel, txId=" + txId);
        }

        // 2. 解冻金额 (可用 +X, 冻结 -X)
        if (txLog.getStatus() == TxStatus.TRYED) {
            int rows = accountDao.unfreezeBalance(txLog.getUserId(), txLog.getAmount());
            if (rows != 1) {
                throw new CancelFailedException("Cancel unfreeze failed, txId=" + txId);
            }
        }

        // 3. 更新事务状态
        txLog.setStatus(TxStatus.CANCELED);
        txLog.setCancelTime(new Date());
        txLogDao.updateStatus(txLog);

        log.info("TCC Cancel success, txId={}", txId);
    }
}

4.3 可靠消息:异步通知的"事务消息"

积分、优惠券、消息推送这些"非资金核心"业务,不能用 TCC(开发成本太高),但又必须保证"业务执行成功 → 消息一定发出"。这就是可靠消息(事务消息)的价值:把本地事务和消息发送绑定为原子操作。RocketMQ 的事务消息机制是经典实现:半消息(消息先到 MQ 但不可消费)+ 本地事务执行(执行业务逻辑)+ 事务状态回查(MQ 主动询问业务是否成功)+ 消息投递(业务成功后才可被消费)。这套机制保证了"业务成功 → 消息必达,业务失败 → 消息必丢"。

/**
 * 可靠消息实现:支付成功后,异步通知积分/券/推送服务
 *
 * 流程:
 *   1. 发送"半消息"到 MQ
 *   2. 执行本地事务 (订单状态推进)
 *   3. 根据本地事务结果, 确认/回滚 MQ 消息
 *   4. 下游服务消费消息, 幂等处理
 */
@Service
public class PaymentEventPublisher {

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    @Autowired
    private OrderDao orderDao;

    /**
     * 发送支付成功事件 (可靠消息)
     */
    public void publishPaymentSuccess(PaymentSuccessEvent event) {
        Message<PaymentSuccessEvent> message = MessageBuilder
            .withPayload(event)
            .setHeader(RocketMQHeaders.KEYS, event.getOrderId())
            .setHeader("tx_id", event.getTxId())
            .build();

        // 1. 发送半消息 (此时消息对消费者不可见)
        SendResult sendResult = rocketMQTemplate.syncSend(
            "payment-success-topic",
            message,
            3000  // 3秒超时
        );

        if (sendResult.getSendStatus() != SendStatus.SEND_OK) {
            throw new MessageSendException("Half message send failed");
        }

        // 2. 同步执行本地事务 (订单状态推进)
        // 注意: 这里必须保证本地事务和消息确认的原子性
        // 如果本地事务失败, 通过事务回查机制让 MQ 删除半消息
    }

    /**
     * 本地事务执行器 (RocketMQ 回调)
     *
     * 该方法返回:
     *   - LocalTransactionState.COMMIT_MESSAGE → 消息对消费者可见
     *   - LocalTransactionState.ROLLBACK_MESSAGE → 消息被删除
     *   - LocalTransactionState.UNKNOW → 等待事务回查
     */
    @RocketMQTransactionListener
    public class PaymentTransactionListener implements RocketMQLocalTransactionListener {

        @Override
        public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
            try {
                PaymentSuccessEvent event = (PaymentSuccessEvent) msg.getPayload();

                // 推进订单状态 (本地事务)
                int rows = orderDao.updateStatusToPaid(
                    event.getOrderId(),
                    event.getTxId()
                );

                if (rows == 1) {
                    log.info("Local transaction committed, orderId={}", event.getOrderId());
                    return LocalTransactionState.COMMIT_MESSAGE;
                } else {
                    log.warn("Local transaction failed, orderId={}", event.getOrderId());
                    return LocalTransactionState.ROLLBACK_MESSAGE;
                }
            } catch (Exception e) {
                log.error("Local transaction error", e);
                return LocalTransactionState.UNKNOW;  // 进入回查
            }
        }

        /**
         * 事务回查 (RocketMQ 在 UNKNOW 状态时主动调用)
         *
         * 这是可靠消息的关键兜底: 哪怕本地事务执行时进程崩溃,
         * MQ 也会在 60s 后主动回查, 根据订单实际状态决定消息去向
         */
        @Override
        public LocalTransactionState checkLocalTransaction(Message msg) {
            try {
                PaymentSuccessEvent event = (PaymentSuccessEvent) msg.getPayload();

                // 查询订单实际状态
                Order order = orderDao.getById(event.getOrderId());
                if (order == null) {
                    return LocalTransactionState.ROLLBACK_MESSAGE;
                }

                if (order.getStatus() == OrderStatus.PAID) {
                    log.info("Transaction check: order paid, orderId={}", event.getOrderId());
                    return LocalTransactionState.COMMIT_MESSAGE;
                }

                if (order.getStatus() == OrderStatus.FAILED) {
                    log.info("Transaction check: order failed, orderId={}", event.getOrderId());
                    return LocalTransactionState.ROLLBACK_MESSAGE;
                }

                // 订单还在中间状态, 继续等待
                return LocalTransactionState.UNKNOW;
            } catch (Exception e) {
                log.error("Transaction check error", e);
                return LocalTransactionState.UNKNOW;
            }
        }
    }
}

💡 架构深挖点

  1. TCC 模式中,如果 Confirm 阶段因为数据库宕机一直失败,事务协调器会无限重试吗?怎么设计"重试上限 + 人工介入"的机制?(提示:补偿型事务)
  2. 可靠消息的"事务回查"机制为什么要设计成"被动"而不是"主动心跳"?这种设计有什么权衡?
  3. 如果让你设计一个支持 100 万 TPS 的支付系统,事务协调器自身怎么保证高可用?(提示:去中心化协调 vs 中心化协调)

五、异步对账:T+0 实时 + T+1 全量双轨设计

5.1 对账为什么是支付系统的"最后一道防线"

支付系统有句老话:不怕交易失败,就怕账实不平。交易失败可以重试、可以退款,但账实不平意味着系统内部记录和外部渠道(银行、第三方支付、银联)记录不一致——可能是几千万、几亿的资金差错。对账系统就是支付中台的"最后一道防线":通过与外部渠道逐笔核对交易,发现内部系统没记录到的异常交易(漏单)、与渠道不一致的交易(错单)、重复的交易(重单)。我们设计了两条对账轨道:T+0 实时对账(逐笔实时核对,5 分钟内发现差错)和 T+1 全量对账(次日凌晨全量比对,兜底实时对账的漏网之鱼)。

flowchart TB subgraph T0["T+0 实时对账 (准实时 · 分钟级)"] TX1[支付交易] --> BIN[(内部流水
MySQL + Binlog)] TX1 --> KAF[渠道流水
Kafka / 主动查询] BIN --> FLINK{Flink 流式对账引擎
双流 Join 30min 窗口} KAF --> FLINK FLINK -->|匹配成功| OK1[已对账标记] FLINK -->|未匹配| ALERT1[异常告警
5min 内通知] ALERT1 --> HANDLER1[差错处理服务] end subgraph T1["T+1 全量对账 (批量 · 次日凌晨 02:00-06:00)"] CRON[定时任务触发] --> PULL1[拉取内部流水
全量分片] CRON --> PULL2[拉取渠道对账文件
FTP / SFTP] PULL1 --> SPARK{Spark / Hive
批量对账引擎} PULL2 --> SPARK SPARK -->|全量比对| DIFF[差错分类
长款/短款/金额差/状态差] DIFF --> AUTO[自动调账
差错冲正] end style FLINK fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style SPARK fill:#0a0e27,stroke:#7b61ff,stroke-width:2px style ALERT1 fill:#16213e,stroke:#00d4ff style AUTO fill:#16213e,stroke:#7b61ff

5.2 T+0 实时对账:Flink 流式核对

实时对账的核心挑战是内外流水到达时间不一致:内部系统记录了"用户付款成功",但渠道(银联/微信/支付宝)的确认可能延迟 1-3 秒才到。我们的解法是用 Flink 做双流 Join + 状态缓存:内部流水和渠道流水进入不同的 Kafka Topic,Flink 用 order_id 作为 key 做窗口为 30 分钟的 Join(容忍渠道延迟);30 分钟内匹配的算"已对账",没匹配的进入"长款/短款"异常队列;定时器触发后调用差错处理服务。这种方案让我们把差错发现时间从 T+1 缩短到了 5 分钟以内。

差错类型 内部流水 渠道流水 可能原因 处理策略
长款 (内部有, 渠道无)渠道支付成功但未回调主动查询渠道, 确认后补单
短款 (内部无, 渠道有)渠道已收款但内部未落单主动补单, 资金入账
金额不一致10099.99渠道手续费/汇率损耗标记差异, T+1 全量核对
状态不一致SUCCESSFAILED渠道最终失败但内部已成功自动退款, 差错冲正
重复交易2 笔1 笔内部重复发单合并/退款, 加强幂等

5.3 T+1 全量对账:批次化与差错分类

T+1 全量对账是"兜底"机制——即使实时对账漏掉了某些边缘 case,全量对账也必须 100% 覆盖。每天凌晨 02:00 系统自动触发:先从各渠道拉取对账文件(CSV/XML),再用 Spark 做全量 JOIN 比对,最后生成差错报表供次日运营处理。差错分四个等级:P0(金额差错,5 分钟内人工介入)、P1(状态差错,2 小时内处理)、P2(手续费差异,T+3 内确认)、P3(信息不一致,月底前处理)。全量对账的批次化处理对延迟不敏感,但必须保证不漏不错——这是合规审计的重点检查项。

/**
 * T+1 全量对账核心逻辑 (Spark 实现)
 *
 * 处理流程:
 *   1. 拉取渠道对账文件 (ftp/sftp)
 *   2. 解析为标准结构 (DataFrame)
 *   3. 与内部流水做全量 LEFT JOIN
 *   4. 标记差错类型
 *   5. 生成差错报表 + 自动调账
 */
public class DailyReconciliationJob {

    /**
     * 主入口: 每日凌晨 02:00 触发
     */
    public void runDailyReconciliation() {
        // 1. 拉取所有渠道的对账文件
        Map<String, List<ChannelRecord>> channelFiles = fetchAllChannelFiles();

        // 2. 加载内部流水 (按日期分片)
        Dataset<Row> internalDF = spark.read()
            .format("jdbc")
            .option("url", "jdbc:mysql://...")
            .option("dbtable", "payment_flow_20260603")
            .option("partitionColumn", "id")
            .option("lowerBound", 1)
            .option("upperBound", 100000000)
            .option("numPartitions", 32)
            .load();

        // 3. 逐个渠道做对账
        for (Map.Entry<String, List<ChannelRecord>> entry : channelFiles.entrySet()) {
            String channel = entry.getKey();
            List<ChannelRecord> records = entry.getValue();

            // 转换为 DataFrame
            Dataset<Row> channelDF = spark.createDataFrame(records, ChannelRecord.class);

            // 4. 全量 LEFT JOIN (内部为准, 渠道比对)
            Dataset<Row> joined = internalDF.join(
                channelDF,
                internalDF.col("order_id").equalTo(channelDF.col("channel_order_id")),
                "full_outer"  // 全外连接, 任何一边有就列出
            );

            // 5. 分类差错
            Dataset<Row> diff = joined
                .withColumn("diff_type", when(
                    internalDF.col("order_id").isNull(), "LONG_MONEY"  // 长款
                ).when(
                    channelDF.col("channel_order_id").isNull(), "SHORT_MONEY"  // 短款
                ).when(
                    internalDF.col("amount").notEqual(channelDF.col("channel_amount")),
                    "AMOUNT_MISMATCH"  // 金额不一致
                ).when(
                    internalDF.col("status").notEqual(channelDF.col("channel_status")),
                    "STATUS_MISMATCH"  // 状态不一致
                ).otherwise("MATCHED"));  // 匹配

            // 6. 差错分类 + 优先级
            Dataset<Row> classified = diff
                .withColumn("priority", when(
                    col("diff_type").isin("LONG_MONEY", "SHORT_MONEY"), "P0"
                ).when(
                    col("diff_type").isin("AMOUNT_MISMATCH", "STATUS_MISMATCH"), "P1"
                ).otherwise("P2"))
                .withColumn("amount_diff",
                    coalesce(channelDF.col("channel_amount"), lit(0))
                    .minus(coalesce(internalDF.col("amount"), lit(0))));

            // 7. 落库差错表 + 通知
            classified.write()
                .format("jdbc")
                .option("url", "jdbc:mysql://...")
                .option("dbtable", "reconcile_diff_" + channel)
                .mode(SaveMode.Append)
                .save();

            // 8. 差错统计 + 告警
            long p0Count = classified.filter(col("priority").equalTo("P0")).count();
            if (p0Count > 0) {
                alertService.sendAlert("P0 差错告警: " + channel + " 有 " + p0Count + " 笔长款/短款");
            }
        }
    }

    /**
     * 自动调账 (处理 P3 级别差错, 如手续费差异)
     */
    public void autoAdjust(List<ReconcileDiff> diffs) {
        for (ReconcileDiff diff : diffs) {
            if (diff.getDiffType() == DiffType.AMOUNT_MISMATCH
                && Math.abs(diff.getAmountDiff().doubleValue()) < 1.0) {
                // 金额差异小于 1 元 (手续费), 自动调账
                adjustmentService.createAdjustment(diff);
            }
        }
    }
}

💡 架构深挖点

  1. 如果渠道对账文件比内部流水晚 1 天到(比如银行系统故障),T+1 全量对账当天对账不平,第二天又对账不平,怎么处理?(提示:滚动对账 + 长尾差错处理)
  2. 实时对账的"30 分钟窗口"怎么定出来的?太短会漏掉延迟订单,太长会堆积状态。这个窗口可以动态调整吗?
  3. 对账系统发现差错后,自动调账和人工调账的边界在哪里?什么情况下必须人工介入?

六、合规与审计:操作流水与防重放

6.1 监管要求:账实一致、零差错、可追溯

支付系统是强监管行业,每一行代码都受到《非银行支付机构网络支付业务管理办法》《银行卡收单业务管理办法》《反洗钱法》等法规约束。监管的核心要求是三个字:账实一致、零差错、可追溯。这要求每一笔交易必须有完整的操作流水:谁(用户ID/商户ID/操作员ID)、什么时候(精确到毫秒)、做了什么(操作类型/金额/对象)、结果如何(成功/失败/异常)。这套操作流水不仅要写入数据库(便于查询),还要写入独立的审计库(防止被篡改),并定期归档到离线存储(满足 5 年留存要求)。

监管要求 技术实现 存储周期 查询性能
账实一致复式记账 + 日终对账5 年实时
零差错TCC 强一致 + 幂等设计5 年实时
可追溯全链路 TraceID + 操作流水 5 年秒级
反洗钱大额报备 + 可疑交易监测10 年分钟级
数据安全加密存储 + 防泄漏 + 脱敏永久不直接查询
用户隐私身份证/银行卡号脱敏永久授权后查询

6.2 操作流水:金融级审计日志设计

操作流水不是普通的业务日志,它必须满足不可篡改、可验证、长期保存三大特性。我们的实现方案是WAL(Write-Ahead Log)+ 数字签名 + 链式哈希:每条操作流水生成时,先计算 SHA-256 摘要,再用 RSA 私钥签名,最后把"前一条流水哈希 + 当前流水摘要 + 时间戳"作为新区块哈希——形成类似区块链的链式结构。任何对历史流水的篡改都会导致后续哈希全部失效,监管审计时只要校验哈希链就能发现篡改。这套机制通过了等保三级和 PCI-DSS 认证。

/**
 * 金融级审计日志服务
 * 
 * 核心特性:
 *   1. 不可篡改: 链式哈希 + 数字签名
 *   2. 可验证: 监管审计时校验哈希链
 *   3. 长期保存: 5 年在线 + 10 年离线
 *   4. 高性能: 异步落库, 不阻塞业务
 */
@Service
public class AuditLogService {

    @Autowired
    private AuditLogDao auditLogDao;

    @Autowired
    private HsmSignService hsmSignService;  // 硬件签名机

    @Autowired
    private KafkaTemplate<String, AuditLog> kafkaTemplate;

    /**
     * 记录审计日志 (关键操作)
     * 
     * @param operatorId 操作员/用户ID
     * @param operationType 操作类型 (PAYMENT / REFUND / FREEZE ...)
     * @param resourceId 操作对象 (订单ID/账户ID)
     * @param details 操作详情 (JSON)
     */
    public void record(String operatorId, String operationType, 
                       String resourceId, Map<String, Object> details) {
        // 1. 构建审计日志
        AuditLog log = new AuditLog();
        log.setId(UUID.randomUUID().toString());
        log.setOperatorId(operatorId);
        log.setOperationType(operationType);
        log.setResourceId(resourceId);
        log.setDetails(JsonUtils.toJson(details));
        log.setClientIp(details.get("clientIp").toString());
        log.setDeviceId(details.get("deviceId").toString());
        log.setCreateTime(new Date());

        // 2. 获取上一条日志的哈希 (链式结构)
        String prevHash = auditLogDao.getLastHash();
        log.setPrevHash(prevHash);

        // 3. 计算当前日志的哈希
        String content = log.getId() + log.getOperatorId() 
            + log.getOperationType() + log.getResourceId()
            + log.getDetails() + log.getCreateTime().getTime() 
            + (prevHash == null ? "" : prevHash);
        String currentHash = DigestUtils.sha256Hex(content);
        log.setCurrentHash(currentHash);

        // 4. 使用 HSM 硬件签名机签名
        String signature = hsmSignService.sign(currentHash);
        log.setSignature(signature);

        // 5. 异步写入审计库 + Kafka (双写)
        kafkaTemplate.send("audit-log-topic", log.getId(), log);

        // 6. 同步等待确认 (高敏感操作)
        if (HIGH_SENSITIVITY_OPERATIONS.contains(operationType)) {
            // 大额/退款/解冻等操作必须同步落库
            auditLogDao.insertWithLock(log);
        }
    }

    /**
     * 审计日志验证 (供监管使用)
     * 
     * 验证流程:
     *   1. 查询指定时间范围的所有日志
     *   2. 重新计算每条日志的哈希
     *   3. 验证当前哈希 = 重新计算的哈希
     *   4. 验证前一条哈希 = 当前 prevHash
     *   5. 验证签名有效
     *   6. 输出验证报告
     */
    public AuditVerificationReport verify(Date from, Date to) {
        AuditVerificationReport report = new AuditVerificationReport();
        List<AuditLog> logs = auditLogDao.queryByTimeRange(from, to);
        
        String expectedPrevHash = null;
        for (AuditLog log : logs) {
            // 1. 验证签名
            if (!hsmSignService.verify(log.getCurrentHash(), log.getSignature())) {
                report.addInvalidLog(log, "签名验证失败");
                continue;
            }
            
            // 2. 验证哈希链
            if (!Objects.equals(expectedPrevHash, log.getPrevHash())) {
                report.addInvalidLog(log, "哈希链断裂");
            }
            
            // 3. 重新计算哈希
            String content = log.getId() + log.getOperatorId() 
                + log.getOperationType() + log.getResourceId()
                + log.getDetails() + log.getCreateTime().getTime() 
                + (log.getPrevHash() == null ? "" : log.getPrevHash());
            String recomputedHash = DigestUtils.sha256Hex(content);
            if (!Objects.equals(recomputedHash, log.getCurrentHash())) {
                report.addInvalidLog(log, "哈希不匹配, 数据可能被篡改");
            }
            
            expectedPrevHash = log.getCurrentHash();
        }
        
        return report;
    }
}

6.3 防重放:让"复制粘贴"的请求失效

支付系统最大的安全威胁之一是重放攻击:黑客截获一个合法的支付请求,复制粘贴到另一个时间点再次发送,系统以为是合法请求就执行——造成资金损失。我们的防重放机制是五重防护①Nonce 一次性令牌(每次请求生成唯一 ID,5 分钟内有效,已用过的拒绝)、②时间戳校验(请求时间与服务端时间差超过 60 秒拒绝)、③签名验证(HMAC-SHA256 签名,密钥按时间轮换)、④IP 设备绑定(同一令牌只能从同一 IP+设备发起)、⑤金额滑窗(同一用户 5 分钟内累计金额超过 10 倍均值触发风控)。这五重防护必须同时使用,缺一不可。

/**
 * 防重放攻击过滤器
 * 
 * 防护等级:
 *   1. Nonce 一次性令牌 (Redis SETNX, 5min TTL)
 *   2. 时间戳校验 (±60s)
 *   3. HMAC-SHA256 签名验证
 *   4. IP + 设备指纹绑定
 *   5. 金额滑窗检测
 */
@Component
public class ReplayAttackFilter extends OncePerRequestFilter {

    private static final long MAX_TIMESTAMP_DIFF = 60_000L;  // 60秒
    private static final long NONCE_TTL = 300L;  // 5分钟

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                     HttpServletResponse response,
                                     FilterChain filterChain) {
        // 1. 提取防重放相关参数
        String nonce = request.getHeader("X-Nonce");
        String timestamp = request.getHeader("X-Timestamp");
        String signature = request.getHeader("X-Signature");
        String userId = extractUserId(request);
        String clientIp = request.getRemoteAddr();
        String deviceId = request.getHeader("X-Device-Id");

        // 2. 时间戳校验
        long requestTime = Long.parseLong(timestamp);
        long now = System.currentTimeMillis();
        if (Math.abs(now - requestTime) > MAX_TIMESTAMP_DIFF) {
            throw new ReplayAttackException("时间戳超出范围: " + timestamp);
        }

        // 3. Nonce 一次性检查 (Redis SETNX)
        String nonceKey = "nonce:" + userId + ":" + nonce;
        Boolean firstUse = redisTemplate.opsForValue()
            .setIfAbsent(nonceKey, "1", Duration.ofSeconds(NONCE_TTL));
        if (Boolean.FALSE.equals(firstUse)) {
            throw new ReplayAttackException("Nonce 重复使用: " + nonce);
        }

        // 4. IP + 设备绑定校验
        String deviceKey = "device:" + userId + ":" + deviceId;
        String boundIp = redisTemplate.opsForValue().get(deviceKey);
        if (boundIp != null && !boundIp.equals(clientIp)) {
            // IP 变化, 触发风控 (但仍允许这次请求, 走人工审核)
            riskControlService.flagDeviceChange(userId, deviceId, clientIp, boundIp);
        } else if (boundIp == null) {
            // 首次绑定
            redisTemplate.opsForValue().set(deviceKey, clientIp, Duration.ofHours(24));
        }

        // 5. 签名验证
        String body = getRequestBody(request);
        String expectedSignature = HmacUtils.hmacSha256Hex(SECRET_KEY, 
            timestamp + nonce + userId + body);
        if (!expectedSignature.equals(signature)) {
            throw new ReplayAttackException("签名验证失败");
        }

        // 6. 金额滑窗检测 (附加风控)
        BigDecimal amount = extractAmount(body);
        if (amount != null) {
            String windowKey = "amount-window:" + userId;
            BigDecimal windowAmount = Optional.ofNullable(
                redisTemplate.opsForValue().get(windowKey))
                .map(BigDecimal::new)
                .orElse(BigDecimal.ZERO);
            
            if (windowAmount.add(amount).compareTo(MAX_WINDOW_AMOUNT) > 0) {
                riskControlService.flagAmountAnomaly(userId, windowAmount, amount);
            }
            
            redisTemplate.opsForValue().set(windowKey, 
                windowAmount.add(amount).toString(), 
                Duration.ofMinutes(5));
        }

        filterChain.doFilter(request, response);
    }
}

6.4 风控前置:把风险拦在交易前

风控前置是支付系统的"门神"——在交易真正执行资金扣款之前,先做一轮风险评估。我们的风控体系分为三层:规则引擎层(Drools,毫秒级匹配上千条规则)、机器学习层(XGBoost/Flink ML,对可疑行为打分)、图计算层(Neo4j,识别团伙欺诈)。三层风控并行执行,取最严格的决策。整个风控决策要求 100ms 内完成,超时的请求默认拒绝(宁可误杀不可放过)。

flowchart TB REQ([支付请求]) --> GW{风控决策网关
100ms 超时} GW --> R1["规则引擎层 (Drools)
耗时 20ms
• 黑名单
• 设备指纹
• IP 风险
• 时间窗口
• 限额规则"] GW --> R2["机器学习层 (XGBoost)
耗时 30ms
• 行为序列
• 异常检测
• 信用评分"] GW --> R3["图计算层 (Neo4j)
耗时 50ms
• 关系图谱
• 团伙识别
• 资金环路
• 关联账户"] R1 --> SUM{风控决策汇总
取最严格的决策} R2 --> SUM R3 --> SUM SUM -->|通过| PASS([PASS]) SUM -->|可疑| REVIEW([REVIEW]) SUM -->|拒绝| REJECT([REJECT]) style REQ fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style GW fill:#0a0e27,stroke:#7b61ff,stroke-width:2px style R1 fill:#16213e,stroke:#00d4ff style R2 fill:#16213e,stroke:#7b61ff style R3 fill:#16213e,stroke:#00d4ff style SUM fill:#0a0e27,stroke:#7b61ff,stroke-width:2px style PASS fill:#0a0e27,stroke:#00ff00,stroke-width:2px style REJECT fill:#0a0e27,stroke:#ff4444,stroke-width:2px style REVIEW fill:#0a0e27,stroke:#ffaa00,stroke-width:2px

💡 架构深挖点

  1. 为什么审计日志要"链式哈希"而不是简单的数据库唯一约束?前者防的是内部人员的篡改,后者防的是普通用户——两者威胁模型有什么不同?
  2. 防重放的 Nonce 机制如果 Redis 故障了怎么办?降级方案是什么?直接放行还是有其他补救?
  3. 风控决策的"100ms 超时"是硬性要求,但图计算层经常超过这个时间,怎么设计超时降级策略?

七、容灾设计:跨机房双活与故障自愈

7.1 容灾的四个 9 与两个 9

支付系统对可用性的要求是四个 9(99.99%),全年不可用时间不超过 52.6 分钟。听起来很短,但实际生产中光是每月一次发布、每季度一次机房演练就要吃掉 30 分钟的预算。真正的容灾设计不是"追求 100% 可用"——那是不存在的——而是把故障爆炸半径降到最小,把恢复时间降到最短。我们的容灾标准是:RTO(恢复时间目标)< 30 秒,RPO(数据丢失目标)= 0。RTO=30 秒意味着用户最多感知 30 秒的卡顿/失败;RPO=0 意味着任何故障都不能丢钱(一分钱都不能丢)。

容灾等级 RTO RPO 实现方式 成本 适用场景
Level 1 (本地 HA)< 1min0主从自动切换单机故障
Level 2 (同城双活)< 5min0同城双机房 + 负载均衡机房级故障
Level 3 (异地灾备)< 30min< 1min异地冷备 + 异步复制城市级灾难
Level 4 (异地多活)< 30s0异地多活 + 单元化极高支付核心
Level 5 (异地双活+)< 5s0全栈单元化 + 自动化切换最高金融级

7.2 单元化天生就是异地多活

很多人以为异地多活需要单独设计,其实单元化架构天生就支持异地多活——单元本身就包含了完整可用的应用+数据副本,可以部署在任何城市。我们的生产部署是三地五中心:北京(2 单元 + 1 单元冷备)、上海(3 单元)、广州(3 单元),任何一地故障都能在 30 秒内切换到其他城市。每个单元都有独立的专线(运营商级 SDH),专线故障时降级到公网(延迟增加 20ms 但不影响业务)。

flowchart TB subgraph BJ["北京 (2 单元)"] BJ0["BJ-0
user%8={0,3}
主流量 60%"] BJ1["BJ-1
user%8={1,2}
主流量 40%"] end subgraph SH["上海 (3 单元)"] SH0["SH-0
user%8={4,5,6,7}
异地灾备 100%"] SH1["SH-1 (备份)"] SH2["SH-2 (备份)"] end subgraph GZ["广州 (3 单元)"] GZ0["GZ-0
异地灾备 100%"] GZ1["GZ-1 (备份)"] GZ2["GZ-2 (备份)"] end BJ0 <-->|专线 100ms RTT| BJ1 BJ0 -.Binlog 同步.-> SH0 BJ0 -.Binlog 同步.-> GZ0 SH0 -.Binlog 同步.-> GZ0 SH1 --- SH0 SH2 --- SH0 GZ1 --- GZ0 GZ2 --- GZ0 style BJ0 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style BJ1 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style SH0 fill:#16213e,stroke:#7b61ff style GZ0 fill:#16213e,stroke:#7b61ff

故障切换策略

  • 单个单元故障 → 30s 内路由层切到同城备单元
  • 同城机房整体故障 → 60s 内切到异地单元
  • 城市级灾难 → 120s 内切到其他城市单元
  • 任意切换 RPO = 0(Binlog 同步 + 校验)

7.3 数据库同步:Binlog + 校验

异地多活的最大挑战是数据库同步。我们用 MySQL Binlog + 自研同步中间件 Cell-Replicator 实现异地同步:主单元的 Binlog 实时同步到备单元,备单元重放 Binlog 保持数据一致。但 Binlog 同步有几个棘手问题:主键冲突(两个单元同时插入相同主键)、延迟丢数据(主单元 Binlog 没同步到备单元就故障)、循环复制(备单元同步到主单元形成死循环)。我们通过单元化主键策略(user_id 末 3 位作为主键前缀,不同单元前缀不同)、半同步复制(主库必须收到备库 ACK 才返回)、复制拓扑管理(主→备单向,禁止反向)解决了这三大问题。

/**
 * 单元化 Binlog 同步中间件 (Cell-Replicator)
 * 
 * 核心要点:
 *   1. 单元化主键: 主键包含单元号, 避免冲突
 *   2. 半同步: 主库等待备库 ACK 才返回
 *   3. 单向复制: 防止循环
 *   4. 自动重试: 断网时本地缓存 Binlog
 *   5. 一致性校验: 定时全量比对
 */
public class CellReplicator {

    /**
     * Binlog 拉取 + 同步
     */
    public void replicateFromMaster(String masterUrl, String slaveUrl) {
        // 1. 作为 Slave 连接 Master (只读 Binlog)
        BinaryLogClient masterClient = new BinaryLogClient(masterUrl);
        masterClient.setSlaveId(uniqueSlaveId);  // 每个单元唯一
        masterClient.setServerId(serverId);

        // 2. 注册 Binlog 事件监听器
        masterClient.registerEventListener(event -> {
            if (event.getEventType() == EventType.WRITE_ROWS 
                || event.getEventType() == EventType.UPDATE_ROWS
                || event.getEventType() == EventType.DELETE_ROWS) {
                
                // 3. 过滤: 只同步本单元的数据
                for (Row row : event.getRows()) {
                    if (isBelongToThisCell(row)) {
                        // 4. 写入本地 (异步批量)
                        pendingWrites.add(row);
                    }
                }
            }
        });

        // 5. 异步批量写入 (合并多次写入, 减少网络往返)
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(4);
        executor.scheduleAtFixedRate(() -> {
            List<Row> batch = drainPendingWrites(1000);
            if (!batch.isEmpty()) {
                applyToSlave(slaveUrl, batch);
            }
        }, 0, 100, TimeUnit.MILLISECONDS);  // 每 100ms 批量写入一次

        // 6. 启动同步
        masterClient.connect();
    }

    /**
     * 判断这条数据是否属于本单元
     * 
     * 单元化主键: user_id % 8 == this_cell_id
     * 同步策略: 只同步本单元的数据, 避免重复和冲突
     */
    private boolean isBelongToThisCell(Row row) {
        // 假设表结构: order_id, user_id, amount, status, ...
        long userId = row.get("user_id", Long.class);
        int cellId = (int) (userId % 8);
        return cellId == thisCellId;
    }

    /**
     * 一致性校验 (每小时一次)
     */
    public ConsistencyReport verifyConsistency() {
        ConsistencyReport report = new ConsistencyReport();
        
        // 1. 拉取主库和备库的记录数
        long masterCount = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM payment_flow", Long.class);
        long slaveCount = slaveJdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM payment_flow", Long.class);
        
        if (masterCount != slaveCount) {
            report.addIssue("行数不一致: master=" + masterCount + ", slave=" + slaveCount);
        }

        // 2. 抽样校验 (每 1000 条校验一次)
        List<String> sampleIds = jdbcTemplate.queryForList(
            "SELECT order_id FROM payment_flow TABLESAMPLE SYSTEM(0.1)", String.class);
        for (String id : sampleIds) {
            PaymentFlow masterRecord = jdbcTemplate.queryForObject(
                "SELECT * FROM payment_flow WHERE order_id=?", 
                new Object[]{id}, 
                (rs, rowNum) -> mapRow(rs));
            PaymentFlow slaveRecord = slaveJdbcTemplate.queryForObject(
                "SELECT * FROM payment_flow WHERE order_id=?",
                new Object[]{id},
                (rs, rowNum) -> mapRow(rs));
            
            if (!Objects.equals(masterRecord.getAmount(), slaveRecord.getAmount())) {
                report.addIssue("金额不一致: orderId=" + id 
                    + ", master=" + masterRecord.getAmount() 
                    + ", slave=" + slaveRecord.getAmount());
            }
        }

        return report;
    }
}

7.4 故障自愈:从"人工运维"到"无人值守"

传统运维是"故障告警 → 人工介入 → 排查 → 恢复",平均恢复时间 10-30 分钟。金融级支付系统要求故障自愈——系统自动检测、自动决策、自动恢复。我们的故障自愈框架基于健康检查 + 决策引擎 + 执行器三层架构:健康检查每 5 秒探活所有单元,决策引擎根据预设策略判断"是否需要切换",执行器负责流量切换和数据校验。整个过程无需人工介入,恢复时间 5-30 秒。

/**
 * 故障自愈协调器
 * 
 * 自愈策略库:
 *   - 单元级故障: 5s 内检测, 30s 内切流量
 *   - 数据库故障: 切换主从, 重新同步
 *   - 网络分区: 启用公网降级
 *   - 资源耗尽: 限流 + 排队
 */
@Service
public class FaultSelfHealer {

    @Autowired
    private HealthCheckService healthCheckService;

    @Autowired
    private RouteConfigService routeConfigService;

    @Autowired
    private AlertService alertService;

    /**
     * 故障自愈主循环 (每 5 秒执行)
     */
    @Scheduled(fixedDelay = 5000)
    public void detectAndHeal() {
        // 1. 健康检查所有单元
        Map<String, CellHealth> healthMap = healthCheckService.checkAll();
        
        for (Map.Entry<String, CellHealth> entry : healthMap.entrySet()) {
            String cellId = entry.getKey();
            CellHealth health = entry.getValue();
            
            // 2. 判断故障等级
            FaultLevel level = classifyFault(health);
            if (level == FaultLevel.HEALTHY) {
                continue;
            }
            
            // 3. 决策自愈策略
            HealStrategy strategy = decideStrategy(cellId, level);
            
            // 4. 执行自愈
            executeStrategy(strategy);
            
            // 5. 告警通知 (即使是自愈, 也要通知运维)
            alertService.notifyHealing(cellId, level, strategy);
        }
    }

    /**
     * 故障分级
     */
    private FaultLevel classifyFault(CellHealth health) {
        // P0: 单元完全不可用 (探活失败)
        if (!health.isReachable()) {
            return FaultLevel.P0;
        }
        // P1: 错误率超过 50%
        if (health.getErrorRate() > 0.5) {
            return FaultLevel.P1;
        }
        // P2: 错误率超过 10% 或 延迟超过 2x
        if (health.getErrorRate() > 0.1 || health.getAvgLatency() > 200) {
            return FaultLevel.P2;
        }
        // P3: 资源使用率超过 80%
        if (health.getCpuUsage() > 0.8 || health.getMemoryUsage() > 0.8) {
            return FaultLevel.P3;
        }
        return FaultLevel.HEALTHY;
    }

    /**
     * 决策自愈策略
     */
    private HealStrategy decideStrategy(String cellId, FaultLevel level) {
        switch (level) {
            case P0:
                // 单元完全不可用: 立即切走所有流量
                return new HealStrategy(
                    HealAction.ISOLATE_CELL,  // 隔离故障单元
                    List.of(getBackupCells(cellId)),  // 切到备单元
                    Duration.ofSeconds(30)
                );
            case P1:
                // 错误率过高: 切走 50% 流量
                return new HealStrategy(
                    HealAction.PARTIAL_DRAIN,
                    List.of(getBackupCells(cellId)),
                    Duration.ofSeconds(60)
                );
            case P2:
                // 降级: 限流到 50%
                return new HealStrategy(
                    HealAction.RATE_LIMIT,
                    List.of(),
                    Duration.ofSeconds(120)
                );
            case P3:
                // 资源紧张: 触发弹性扩容
                return new HealStrategy(
                    HealAction.AUTO_SCALE,
                    List.of(),
                    Duration.ofSeconds(180)
                );
            default:
                return HealStrategy.NO_ACTION;
        }
    }

    /**
     * 执行自愈策略
     */
    private void executeStrategy(HealStrategy strategy) {
        switch (strategy.getAction()) {
            case ISOLATE_CELL:
                // 1. 从路由层移除该单元
                routeConfigService.removeCell(strategy.getCellId());
                // 2. 等待 30s, 观察新流量
                sleep(30);
                // 3. 如果单元恢复, 加回路由
                if (healthCheckService.isHealthy(strategy.getCellId())) {
                    routeConfigService.addCell(strategy.getCellId());
                }
                break;
            case PARTIAL_DRAIN:
                routeConfigService.setTrafficRatio(strategy.getCellId(), 0.5);
                break;
            case RATE_LIMIT:
                routeConfigService.setRateLimit(strategy.getCellId(), 0.5);
                break;
            case AUTO_SCALE:
                k8sService.scaleUp(strategy.getCellId(), 1);  // 增加 1 个 Pod
                break;
        }
    }
}

💡 架构深挖点

  1. 异地多活最大的隐患是"脑裂"——主备都以为对方挂了,都开始接收写请求。怎么用单元化避免脑裂?(提示:单元归属是确定的)
  2. 如果 Binlog 同步延迟 30 秒,主库故障时这 30 秒内的交易会丢失吗?怎么做到 RPO=0?
  3. 故障自愈做错决定(误判健康单元为故障)怎么办?怎么设计"自愈也可以被回滚"的机制?

八、灰度与回滚:单元级蓝绿发布

8.1 为什么不能用 K8s 滚动发布

很多人觉得支付系统发布就是 K8s 滚动更新——这其实非常危险。K8s 滚动更新是"先起新版本 Pod,再停老版本 Pod",过渡期新老版本同时存在。但支付系统有两个特殊性:①版本不兼容(数据库表结构变化,老版本代码读新版本写入的数据可能报错)、②故障爆炸半径大(如果新版本有 Bug,滚动更新会一台一台把流量切过去,等发现问题时已经切了一半了)。我们用单元级蓝绿发布:新版本先部署到独立的"灰度单元",5% 真实流量验证,确认无误后逐步扩大到 25% → 50% → 100%,任意一步发现异常立即回滚到 100% 老版本。

flowchart LR S0([T+0 启动]) --> P1[阶段1: 5% 灰度
灰度单元 + 7 老单元] P1 -->|15min 验证通过| P2[阶段2: 25% 灰度
灰度单元 + 2 老单元] P2 -->|15min 验证通过| P3[阶段3: 50% 灰度
灰度单元 + 1 老单元] P3 -->|30min 验证通过| P4[阶段4: 75% 灰度
3 灰度 + 1 老单元] P4 -->|1h 验证通过| P5[阶段5: 100% 全量
全部灰度单元] P5 -->|1h 持续观察| DONE([T+3h 发布完成]) P1 -.->|任意阶段失败| RB[立即回滚] P2 -.->|任意阶段失败| RB P3 -.->|任意阶段失败| RB P4 -.->|任意阶段失败| RB P5 -.->|任意阶段失败| RB RB --> RBO([30s 内 100% 切回老版本]) style S0 fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style DONE fill:#0a0e27,stroke:#00d4ff,stroke-width:2px style RBO fill:#0a0e27,stroke:#7b61ff,stroke-width:2px style P1 fill:#16213e,stroke:#00d4ff style P2 fill:#16213e,stroke:#00d4ff style P3 fill:#16213e,stroke:#7b61ff style P4 fill:#16213e,stroke:#7b61ff style P5 fill:#16213e,stroke:#7b61ff style RB fill:#ff4444,stroke:#ff0000,stroke-width:2px

五阶段验证点

  • 阶段1(5%):基础功能 / 支付成功率 / 对账准确
  • 阶段2(25%):性能压测 / 资金一致性 / 并发安全
  • 阶段3(50%):大额交易 / 退款 / 冲正 / 对账
  • 阶段4(75%):边界场景 / 异常处理
  • 阶段5(100%):灰度单元持续运行 1h, 观察长尾问题

8.2 灰度路由配置:动态流量调度

灰度发布的核心是动态流量调度——发布系统向路由层推送灰度配置,路由层按 user_id 哈希或白名单决定流量走新版本还是老版本。我们用双层灰度机制:用户灰度(按 user_id 范围,如 1%-5% 用户先用新版本)和流量灰度(按请求比例,如 5% 随机请求走新版本)。两套机制可以独立配置,也可以叠加使用:先选用户再选比例,确保灰度用户每次请求都走新版本,避免"老用户新版本"和"新用户老版本"的体验混乱。

/**
 * 灰度发布管理器
 * 
 * 灰度策略:
 *   1. 白名单灰度: 指定 user_id 列表优先体验
 *   2. 比例灰度: 按 user_id 哈希取模
 *   3. 时间窗口灰度: 每天仅特定时间启用新版本
 *   4. 业务灰度: 仅特定业务类型走新版本 (如只灰度支付, 不灰度退款)
 */
@Service
public class GrayscaleReleaseService {

    @Autowired
    private RouteConfigService routeConfigService;

    @Autowired
    private ApolloConfig apolloConfig;

    /**
     * 启动灰度发布
     * 
     * @param serviceName 服务名 (如 payment-core)
     * @param newVersion 新版本号
     * @param strategy 灰度策略
     */
    public void startGrayscale(String serviceName, String newVersion, GrayscaleStrategy strategy) {
        // 1. 部署灰度单元 (Kubernetes 自动扩缩)
        k8sService.deployGrayscaleUnit(serviceName, newVersion);

        // 2. 配置灰度路由规则
        RouteConfig config = new RouteConfig();
        config.setServiceName(serviceName);
        config.setNewVersion(newVersion);
        config.setStrategy(strategy);
        
        // 3. 推送配置到 Apollo (路由层会动态加载)
        apolloConfig.publish("grayscale-" + serviceName, config);

        log.info("Grayscale started: {} → {}", serviceName, newVersion);
    }

    /**
     * 推进灰度进度
     */
    public void advance(String serviceName, int targetPercent) {
        GrayscaleStrategy strategy = apolloConfig.get("grayscale-" + serviceName);
        strategy.setPercent(targetPercent);
        apolloConfig.publish("grayscale-" + serviceName, strategy);
        
        log.info("Grayscale advanced to {}%: {}", targetPercent, serviceName);
    }

    /**
     * 回滚灰度
     */
    public void rollback(String serviceName, String reason) {
        // 1. 立即 100% 切回老版本
        GrayscaleStrategy strategy = apolloConfig.get("grayscale-" + serviceName);
        strategy.setPercent(0);
        apolloConfig.publish("grayscale-" + serviceName, strategy);

        // 2. 关闭灰度单元
        k8sService.shutdownGrayscaleUnit(serviceName);

        // 3. 通知相关人员
        alertService.sendAlert("灰度回滚: " + serviceName + ", 原因: " + reason);

        log.warn("Grayscale rolled back: {}, reason: {}", serviceName, reason);
    }
}

/**
 * 路由层灰度决策 (在 route_request Lua 脚本中调用)
 */
public class GrayscaleRouter {

    public static String decideRoute(String userId, String serviceName, GrayscaleStrategy strategy) {
        // 1. 白名单优先
        if (strategy.getWhitelist() != null && strategy.getWhitelist().contains(userId)) {
            return strategy.getNewVersion();
        }

        // 2. 业务类型灰度 (仅特定业务走新版本)
        String bizType = getCurrentBizType();
        if (strategy.getBizTypes() != null && !strategy.getBizTypes().contains(bizType)) {
            return strategy.getOldVersion();
        }

        // 3. 比例灰度
        if (strategy.getPercent() <= 0) {
            return strategy.getOldVersion();
        }
        if (strategy.getPercent() >= 100) {
            return strategy.getNewVersion();
        }

        // 4. 按 user_id 哈希取模, 保证同一用户每次都走同一版本
        int hash = Math.abs(userId.hashCode() % 100);
        if (hash < strategy.getPercent()) {
            return strategy.getNewVersion();
        }
        return strategy.getOldVersion();
    }
}
灰度维度 实现方式 粒度 适用场景
白名单Apollo 配置 + Lua 判断用户级内部员工 / VIP 用户
比例灰度user_id hash % 100请求级通用灰度
业务灰度按 biz_type 区分业务级高风险业务先灰度
地域灰度按用户地域地域级特定地区试点
时间窗口按时段配置时间级仅低峰期灰度

8.3 灰度验证:自动化冒烟与资金对账

灰度发布的"验证"环节至关重要。我们的验证流程包括自动化冒烟测试(核心接口全量跑一遍)、资金一致性校验(新版本和老版本并行处理同一笔交易,对比结果)、异常注入测试(主动注入超时/失败,验证降级逻辑)、回滚演练(模拟触发回滚,验证 30 秒内完成)。任何一项验证失败都会自动触发回滚。

/**
 * 灰度验证服务
 * 
 * 验证项:
 *   1. 自动化冒烟测试 (支付/退款/查询/对账)
 *   2. 资金一致性对比 (新旧版本并行处理)
 *   3. 异常注入 (超时/失败/数据异常)
 *   4. 性能压测 (验证性能不退化)
 *   5. 监控指标 (错误率/延迟/成功率)
 */
@Service
public class GrayscaleVerificationService {

    /**
     * 灰度验证主流程
     */
    public VerificationResult verify(String serviceName, String version) {
        VerificationResult result = new VerificationResult();

        // 1. 自动化冒烟测试
        result.setSmokeTest(doSmokeTest(serviceName, version));

        // 2. 资金一致性对比
        result.setFundConsistency(verifyFundConsistency(serviceName, version));

        // 3. 异常注入
        result.setChaosTest(doChaosTest(serviceName, version));

        // 4. 性能压测
        result.setPerformanceTest(doPerformanceTest(serviceName, version));

        // 5. 综合判定
        result.setPass(result.allPassed());

        return result;
    }

    /**
     * 资金一致性对比 (双写验证)
     * 
     * 让新版本和老版本同时处理同一笔交易, 资金结果必须完全一致
     */
    private boolean verifyFundConsistency(String serviceName, String version) {
        List<String> testOrders = generateTestOrders(100);  // 100 笔测试订单
        int mismatchCount = 0;

        for (String orderId : testOrders) {
            // 同时调用新版本和老版本
            PaymentResult oldResult = callOldVersion(orderId);
            PaymentResult newResult = callNewVersion(orderId);

            // 对比资金结果
            if (!Objects.equals(oldResult.getAmount(), newResult.getAmount()) 
                || !Objects.equals(oldResult.getStatus(), newResult.getStatus())) {
                mismatchCount++;
                log.error("资金不一致: orderId={}, old={}, new={}", 
                    orderId, oldResult, newResult);
            }
        }

        return mismatchCount == 0;
    }

    /**
     * 异常注入测试
     */
    private boolean doChaosTest(String serviceName, String version) {
        // 1. 注入数据库超时
        chaosService.injectDbTimeout(serviceName, version, 0.3);  // 30% 概率超时
        sleep(30);
        // 验证: 系统应该降级, 不应出现 P0 故障
        if (errorRateExceeded(0.05)) return false;

        // 2. 注入下游服务失败
        chaosService.injectDownstreamFailure(serviceName, version, 0.5);
        sleep(30);
        if (errorRateExceeded(0.1)) return false;

        // 3. 注入网络分区
        chaosService.injectNetworkPartition(serviceName, version, 0.2);
        sleep(30);
        if (errorRateExceeded(0.05)) return false;

        return true;
    }
}

💡 架构深挖点

  1. 灰度发布时,如果新版本涉及数据库表结构变更, 老版本代码会读新表结构失败吗?怎么设计兼容的发布顺序?(提示:扩展-迁移-收缩 三步法)
  2. 资金一致性对比的"双写"如果新版本有 Bug 导致重复扣款, 怎么避免污染测试数据?
  3. 如果灰度验证通过, 全量发布后又出现问题, 这种"长尾 Bug"怎么提前发现?

九、容量规划:双十一压测与水位线

9.1 双十一的"百倍流量"挑战

每年的双十一都是支付系统的大考:零点峰值流量是日常的 100 倍, 持续 30 分钟, 系统必须在 0 点准时承接, 不能有任何迟疑。我们团队从 8 月开始就要做双十一专项备战, 包括全链路压测容量评估限流降级应急预案四大模块。压测不是为了"证明系统很强", 而是为了找到系统撑不住的点, 然后在双十一前解决掉。我们的双十一目标是零故障、零资损、零超卖——任何一项出问题都是 P0 事故。

┌──────────────────────────────────────────────────────────────────┐
│              双十一容量规划全流程                                    │
│                                                                    │
│   8月: 压测准备                                                     │
│   ┌──────────────────────────────────────────────┐               │
│   │  1. 流量预估: 基于历史 + 业务增长 + 营销活动   │               │
│   │  2. 影子表/影子库准备: 与生产隔离的压测环境    │               │
│   │  3. 压测数据准备: 1000万用户/5000万订单       │               │
│   │  4. 流量录制: 录制去年双十一的真实流量          │               │
│   └──────────────────────────────────────────────┘               │
│                                                                    │
│   9月: 全链路压测 (3 轮)                                            │
│   ┌──────────────────────────────────────────────┐               │
│   │  第1轮: 摸底测试 - 找到系统瓶颈点              │               │
│   │  第2轮: 扩容验证 - 验证扩容后是否达标          │               │
│   │  第3轮: 极限压测 - 1.5x 峰值验证系统冗余       │               │
│   └──────────────────────────────────────────────┘               │
│                                                                    │
│   10月: 容量评估 + 限流策略                                          │
│   ┌──────────────────────────────────────────────┐               │
│   │  1. 单元容量评估: 每单元最大 TPS               │               │
│   │  2. 限流阈值设置: 各接口的 QPS/并发上限        │               │
│   │  3. 降级方案: 哪些功能可以临时关闭              │               │
│   │  4. 应急预案: 每个 P0 故障的处理 SOP           │               │
│   └──────────────────────────────────────────────┘               │
│                                                                    │
│   11月 (双十一前 1 周): 预热 + 演练                                  │
│   ┌──────────────────────────────────────────────┐               │
│   │  1. 缓存预热: 热点数据提前加载                 │               │
│   │  2. 灰度发布: 提前发布所有待上线的版本          │               │
│   │  3. 故障演练: 模拟 P0 故障, 验证恢复流程        │               │
│   │  4. 值班安排: 7x24 小时核心人员值守            │               │
│   └──────────────────────────────────────────────┘               │
└──────────────────────────────────────────────────────────────────┘

9.2 全链路压测:影子表与流量染色

全链路压测的最大难题是压测流量不能污染生产数据。我们的解决方案是影子表 + 流量染色:所有业务表都配套影子表 (order_shadow, account_shadow), 压测流量打上特殊 Header (X-Pressure-Mark: true), 网关识别后路由到影子表。影子表的数据是临时的, 压测结束后清理。这套机制让我们能用真实生产环境做全链路压测, 不需要单独的压测环境 (避免环境差异导致结果不准)。

/**
 * 压测流量染色 + 影子表路由
 * 
 * 关键点:
 *   1. 网关识别 X-Pressure-Mark Header
 *   2. 通过 ThreadLocal 传递压测标记
 *   3. MyBatis 拦截器根据标记路由到影子表
 *   4. 压测数据完全隔离, 不污染生产
 */
public class PressureTestInterceptor implements Interceptor {

    private static final ThreadLocal<Boolean> PRESSURE_MARK = new ThreadLocal<>();

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 1. 检查压测标记
        boolean isPressure = PRESSURE_MARK.get() != null && PRESSURE_MARK.get();
        
        if (isPressure) {
            // 2. 修改 SQL, 路由到影子表
            String originalSql = invocation.getSql();
            String shadowSql = convertToShadowSql(originalSql);
            
            // 3. 准备影子表数据 (如果是查询, 准备测试数据; 如果是写入, 写入影子表)
            BoundSql boundSql = new BoundSql(...);
            return invocation.getTarget().query(shadowSql, ...);
        }
        
        return invocation.proceed();
    }

    /**
     * SQL 转换: order → order_shadow, account → account_shadow
     */
    private String convertToShadowSql(String sql) {
        return sql
            .replaceAll("\\border\\b", "order_shadow")
            .replaceAll("\\baccount\\b", "account_shadow")
            .replaceAll("\\bpayment_flow\\b", "payment_flow_shadow");
    }
}

/**
 * 压测引擎 (基于 JMeter + 自研控制器)
 */
public class PressureTestEngine {

    public void runFullLinkPressureTest(int targetTps, int durationMinutes) {
        // 1. 启动压测流量生成器
        JmeterController jmeter = new JmeterController();
        jmeter.setTargetTps(targetTps);
        jmeter.setDuration(Duration.ofMinutes(durationMinutes));

        // 2. 配置流量模型 (模拟真实双十一)
        TrafficModel model = new TrafficModel();
        model.setPaymentRatio(0.6);  // 60% 支付
        model.setRefundRatio(0.05);  // 5% 退款
        model.setQueryRatio(0.35);   // 35% 查询
        jmeter.setTrafficModel(model);

        // 3. 启动压测
        jmeter.start();

        // 4. 实时监控关键指标
        ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
        monitor.scheduleAtFixedRate(() -> {
            Metrics metrics = collectMetrics();
            log.info("[压测监控] TPS: {}, 错误率: {}, P99延迟: {}", 
                metrics.getTps(), metrics.getErrorRate(), metrics.getP99Latency());
            
            // 自动熔断: 错误率超过 1% 立即停止
            if (metrics.getErrorRate() > 0.01) {
                jmeter.stop();
                alertService.sendAlert("压测自动熔断, 错误率: " + metrics.getErrorRate());
            }
        }, 0, 5, TimeUnit.SECONDS);

        // 5. 等待压测完成
        jmeter.await();
    }
}

9.3 水位线与限流策略

容量规划的核心是水位线——每个服务/单元的水位线包括CPU 使用率内存使用率数据库连接池使用率线程池使用率消息堆积量等指标。我们设定的水位线是双 80 原则: 日常水位 50%, 预警水位 80%, 熔断水位 95%。当水位线超过 80% 时, 系统自动告警并启动限流; 超过 95% 时, 启动降级 (关闭非核心功能)。

水位等级 CPU 内存 连接池 行为
安全水位 (日常)< 50%< 60%< 60%正常运行
预警水位50-80%60-80%60-80%告警, 准备扩容
告警水位80-95%80-95%80-95%紧急告警, 自动扩容, 部分限流
熔断水位> 95%> 95%> 95%熔断降级, 关闭非核心功能
/**
 * 智能限流器 (Sentinel + 自研)
 * 
 * 限流策略:
 *   1. 入口限流: 网关层按 QPS 限流
 *   2. 业务限流: 按业务类型/商户/用户限流
 *   3. 自适应限流: 根据系统负载动态调整阈值
 *   4. 热点限流: 针对热点账户/热点商户
 */
public class PaymentRateLimiter {

    /**
     * 入口限流 (QPS 维度)
     */
    public boolean tryAcquire(int permits) {
        return qpsLimiter.tryAcquire(permits);
    }

    /**
     * 业务限流 (商户维度)
     * 
     * 大商户: 1000 TPS
     * 中商户: 100 TPS
     * 小商户: 10 TPS
     */
    public boolean tryAcquireByMerchant(String merchantId, int permits) {
        Merchant merchant = merchantService.getById(merchantId);
        int limit = getMerchantLimit(merchant.getLevel());
        return merchantLimiter.tryAcquire(merchantId, permits, limit);
    }

    /**
     * 自适应限流 (基于系统负载)
     * 
     * 核心算法: 借鉴 TCP BBR 思想
     *   - 当 P99 延迟超过阈值, 主动降低 QPS 上限
     *   - 当 CPU/内存超过阈值, 主动降低 QPS 上限
     *   - 阶梯式调整, 避免突刺
     */
    public boolean adaptiveAcquire(int permits) {
        SystemLoad load = systemMonitor.getCurrentLoad();
        
        // 1. 根据延迟调整
        long p99Latency = load.getP99Latency();
        if (p99Latency > 1000) {  // 超过 1 秒
            return false;  // 直接拒绝
        }
        
        // 2. 根据 CPU 调整
        double cpuUsage = load.getCpuUsage();
        if (cpuUsage > 0.9) {
            return false;  // 拒绝
        } else if (cpuUsage > 0.8) {
            // 限流到 50%
            if (permits > 5) return false;
        } else if (cpuUsage > 0.7) {
            // 限流到 80%
            if (permits > 8 && ThreadLocalRandom.current().nextInt(10) < 2) {
                return false;
            }
        }
        
        return true;
    }

    /**
     * 热点限流 (针对大商户/热点账户)
     * 
     * 业务场景: 双十一大商户(如苹果官方)每秒可能产生数万笔订单
     * 如果不限制, 单笔热点账户就能压垮数据库
     */
    public boolean tryAcquireByHotspot(String userId, int permits) {
        // 1. 查询是否热点账户
        if (hotspotCache.isHotspot(userId)) {
            // 2. 热点账户使用独立限流器, 严格控制
            int limit = hotspotCache.getLimit(userId);  // 默认 100 TPS
            return hotspotLimiter.tryAcquire(userId, permits, limit);
        }
        return true;
    }
}

9.4 双十一零点的"守门员"

双十一零点那一刻, 所有人都在屏息等待。我们的"守门"机制包括: 0 点前 5 分钟进入"战时状态" (停止所有发布, 关闭非核心功能); 0 点前 1 分钟启动"全链路压测" (用 10% 真实流量预热); 0 点 0 分 0 秒 营销活动开启, 系统接收百倍流量; 0 点-0 点 30 分是流量高峰, 7x24 值守, 实时监控关键指标; 0 点 30 分-1 点流量逐步回落, 系统进入"观察期"; 1 点后切回"日常模式"。

┌──────────────────────────────────────────────────────────────────┐
│              双十一零点作战时间表                                    │
│                                                                    │
│   11/10 23:00 ─ 进入战时状态                                       │
│   ├── 停止所有非必要发布                                            │
│   ├── 关闭非核心功能 (推荐位/广告/活动)                            │
│   ├── 缓存预热完成 (热点商品/活动信息)                              │
│   └── 值班人员就位                                                  │
│                                                                    │
│   11/10 23:55 ─ 流量预热                                            │
│   ├── 启动 10% 真实流量 (让系统进入"热"状态)                       │
│   ├── 检查所有单元健康状态                                          │
│   └── 关闭所有灰度发布                                              │
│                                                                    │
│   11/11 00:00 ─ 决战时刻                                            │
│   ├── 营销活动开启                                                  │
│   ├── 流量瞬间从 5万 TPS 跳到 100万 TPS                            │
│   ├── 7 单元全部满载, 自动扩容到极限                                │
│   └── 监控告警实时显示, 异常立即处置                                │
│                                                                    │
│   11/11 00:30 ─ 流量高峰                                            │
│   ├── 持续高流量, 系统在极限边缘运行                                │
│   ├── 限流策略生效, 部分请求被拒绝                                  │
│   └── 重点关注资损 (差一分钱都要报警)                                │
│                                                                    │
│   11/11 01:00 ─ 流量回落                                            │
│   ├── TPS 回落到 30万                                              │
│   ├── 系统进入"观察期"                                              │
│   └── 排查潜在问题 (内存泄漏/连接未释放/日志堆积)                    │
│                                                                    │
│   11/11 02:00 ─ 战时状态解除                                        │
│   ├── 系统切回"日常模式"                                            │
│   ├── 恢复非核心功能                                                │
│   ├── 生成双十一复盘报告 (RPS/错误率/资损/Top问题)                  │
│   └── 团队庆祝, 但仍要值班到天亮                                    │
└──────────────────────────────────────────────────────────────────┘

💡 架构深挖点

  1. 全链路压测时, 影子表的数据最终要清理吗?如果不清理, 时间长了会不会影响生产表性能?
  2. 自适应限流的"基于负载调整 QPS"会不会和入口限流冲突?两层限流怎么协调?
  3. 双十一零点那一刻, 系统的最大瓶颈是 CPU/数据库/网络/磁盘中的哪一项?怎么提前识别?

十、经验沉淀:踩过的 7 个坑

10.1 坑 1: 单元扩容时数据迁移踩了 7 天

第一次做单元扩容 (8 单元扩到 16 单元), 我们用了最朴素的"停机迁移"方案: 凌晨 2 点停止服务, 把数据从旧单元导出, 导入新单元, 重新计算路由, 然后启动服务。理想很丰满, 现实很骨感——实际迁移了 7 天。原因: 120 亿条数据的导出导入本身就需要 36 小时, 加上网络中断、磁盘故障、数据校验问题, 一个月的"大动作"最终变成了一场马拉松。后来我们重写了 Cell-Migrator: 不停机迁移(双写校验 + 灰度切换), 把 7 天压缩到 7 小时。这个教训让我深刻认识到: 任何架构变更都要从"不停机"开始设计, 而不是"先停机, 后面再优化"。

10.2 坑 2: TCC 的 Cancel 阶段出现"悬挂"

早期 TCC 实现中, 我们遇到了"悬挂"问题: 一个分布式事务的 Try 阶段因为网络超时未到达, 但 Cancel 阶段先到了 (因为协调器认为 Try 失败触发 Cancel)。然后这个 Cancel 完成了 (空回滚), 但过了一会儿, 那个迟到的 Try 请求终于到了——此时 Try 以为自己是第一次执行, 直接执行了资源预留。结果: Try 预留了资源, 但 Cancel 已经完成, 资源永远不会被释放。这次故障导致 23 个账户的冻结金额"凭空消失", 我们花了 3 天时间人工核对每一笔交易。修复方案: Cancel 阶段必须先写入一条"已 Cancel"的状态记录, Try 阶段执行前先检查这个状态, 如果已 Cancel 则直接放弃。

// 修复后的 Cancel 阶段 (空回滚防护)
@Transactional
public void cancelUnfreeze(String txId) {
    TransactionLog txLog = txLogDao.getByTxId(txId);
    
    if (txLog == null) {
        // 空回滚: 写入 Cancel 状态, 防止后续 Try 悬挂
        log.warn("Empty rollback detected, txId={}", txId);
        TransactionLog emptyLog = new TransactionLog();
        emptyLog.setTxId(txId);
        emptyLog.setStatus(TxStatus.CANCELED);
        emptyLog.setCancelTime(new Date());
        txLogDao.insert(emptyLog);  // ← 关键: 写入 Cancel 状态
        return;
    }
    // ... 正常 Cancel 逻辑
}

// 修复后的 Try 阶段 (悬挂防护)
@Transactional
public boolean tryFreeze(String txId, String userId, BigDecimal amount) {
    TransactionLog existing = txLogDao.getByTxId(txId);
    
    // 悬挂防护: 如果已有 Cancel 状态, 不要 Try
    if (existing != null && existing.getStatus() == TxStatus.CANCELED) {
        log.warn("Hanging detected, txId={}, cancel already executed", txId);
        return false;  // ← 关键: 拒绝迟到的 Try
    }
    // ... 正常 Try 逻辑
}

10.3 坑 3: 大促期间热点账户拖垮整个集群

2019 年双十一零点, 我们的支付系统突然"雪崩"——所有单元的数据库 CPU 都飙到 100%, 大量请求超时。排查了 2 小时才发现: 某个大商户 (苹果官方店) 在 0 点 0 分 0 秒发起了 50 万笔支付请求, 全部命中同一账户行。MySQL 单行更新是串行的, 即使我们分库分表把这个商户打散到 1024 个子账户, 还是有大量子账户的更新集中在数据库的同一个 page 里, 形成热点。最后我们做了两件事: ①热点账户识别(实时监控哪些账户的更新频率超过阈值, 标记为热点); ②热点账户拆分(强制把热点账户的余额拆分成 1000 个子账户, 每次更新随机选一个, 写入时聚合)。

/**
 * 热点账户自动拆分
 * 
 * 原理: 把单个账户的余额拆分到 N 个子账户, 每次更新随机选一个子账户
 *       查询时聚合所有子账户的余额
 */
public class HotspotAccountSplitter {

    private static final int SUB_ACCOUNT_COUNT = 1000;
    private static final BigDecimal HOTSPOT_THRESHOLD = new BigDecimal("10000");

    /**
     * 检测热点账户
     */
    public boolean isHotspot(String userId) {
        // 1. 查询该账户的最近 1 分钟更新次数
        long updateCount = redisTemplate.opsForValue()
            .increment("hotspot-counter:" + userId);
        redisTemplate.expire("hotspot-counter:" + userId, Duration.ofMinutes(1));

        // 2. 超过阈值, 标记为热点
        return updateCount > HOTSPOT_THRESHOLD.longValue();
    }

    /**
     * 写入热点账户 (随机子账户)
     */
    public void write(String userId, BigDecimal amount) {
        if (isHotspot(userId)) {
            // 1. 随机选一个子账户
            int subIndex = ThreadLocalRandom.current().nextInt(SUB_ACCOUNT_COUNT);
            String subAccountId = userId + "_" + subIndex;

            // 2. 写入子账户
            accountDao.updateBalance(subAccountId, amount);

            // 3. 异步聚合到主账户 (供查询使用)
            kafkaTemplate.send("account-aggregate", userId, amount);
        } else {
            // 正常账户: 直接写主账户
            accountDao.updateBalance(userId, amount);
        }
    }

    /**
     * 查询热点账户 (聚合所有子账户)
     */
    public BigDecimal getBalance(String userId) {
        if (isHotspot(userId)) {
            // 聚合查询: SUM(所有子账户余额)
            return accountDao.sumSubAccountBalance(userId);
        } else {
            return accountDao.getBalance(userId);
        }
    }
}

10.4 坑 4: 防重放的 Nonce Redis 故障, 系统拒绝所有请求

一次 Redis 集群故障, 我们的防重放机制把所有支付请求都拒了! 原因: Nonce 校验依赖 Redis SETNX, Redis 故障时, 我们设计的"降级"是"放行"——但生产环境的实际情况是: Redis 故障的瞬间, 大量请求涌入, 监控系统疯狂告警"异常流量", 安全团队立刻切到了"严格模式"(所有请求必须验证 Nonce, 没缓存的全部拒绝)。结果: 真实用户的支付请求全部失败, 业务损失惨重。修复: 故障降级必须默认放行, 严格模式必须人工介入才能开启, 而不能由系统自动判断。

10.5 坑 5: 灰度发布的"数据漂移"

一次灰度发布, 新版本和旧版本并行处理了 12 小时, 期间产生了 100 万笔交易。发布完成后, 我们发现新旧版本的资金统计对不上——差异 3.2 万元。原因: 新版本修复了一个老版本的"金额计算 bug", 但这个 bug 之前已经被用户在退款时"利用"过了 (虽然不是恶意), 形成了一个"历史平衡点"。修复 bug 后, 真实的资金分布和"用户预期的资金分布"出现了偏差。教训: 涉及资金计算的 bug 修复, 必须在灰度阶段做"双轨对比", 把差异明细告知财务, 由财务判断哪些差异需要补偿, 哪些接受。

10.6 坑 6: 对账系统错把"幂等重试"当成"重复交易"

对账系统一直告警"重复交易", 但实际上这些"重复"是同一个订单的多次回调(支付渠道因为网络问题会重试回调)。我们最初的对账逻辑没有考虑幂等性, 简单按"金额+时间"做匹配, 把同一笔订单的多次回调都当成了"重复交易", 推送给运营做差错处理。运营一查发现是同一个订单, 反复投诉对账系统不智能。修复: 对账匹配键必须包含业务唯一键(订单号), 而不是只靠金额+时间; 同时接入幂等表, 把已处理的回调标记掉, 避免重复处理。

10.7 坑 7: 单元化的"幽灵用户"

单元化上线后, 我们发现有 0.01% 的用户"突然消失"——能登录, 但查不到任何账户信息。排查发现, 这些用户的 user_id 在数据库迁移时被错误地分配到了错误的单元(原本属于单元 3 的用户, 数据跑到了单元 5)。这种"幽灵用户"非常难排查, 因为他们登录、查询都是正常的, 只是查不到数据。最后我们增加了一个单元归属校验服务: 任何用户访问时, 系统都先校验 "这个 user_id 实际属于哪个单元", 如果和当前路由的单元不一致, 自动重定向到正确单元。这个简单的服务把"幽灵用户"从 0.01% 降到了 0。

/**
 * 单元归属校验服务 (解决"幽灵用户"问题)
 *
 * 场景: 用户登录时, 路由层根据 user_id % 8 路由到单元 X
 *       但实际这个用户的数据在单元 Y (数据迁移时错位)
 *
 * 方案: 维护一个 user_id → cell_id 的权威映射表
 *       任何请求到达时, 先查询权威表, 再决定是否需要重定向
 */
@Service
public class CellOwnershipValidator {

    @Autowired
    private CellOwnershipDao ownershipDao;

    @Autowired
    private RouteConfigService routeConfigService;

    /**
     * 校验并纠正单元归属
     */
    public String validateAndCorrect(String userId, String routedCell) {
        // 1. 查询权威映射
        String actualCell = ownershipDao.getActualCell(userId);

        if (actualCell == null) {
            // 1.1 新用户: 按当前路由注册到对应单元
            ownershipDao.register(userId, routedCell);
            return routedCell;
        }

        // 2. 校验一致性
        if (!Objects.equals(actualCell, routedCell)) {
            // 2.1 不一致: 记录并重定向
            log.warn("Cell ownership mismatch: userId={}, routed={}, actual={}",
                userId, routedCell, actualCell);

            // 2.2 重新路由到正确单元
            return actualCell;
        }

        // 2.3 一致: 直接放行
        return routedCell;
    }

    /**
     * 定期巡检: 发现并修复不一致
     */
    @Scheduled(cron = "0 0 3 * * ?")  // 每天凌晨 3 点
    public void dailyReconciliation() {
        // 1. 扫描所有单元
        for (String cellId : cellConfig.getAllCells()) {
            // 2. 在该单元中抽样用户
            List<String> userIds = accountDao.sampleUsers(cellId, 10000);

            // 3. 校验每个用户的归属
            for (String userId : userIds) {
                String calculatedCell = calculateCell(userId);
                if (!Objects.equals(calculatedCell, cellId)) {
                    // 4. 标记为不一致, 等待人工或自动修复
                    ownershipDao.markInconsistent(userId, cellId, calculatedCell);
                    log.error("Found inconsistent user: userId={}, in cell={}, should be in={}",
                        userId, cellId, calculatedCell);
                }
            }
        }
    }
}

10.8 经验总结: 支付架构的"反直觉"原则

15 年支付架构经验, 让我总结出几个"反直觉"原则:

常见直觉 支付架构的真相
高可用 = 万无一失高可用 = 故障域隔离 + 快速恢复, 不是"不发生故障"
分布式事务 = 性能杀手混合模式: 资金用 TCC, 外围用可靠消息, 不是"一种打天下"
单元化 = 数据分片单元化 = 完整系统复制, 数据分片只是副产品
灰度发布 = 慢慢切流量灰度 = 多维度验证 + 自动化回滚, 切流量只是表象
压测 = 找系统极限压测 = 找系统不可接受的点, 真正的目标是"修复"
容灾 = 备份 + 切换容灾 = 单元化 + 自动化自愈, 不需要"切"
合规 = 加审计日志合规 = 不可篡改 + 可验证 + 长期保存, 日志只是基础
性能优化 = 调参数性能优化 = 找瓶颈 + 架构升级, 调参数只能解决 10% 的问题

10.9 写在最后: 架构师的"道"与"术"

写到这里, 这篇"金融级支付中台架构"长文就要结束了。最后想分享几点心得:

① 架构不是设计出来的, 是演进出来的。我们这套单元化架构不是一天建成的, 是经过 5 年 4 个阶段的演进才稳定下来。一上来就追求"完美架构"是不现实的, 也不可取。先解决当前最痛的问题, 再考虑下一步。

② 业务驱动架构, 不是技术驱动。单元化不是为了"技术先进", 是因为大促流量实在扛不住才升级的。TCC 也不是为了"分布式事务", 是因为账实不平被监管处罚才上线的。脱离业务的架构是空中楼阁。

③ 简单优于复杂, 但不是简陋。很多架构师喜欢"高深"的技术方案, 但支付系统的核心是简单可控。能本地事务就不分布式事务, 能同步就不异步, 能人工就不自动。但"简单"不等于"简陋"——简单是经过深思熟虑的简化, 简陋是没想到问题的敷衍。

④ 永远假设最坏情况会发生。网络一定会断, 数据库一定会挂, 程序员一定会写出 bug。架构设计要从"失败"开始倒推, 而不是从"成功"开始顺推。单元化、限流、降级、自愈, 全是为了"失败"准备的。

⑤ 重视每一个细节, 因为支付不允许"差不多"。1 分钱的差异, 1 毫秒的延迟, 1 笔交易的失败, 在支付系统里都是 P0 故障。架构师必须对每一个细节斤斤计较, 这种"偏执"是支付行业的基因。

希望这篇长文能对正在或即将设计支付系统的架构师们有所启发。支付系统是技术深度和业务复杂度都极高的领域, 没有银弹, 只有持续演进。共勉!

💡 架构深挖点

  1. 从分库分表演进到单元化, 最难的不是技术, 而是组织和文化——单元化要求每个单元独立交付, 跨团队协作变多, 怎么设计团队结构和发布流程来适应?
  2. 如果让你重新设计这套架构, 哪些地方会用云原生技术 (Service Mesh / K8s / Serverless)? 哪些地方坚持用传统方案? 为什么?
  3. 支付系统的可观测性怎么做? 从一笔交易的入口到出口, 几百个调用点, 怎么在 5 秒内定位问题? (提示: OpenTelemetry + 分布式追踪 + 业务埋点的三层结合)
  4. 单元化的"故障域隔离"和"数据本地化"是不是一对矛盾? 极端情况下, 某个单元的数据必须全局访问, 怎么破?