# 分布式数据一致性设计

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

---

## 一、为什么必须专门设计（问题的严重性）

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

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

---

## 二、三个基础组件

### 组件 1：Outbox（出站事件表）

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

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

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

**关键设计（已实现）：**
```sql
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：幂等记录表

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

```sql
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 金额必须用「分」为单位的整数

```sql
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 版本号防旧覆盖新

```sql
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） | 举证签署时间 |
| 供应商出具的存证证书 | 司法机构认可 |

```sql
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 为什么锚点必须自己存

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

- ❌ 无法做「点热点看真实空调状态」的业务联动
- ❌ 换供应商时，所有业务关联丢失
- ❌ 无法做热点点击统计与优化

**本设计的解法：**
```sql
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 供应商抽象

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

---

## 九、实施优先级建议

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

---

*配套 DDL：`../DDL/09-分布式一致性.sql`（执行通过）、`../DDL/10-分布式一致性验证.sql`（13 项通过）*
