接入第三方支付、OTA、签章、AR 后,系统从「单体事务」变成「分布式事务」。
外部系统的固有特征:会超时、会重复回调、会乱序、会不可用。
本文给出应对这些问题的表结构与实现模式,配套 DDL 见../DDL/09-分布式一致性.sql,13 项验证已全部通过。
| 场景 | 如果不设计会怎样 |
|---|---|
| 支付回调重复到达 | 重复入账,租客付一次钱记两次收款,账实不符 |
| 支付回调丢失 | 钱收了但账单还是未缴,租客被催缴甚至断水断电 |
| OTA 与本地同时下单 | 超卖,两个客人订了同一间房,到场无房(最恶性事故) |
| 签章回调乱序 | 合同状态错乱,出现"已签但显示未签" |
| 网络超时后重试 | 同一笔退款执行多次,重复退款(直接资损) |
| 第三方服务不可用 | 全站收不了款 |
这些问题不是"可能发生",是"一定发生"。
一个日均 1 万笔支付的系统,即使回调成功率 99.9%,每天也有 10 笔出问题。
解决的问题:「业务数据改了,但通知没发出去」。
❌ 错误做法(无法保证一致性):
① 更新数据库(成功)
② 发送 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 状态并告警 |
| 死信处理 | 人工介入 + 告警 | 必须有,否则消息永久丢失 |
解决的问题:「第三方回调处理到一半崩溃,状态不一致」。
❌ 错误做法:
收到回调 → 验签 → 查订单 → 更新状态 → 返回 200
若"更新状态"时崩溃 → 第三方已收到 200,不会重试 → 永久不一致
✅ 正确做法:
收到回调 → 验签 → 【原样落库 inbound_callback】 → 立即返回 200
↓
后台任务处理 → 幂等校验 → 更新业务状态
好处:
实测验证:
用例5:同一 (source_system, external_id) 重复回调
→ ERROR: duplicate key value violates unique constraint "uq_inbound_source_external" ✅
⚠️ 注意:重复回调应该返回 200 而非报错。
数据库报唯一约束 → 应用捕获 → 返回 200 告诉第三方"已收到"。
因为对第三方来说,重复发送是正常的重试行为,报错会导致它无限重试。
解决的问题:「网络重试导致同一请求被执行两次」。
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
→ 成功 ✅
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 ✅
| 铁律 | 原因 | 实现 |
|---|---|---|
| 不信回调,主动查单 | 回调可能丢失 | 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" ✅
created ──支付──> paying ──成功──> paid ──退款──> refunding ──> refunded
│ │
└──超时──> closed └──失败──> failed
状态只能单向流转,不允许跳跃。
例如 created 不能直接到 refunded(未支付的钱不能退)。
每日 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 才能算"账平了"。
这一项必须进监控告警,且要求日清。
┌─────────────────┐
│ 本地库存(真相)│ room_calendar
└────────┬────────┘
│ 推送(异步,可能失败)
┌───────────┼───────────┐
▼ ▼ ▼
携程 美团 飞猪 ← 都是"副本",可能滞后
│ │ │
└───────────┼───────────┘
│ 下单回调
▼
① 原子扣减本地库存(fn_hold_room_nights)
├─ 成功 → 确认订单
└─ 失败 → 拒单(调用渠道拒单接口)
这是一个产品认知问题,不只是技术问题。
多渠道销售时,OTA 上的库存必然存在短暂滞后(推送有延迟)。
所以「渠道显示有房但实际没了」的情况无法 100% 避免。
正确做法:
❌ 很多团队的错误做法:为了保证"不拒单",让渠道可以超售,
然后靠人工调房。结果是客人到场无房,差评与投诉远大于拒单成本。
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" ✅
received(已接收)
├─ 库存不足 → rejected(拒单)
├─ 库存充足 → confirmed(已确认)
│ ├─ cancel → cancelled
│ ├─ 入住 → checked_in
│ │ └─ 离店 → checked_out
│ └─ 未到 → noshow
└─ 已确认 → 退订 → cancelled
received状态必须是短暂的。 收到订单后要尽快(秒级)决定确认还是拒单,
因为渠道对确认时限有 SLA(通常几分钟),超时会被判"确认失败"。
① 我方发起签署 → contract_sign_flow (status='sent')
② 供应商发送签署链接给租客
③ 租客签署(可能几小时到几天)
④ 供应商回调 → inbound_callback → 更新 sign_flow.status
⑤ 完成后下载最终 PDF → OSS → 记录 final_file_hash
| 必须保存 | 原因 |
|---|---|
| 合同原文 | 举证内容真实 |
| 签署过程记录(谁、何时、IP) | 举证签署意愿 |
| 最终文件哈希 | 举证未被篡改 |
| 可信时间戳(TSA) | 举证签署时间 |
| 供应商出具的存证证书 | 司法机构认可 |
final_file_key varchar(500), -- OSS 地址
final_file_hash varchar(128), -- SHA256,防篡改
timestamp_token text, -- 时间戳服务凭证
signers jsonb, -- 每个签署方的状态与时间
⚠️ 强烈建议接第三方(法大大/e签宝/上上签)。
自建电子签的法律效力在诉讼中很难被认可,这个风险不值得省成本。
| 内容 | 谁负责 | 存哪里 |
|---|---|---|
| 拍摄/建模/渲染 | 第三方(专业设备与算法) | 第三方 + OSS 副本 |
| 媒体文件(全景图/glTF) | 第三方生产,我方托管 | OSS |
| 空间锚点(热点/轨迹/尺寸) | 必须我方 | PostgreSQL |
如果锚点数据(热点坐标、相机轨迹)只存在第三方的模型文件里:
本设计的解法:
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, -- 点击后做什么
);
CREATE TABLE integration_provider (
integration_type varchar(32), -- esign / ar / sms / map
provider_code varchar(64), -- beike_vr / kujiale / ...
is_primary boolean, -- 主用/备用
...
);
换供应商 = 更新 provider 配置 + 重新托管媒体文件,业务表不动。
| 业务 | 流量特征 | 风险 |
|---|---|---|
| 租赁主业务 | 平稳,B 端为主 | — |
| 直播带货 | 脉冲式,秒级万级并发 | 打满连接池 → 收租功能不可用 |
| 团购秒杀 | 短时高并发 + 库存争抢 | 锁竞争拖垮主库 |
| 商城 | 读多写多 | 稀释主库资源 |
真实事故模式:一次直播活动,商城订单涌入打满 RDS 连接池,
导致同一时刻所有租客无法交租金。这类事故在行业内并不罕见。
┌────────────────────┐ ┌────────────────────┐
│ B 端 + 租赁主业务 │ │ C 端商城/直播/团购 │
├────────────────────┤ ├────────────────────┤
│ RDS 主库(写) │ │ 独立 RDS 集群 │
│ RDS 只读副本(读) │ │ 独立 Redis(库存) │
│ 独立 Redis │ │ 独立 RocketMQ │
│ │ │ 独立 K8s 命名空间 │
└────────────────────┘ └────────────────────┘
│ │
└──────── 通过 API/MQ 通信 ─────┘
账号体系可共用(user_account 支持一人多租户绑定),
但数据库与资源池必须分开。
商城秒杀与短租库存有相似之处,但量级更高:
| 手段 | 说明 |
|---|---|
| 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 项通过)