← 返回展示台

分布式数据一致性设计

接入第三方支付、OTA、签章、AR 后,系统从「单体事务」变成「分布式事务」。
外部系统的固有特征:会超时、会重复回调、会乱序、会不可用。
本文给出应对这些问题的表结构与实现模式,配套 DDL 见
../DDL/09-分布式一致性.sql,13 项验证已全部通过。


一、为什么必须专门设计(问题的严重性)

场景如果不设计会怎样
支付回调重复到达重复入账,租客付一次钱记两次收款,账实不符
支付回调丢失钱收了但账单还是未缴,租客被催缴甚至断水断电
OTA 与本地同时下单超卖,两个客人订了同一间房,到场无房(最恶性事故)
签章回调乱序合同状态错乱,出现"已签但显示未签"
网络超时后重试同一笔退款执行多次,重复退款(直接资损)
第三方服务不可用全站收不了款

这些问题不是"可能发生",是"一定发生"。
一个日均 1 万笔支付的系统,即使回调成功率 99.9%,每天也有 10 笔出问题。


二、三个基础组件

组件 1:Outbox(出站事件表)

解决的问题:「业务数据改了,但通知没发出去」。

❌ 错误做法(无法保证一致性):
   ① 更新数据库(成功)
   ② 发送 MQ 消息(失败)← 外部系统永远不知道发生了什么

✅ Outbox 做法:
   ① 开启本地事务
   ② 更新业务数据 + 写入 outbox_event(同一事务,要么都成功要么都失败)
   ③ 提交事务
   ④ 后台任务扫描 outbox_event → 投递到外部系统 → 标记 success

关键设计(已实现):

CREATE TABLE outbox_event (
  ...
  -- 同一业务动作只投递一次
  UNIQUE (tenant_id, event_type, biz_key),
  -- 状态机:pending → sending → success / failed → dead
  status varchar(16),
  retry_count smallint, next_retry_at timestamptz,
  ...
);
-- 只扫待投递的(部分索引,体积小效率高)
CREATE INDEX idx_outbox_pending ON outbox_event (next_retry_at)
  WHERE status IN ('pending','failed');

实测验证:

用例3:同一 (tenant, event_type, biz_key) 重复插入
 → ERROR: duplicate key value violates unique constraint  ✅ 拦截
用例4:同一 biz_key 不同事件类型(payment.created vs payment.refunded)
 → 成功  ✅ 允许(合理:同一笔支付可既有创建又有退款)

投递策略:

参数建议值说明
扫描间隔1 秒兼顾实时性与负载
单批数量100 条避免单事务过大
重试间隔指数退避 1s→2s→4s...上限 5 分钟避免打爆外部系统
最大重试10 次超过转 dead 状态并告警
死信处理人工介入 + 告警必须有,否则消息永久丢失

组件 2:入站回调表

解决的问题:「第三方回调处理到一半崩溃,状态不一致」。

❌ 错误做法:
   收到回调 → 验签 → 查订单 → 更新状态 → 返回 200
   若"更新状态"时崩溃 → 第三方已收到 200,不会重试 → 永久不一致

✅ 正确做法:
   收到回调 → 验签 → 【原样落库 inbound_callback】 → 立即返回 200
                    ↓
              后台任务处理 → 幂等校验 → 更新业务状态

好处:

  1. 不丢:先落库再处理,处理失败可重试
  2. 可重放:任何时间可以用原始报文重新处理
  3. 可对账:原始报文是排查问题的唯一真相

实测验证:

用例5:同一 (source_system, external_id) 重复回调
 → ERROR: duplicate key value violates unique constraint "uq_inbound_source_external"  ✅

⚠️ 注意:重复回调应该返回 200 而非报错。

数据库报唯一约束 → 应用捕获 → 返回 200 告诉第三方"已收到"。

因为对第三方来说,重复发送是正常的重试行为,报错会导致它无限重试。

组件 3:幂等记录表

解决的问题:「网络重试导致同一请求被执行两次」。

CREATE TABLE idempotency_record (
  idem_key    varchar(256) NOT NULL UNIQUE,  -- 核心:唯一约束
  scene       varchar(64),
  status      varchar(16),   -- processing / success / failed
  response_body jsonb,       -- 首次响应快照
  locked_by   varchar(128),  -- 并发保护
  locked_until timestamptz,
  ...
);

实现模式:

处理请求(key):
  ① INSERT INTO idempotency_record(key, status='processing')
     ├─ 成功 → 我是第一个,继续处理
     └─ 唯一约束冲突 → 查已有记录
          ├─ status='success' → 直接返回 response_body(语义一致)
          ├─ status='processing' → 返回"处理中,请稍后"(防并发)
          └─ status='failed' → 允许重试
  ② 处理业务
  ③ UPDATE status='success', response_body=...

实测验证:

用例1:同一 idem_key 重复插入
 → ERROR: duplicate key value violates unique constraint  ✅
用例2:不同 idem_key
 → 成功  ✅

三、支付集成(最复杂,必须对账)

3.1 金额必须用「分」为单位的整数

amount_cent bigint NOT NULL,   -- ✅ 30000 表示 300.00 元
-- ❌ 绝不用 numeric/float 做金额传输

为什么? 浮点数 0.1 + 0.2 != 0.3。金额计算一旦出错,对账永远不平。

即使数据库用 numeric 精确存储,与第三方接口交互时必须用整数分,

因为所有支付渠道 API 都要求整数分。

实测验证:

用例6:amount_cent = 0
 → ERROR: violates check constraint "pay_order_amount_cent_check"  ✅
用例7:refunded_cent(40000) > amount_cent(30000)
 → ERROR: violates check constraint "pay_order_check"  ✅
用例8:正常部分退款 30000 - 10000 = 20000  ✅

3.2 三条铁律

铁律原因实现
不信回调,主动查单回调可能丢失pay_order.query_count + 定时查单
必须日终对账只有对账能发现账实不符reconciliation_batch + diff
支付与核销都幂等重复回调会重复入账uq_pay_order_channel_trade + payment_allocation 唯一约束

实测验证:

用例9:同一渠道交易号重复入账
 → ERROR: duplicate key value violates unique constraint "uq_pay_order_channel_trade"  ✅

3.3 支付单状态机(严格约束)

created ──支付──> paying ──成功──> paid ──退款──> refunding ──> refunded
   │                 │
   └──超时──> closed  └──失败──> failed

状态只能单向流转,不允许跳跃。

例如 created 不能直接到 refunded(未支付的钱不能退)。

3.4 对账流程(必须有)

每日 02:00 定时任务:
① 从支付宝/微信下载对账单(前一日)
② 存入 OSS,记录 reconciliation_batch
③ 逐笔比对:
   ├─ 本地有 & 渠道有 & 金额一致 → 匹配
   ├─ 本地有 & 渠道无 → diff_type='local_only'(可能本地多记)
   ├─ 渠道有 & 本地无 → diff_type='channel_only'(**最危险:钱收了没记账**)
   ├─ 金额不符 → diff_type='amount_mismatch'
   └─ 状态不符 → diff_type='status_mismatch'
④ 差异写入 reconciliation_diff,按类型自动处理:
   ├─ channel_only → 尝试自动补记(需人工确认)
   └─ 其他 → 转人工
⑤ 有未处理差异 → 告警

对账差异数为 0 才能算"账平了"。
这一项必须进监控告警,且要求日清。


四、OTA 集成(超卖的最大风险源)

4.1 核心原则:本地库存是唯一真相

        ┌─────────────────┐
        │  本地库存(真相)│  room_calendar
        └────────┬────────┘
                 │ 推送(异步,可能失败)
     ┌───────────┼───────────┐
     ▼           ▼           ▼
  携程        美团        飞猪        ← 都是"副本",可能滞后
     │           │           │
     └───────────┼───────────┘
                 │ 下单回调
                 ▼
        ① 原子扣减本地库存(fn_hold_room_nights)
           ├─ 成功 → 确认订单
           └─ 失败 → 拒单(调用渠道拒单接口)

4.2 必须接受「允许拒单」

这是一个产品认知问题,不只是技术问题。

多渠道销售时,OTA 上的库存必然存在短暂滞后(推送有延迟)。

所以「渠道显示有房但实际没了」的情况无法 100% 避免。

正确做法:

  1. 本地库存扣减失败 → 明确拒单,不做"先答应再想办法"
  2. 准备拒单补偿方案:免费升级房型 / 送券 / 协助转订
  3. 监控拒单率,超过 2% 说明库存同步策略有问题

❌ 很多团队的错误做法:为了保证"不拒单",让渠道可以超售,
然后靠人工调房。结果是客人到场无房,差评与投诉远大于拒单成本。

4.3 版本号防旧覆盖新

CREATE TABLE ota_inventory_sync (
  ota_id, room_id, biz_date,
  local_qty    smallint,    -- 本地可售(真相)
  pushed_qty   smallint,    -- 已推送数量
  version_no   bigint,      -- 单调递增
  ...
  UNIQUE (ota_id, room_id, biz_date)
);

为什么需要版本号?

库存推送是异步的,可能「先发的后到」。

若不用版本号,旧库存会覆盖新库存 → 超卖。

实现: 推送时带上 version_no,渠道侧丢弃比当前版本小的请求。

(需要渠道支持,不支持时用 updated_at 时间戳兜底。)

实测验证:

用例10:同 (ota, room, date) 重复插入
 → ERROR: duplicate key value violates unique constraint  ✅
用例11:ota_order 非法状态 'bogus_status'
 → ERROR: violates check constraint "ota_order_status_check"  ✅

4.4 OTA 订单状态机

received(已接收)
   ├─ 库存不足 → rejected(拒单)
   ├─ 库存充足 → confirmed(已确认)
   │               ├─ cancel → cancelled
   │               ├─ 入住 → checked_in
   │               │         └─ 离店 → checked_out
   │               └─ 未到 → noshow
   └─ 已确认 → 退订 → cancelled

received 状态必须是短暂的。 收到订单后要尽快(秒级)决定确认还是拒单,
因为渠道对确认时限有 SLA(通常几分钟),超时会被判"确认失败"。


五、签章集成

5.1 异步流程

① 我方发起签署 → contract_sign_flow (status='sent')
② 供应商发送签署链接给租客
③ 租客签署(可能几小时到几天)
④ 供应商回调 → inbound_callback → 更新 sign_flow.status
⑤ 完成后下载最终 PDF → OSS → 记录 final_file_hash

5.2 合规要点(司法举证)

必须保存原因
合同原文举证内容真实
签署过程记录(谁、何时、IP)举证签署意愿
最终文件哈希举证未被篡改
可信时间戳(TSA)举证签署时间
供应商出具的存证证书司法机构认可
final_file_key   varchar(500),   -- OSS 地址
final_file_hash  varchar(128),   -- SHA256,防篡改
timestamp_token  text,           -- 时间戳服务凭证
signers          jsonb,          -- 每个签署方的状态与时间

⚠️ 强烈建议接第三方(法大大/e签宝/上上签)。
自建电子签的法律效力在诉讼中很难被认可,这个风险不值得省成本。


六、AR 集成(防厂商锁定)

6.1 分工原则

内容谁负责存哪里
拍摄/建模/渲染第三方(专业设备与算法)第三方 + OSS 副本
媒体文件(全景图/glTF)第三方生产,我方托管OSS
空间锚点(热点/轨迹/尺寸)必须我方PostgreSQL

6.2 为什么锚点必须自己存

如果锚点数据(热点坐标、相机轨迹)只存在第三方的模型文件里:

本设计的解法:

CREATE TABLE ar_hotspot (
  ...
  hotspot_type varchar(16),   -- nav/info/device/product/cta
  coord        jsonb,          -- 空间坐标
  device_id    bigint,         -- ← 关联真实设备
  room_id      bigint,         -- ← 关联房间
  target_node_id bigint,       -- ← 关联其他分区
  action       jsonb,          -- 点击后做什么
);

6.3 供应商抽象

CREATE TABLE integration_provider (
  integration_type varchar(32),  -- esign / ar / sms / map
  provider_code    varchar(64),  -- beike_vr / kujiale / ...
  is_primary       boolean,      -- 主用/备用
  ...
);

换供应商 = 更新 provider 配置 + 重新托管媒体文件,业务表不动。


七、C 端(商城/直播/团购)的隔离设计

7.1 必须物理隔离的理由

业务流量特征风险
租赁主业务平稳,B 端为主—
直播带货脉冲式,秒级万级并发打满连接池 → 收租功能不可用
团购秒杀短时高并发 + 库存争抢锁竞争拖垮主库
商城读多写多稀释主库资源

真实事故模式:一次直播活动,商城订单涌入打满 RDS 连接池,
导致同一时刻所有租客无法交租金。这类事故在行业内并不罕见。

7.2 隔离方案

┌────────────────────┐     ┌────────────────────┐
│  B 端 + 租赁主业务  │     │  C 端商城/直播/团购 │
├────────────────────┤     ├────────────────────┤
│ RDS 主库(写)      │     │ 独立 RDS 集群       │
│ RDS 只读副本(读)  │     │ 独立 Redis(库存)  │
│ 独立 Redis          │     │ 独立 RocketMQ       │
│                     │     │ 独立 K8s 命名空间   │
└────────────────────┘     └────────────────────┘
          │                          │
          └──────── 通过 API/MQ 通信 ─────┘

账号体系可共用(user_account 支持一人多租户绑定),

但数据库与资源池必须分开。

7.3 秒杀类场景的特殊处理

商城秒杀与短租库存有相似之处,但量级更高:

手段说明
Redis 预扣库存在 Redis 原子递减,不碰数据库
请求排队MQ 削峰,异步创建订单
限流网关层按用户/IP/接口限流
热点隔离单个爆品用独立 Redis key,避免大 key
兜底Redis 与 DB 最终对账,异步补偿

本设计的 room_calendar 防超卖方案(原子条件更新)
适用于房源库存(量级百万),
但不适用于秒杀(量级十万 QPS) —— 那个场景必须走 Redis。


八、验证清单(全部实测通过)

#验证项预期结果
1幂等键重复拦截✅
2不同幂等键允许✅
3Outbox 同事件重复拦截✅
4Outbox 同 biz_key 不同事件允许✅
5入站回调重复拦截✅
6支付金额为 0拦截✅
7退款超额拦截✅
8正常部分退款正确计算✅
9渠道交易号重复入账拦截✅
10OTA 库存记录重复拦截✅
11OTA 订单非法状态拦截✅
12灰度比例超范围拦截✅
13迁移破坏性标记正确记录✅

九、实施优先级建议

优先级内容理由
P0幂等表 + 支付对账直接关系资金安全
P0Outbox + 入站回调所有外部集成的基础
P1OTA 库存映射与版本号防超卖,直接影响口碑
P1签章流程合同法律效力
P2AR 集成与热点差异化,但不阻塞主业务
P2灰度开关上线前需要
P3C 端隔离商城上线前完成即可

配套 DDL:../DDL/09-分布式一致性.sql(执行通过)、../DDL/10-分布式一致性验证.sql(13 项通过)

目录

  1. 一、为什么必须专门设计(问题的严重性)
  2. 二、三个基础组件
  3. 三、支付集成(最复杂,必须对账)
  4. 四、OTA 集成(超卖的最大风险源)
  5. 五、签章集成
  6. 六、AR 集成(防厂商锁定)
  7. 七、C 端(商城/直播/团购)的隔离设计
  8. 八、验证清单(全部实测通过)
  9. 九、实施优先级建议