跨境ERP系统如何在香港服务器的Debian系统中配置多站点数据库复制,解决跨境事务一致性?

上个星期五,财务对账单又一次不对:深圳仓的出库扣减比香港主库晚了 40 多秒,越南工厂的回执入账更是延迟到分钟级。跨境链路没出“大问题”,只是稳定的 35~80ms RTT,让“强一致”这三个字显得有点奢侈。我的目标很明确:用户下单体验不受影响(P95 < 120ms)、账务类交易强保证不丢不重、跨境复制在网络抖动下仍可自愈。
于是我把部署清单摊开:Debian 12(bookworm) 系统的 5 台香港服务器做核心(强一致域),再向深圳与胡志明市各拉一条逻辑复制通道。物理同步保证香港核心账务 RPO=0,跨境逻辑复制配合 Outbox 事件表 + 幂等去重 保证最终一致。
一、业务背景与技术目标
业务画像:跨境 ERP(订单、库存、支付、对账),多仓、多币种、跨时区(HKT、ICT、UTC-8)。
一致性目标:
- 香港站内(主域)账务类事务:强一致(本地同步复制,RPO=0)。
- 跨境站点:最终一致(秒级收敛,幂等保障,不重不丢)。
- 性能目标:下单主路径 P95 < 120ms;账务写入 P95 < 200ms(含同步确认)。
- 可用性目标:香港域任一节点宕机 RTO < 60s,跨境链路断开自动追赶。
二、拓扑设计(HK 强一致域 + 跨境最终一致域)
拓扑:
[ 互联网 / APP / 网关 ]
│
[MySQL Router/读写路由]
│
┌────────┴────────┐
│ │
【HK 强一致域】 【只读/异地域】
Debian 12 + PG16 Debian 12 + PG16
┌──────────────┐ ┌───────────────┐
│ Primary (HK1)│ WAL │ Replica (SZ) │ 逻辑订阅
├──────────────┤<========>├───────────────┤ (最终一致)
│ Sync Standby │<====┐ │ Replica (HCM)│
│ (HK2) │ │ └───────────────┘
├──────────────┤ │
│ Sync Standby │<====┘
│ (HK3) │
└──────────────┘
(HK1 对 HK2/HK3 同步物理复制;
SZ/HCM 通过逻辑复制订阅关键表)
说明:本文示例用 PostgreSQL 16(主域强一致用同步物理复制;跨境站点用逻辑复制)。若你更熟 MySQL,可把“物理同步”替换为 InnoDB Group Replication(单主)、“逻辑复制”替换为 异步通道 + GTID,思路一致。
三、硬件与系统参数(我在香港机房的标配)
| 角色 | 型号 | CPU | 内存 | 磁盘 | 网卡 | 备注 |
|---|---|---|---|---|---|---|
| HK1/HK2/HK3 | 2U 定制 | AMD EPYC 7452(32C) | 256GB DDR4 | 2×3.84TB NVMe U.2(ZFS Mirror) | 2×10GbE | 机架同 AZ 内 0.2~0.5ms RTT |
| SZ | 1U | Intel Xeon 4314(16C) | 128GB | 1×3.84TB NVMe | 1×10GbE | 至 HK RTT 35~45ms |
| HCM | 1U | AMD EPYC 7313P(16C) | 128GB | 1×3.84TB NVMe | 1×10GbE | 至 HK RTT 60~80ms |
Debian 12,内核 6.1,启用以下内核参数(/etc/sysctl.d/99-tuning.conf):
vm.swappiness=1
vm.dirty_background_ratio=5
vm.dirty_ratio=15
kernel.numa_balancing=0
net.core.rmem_max=4194304
net.core.wmem_max=4194304
net.ipv4.tcp_congestion_control=bbr
net.ipv4.tcp_fin_timeout=15
net.ipv4.tcp_tw_reuse=1
坑:ZFS 上跑 PG 要注意 recordsize=16K 与 logbias=throughput,并将 sync=always 给 WAL 盘;否则崩机回放时延迟会出人意料的长。
四、技术选型与一致性策略
- 数据库:PostgreSQL 16
- 站内强一致:物理同步复制(synchronous_commit=on,synchronous_standby_names='FIRST 1 (hk2,hk3)')
- 跨境复制:逻辑复制(Publication/Subscription),仅订阅核心业务表(订单、支付、库存流水),降噪、减压。
- 事务模型:账务写入在 HK 主域提交;跨境地区通过 Outbox 事件 驱动消费,幂等处理 确保最终一致。
- 路由:PgBouncer 连接池 + 应用侧读写分离(强一致读需 read from primary)。
- 故障切换:Patroni + etcd(或 Keepalived VIP),1 主 2 同步备。
- 审计与回放:保留 24 小时 WAL(wal_keep_size),跨境断链自动追赶。
五、实施步骤(从干净的 Debian 12 开始)
1)系统准备与时间同步
apt update && apt install -y chrony htop iotop net-tools gnupg lsof jq curl
sed -i 's/^pool.*/pool time.google.com iburst/' /etc/chrony/chrony.conf
systemctl enable --now chrony
时间漂移是复制的一号敌人。监控 NTP 偏差,目标 < 50ms。
2)安装 PostgreSQL 16
# 官方仓库
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pg.gpg
echo "deb [signed-by=/usr/share/keyrings/pg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
apt update && apt install -y postgresql-16 postgresql-16-wal2json pgbouncer
3)主库(HK1)基础配置 /etc/postgresql/16/main/postgresql.conf
listen_addresses = '0.0.0.0'
max_connections = 1000
shared_buffers = 64GB
work_mem = 64MB
maintenance_work_mem = 2GB
effective_cache_size = 180GB
wal_level = logical
max_wal_senders = 32
max_replication_slots = 32
wal_compression = on
wal_keep_size = '24GB'
checkpoint_timeout = '15min'
max_wal_size = '32GB'
min_wal_size = '4GB'
synchronous_commit = on
synchronous_standby_names = 'FIRST 1 (hk2,hk3)'
archive_mode = off
# 延迟敏感的 OLTP 调参
random_page_cost = 1.1
seq_page_cost = 1.0
访问控制 /etc/postgresql/16/main/pg_hba.conf
# 复制专用用户
host replication repl_user 10.10.0.0/16 scram-sha-256
# 应用侧
host appdb app_user 10.20.0.0/16 scram-sha-256
创建用户与库:
CREATE ROLE repl_user WITH REPLICATION LOGIN PASSWORD '***';
CREATE ROLE app_user LOGIN PASSWORD '***';
CREATE DATABASE appdb OWNER app_user;
4)在香港域内配置同步物理复制(HK2/HK3)
在 HK2/HK3 清空 data 目录,用 pg_basebackup 拉链:
systemctl stop postgresql
rm -rf /var/lib/postgresql/16/main/*
pg_basebackup -h hk1 -U repl_user -D /var/lib/postgresql/16/main -Fp -Xs -P -R
chown -R postgres:postgres /var/lib/postgresql/16/main
# 标识节点名(备用)
echo "primary_conninfo = 'host=hk1 user=repl_user password=*** application_name=hk2'" \
>> /var/lib/postgresql/16/main/postgresql.auto.conf
systemctl start postgresql
HK1 上验证:
SELECT application_name, sync_state, state, flush_lag
FROM pg_stat_replication;
你应该看到 hk2/hk3 其中一个是 sync,另一个 potential,确保 synchronous_standby_names='FIRST 1 (hk2,hk3)' 生效。
现场坑 ①:
- 症状:主库写入 P95 突然飙升到 300ms。
- 排查:发现同步链路上的 hk2 写入放大,NVMe GC;
- 处置:把 FIRST 1 (hk2,hk3) 改为 FIRST 1 (hk3,hk2),临时让 hk3 负担同步确认;稍后重平衡。记住:同步副本的 IO 抖动会直接放大主库延迟。
5)跨境逻辑复制(只复制关键表)
在 HK1 创建 publication:
-- 核心表(示例)
CREATE TABLE IF NOT EXISTS orders (
id BIGSERIAL PRIMARY KEY,
order_no TEXT UNIQUE NOT NULL,
user_id BIGINT NOT NULL,
amount NUMERIC(18,2) NOT NULL,
currency TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS inventory_ledger (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL,
warehouse TEXT NOT NULL,
delta INT NOT NULL,
reason TEXT NOT NULL,
ref_order TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 只发布需要跨境消费的表
CREATE PUBLICATION pub_core FOR TABLE orders, inventory_ledger;
在 深圳 SZ 与 胡志明 HCM 建库并创建订阅:
CREATE ROLE sub_user LOGIN PASSWORD '***';
CREATE DATABASE appdb OWNER sub_user;
\c appdb
CREATE SUBSCRIPTION sub_core
CONNECTION 'host=hk1 port=5432 user=sub_user password=*** dbname=appdb sslmode=require'
PUBLICATION pub_core
WITH (copy_data=true, create_slot=true, enabled=true);
现场坑 ②:排序规则/编码不一致 会导致订阅初始化失败。确保各站点 lc_collate、lc_ctype、server_encoding 一致(推荐 en_US.UTF-8)。
现场坑 ③:逻辑复制不复制序列(sequence)。跨境写入端若需要本地创建数据,必须:
改用 ULID/Snowflake 类型全局 ID;或
复制前手动对齐序列起点:SELECT setval('orders_id_seq', (SELECT max(id) FROM orders));
6)Outbox 事件表 + 幂等去重(解决跨境最终一致)
为什么需要 Outbox? 因为跨境逻辑复制到达时间不可控。把“事实”与“事件”分离,事件保证和事务同生死,消费者按 去重键 幂等处理。
建表与触发器:
CREATE TABLE IF NOT EXISTS outbox (
id BIGSERIAL PRIMARY KEY,
event_key TEXT NOT NULL, -- 幂等键(如: orders:ORDER_NO)
event_type TEXT NOT NULL, -- 订单创建/支付成功/库存扣减等
payload JSONB NOT NULL, -- 业务负载
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed BOOLEAN NOT NULL DEFAULT false
);
CREATE UNIQUE INDEX ux_outbox_event_key ON outbox(event_key);
-- 示例:订单创建时写 outbox(与订单在同一事务)
CREATE OR REPLACE FUNCTION f_order_created()
RETURNS trigger AS $$
BEGIN
INSERT INTO outbox(event_key, event_type, payload)
VALUES (concat('orders:', NEW.order_no), 'ORDER_CREATED',
jsonb_build_object('order_no', NEW.order_no,
'amount', NEW.amount,
'currency', NEW.currency,
'status', NEW.status));
RETURN NEW;
END; $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_order_created
AFTER INSERT ON orders
FOR EACH ROW EXECUTE FUNCTION f_order_created();
跨境站点消费者(伪码):
-- 每次取 500 条未处理事件,幂等 upsert
WITH cte AS (
SELECT id, event_key, event_type, payload
FROM outbox
WHERE processed = false
ORDER BY id
LIMIT 500
FOR UPDATE SKIP LOCKED
)
-- 以 event_key 做幂等约束
INSERT INTO inbox (event_key, event_type, payload, received_at)
SELECT event_key, event_type, payload, now() FROM cte
ON CONFLICT (event_key) DO NOTHING;
-- 按事件类型驱动本地投影(读模型)
-- 例如:
-- INSERT INTO projection_orders(...) SELECT ... FROM inbox WHERE event_type='ORDER_CREATED' AND NOT applied;
关键点:Outbox 与业务提交在同一事务,保证“有事实就必有事件”。消费者必须以 event_key 作为 唯一幂等键,避免跨境重复投影。
7)扣库存的一致性(强一致域事务 + 行级锁)
库存扣减要么完全成功要么完全失败:
BEGIN;
-- 锁住该 SKU 在指定仓的行,避免并发扣减
SELECT id FROM inventory WHERE sku=$1 AND warehouse=$2 FOR UPDATE;
-- 业务检查:可用库存是否足够
UPDATE inventory SET available = available - $3 WHERE sku=$1 AND warehouse=$2 AND available >= $3;
-- 记录流水
INSERT INTO inventory_ledger(sku, warehouse, delta, reason, ref_order)
VALUES ($1, $2, -$3, 'ORDER_ALLOCATE', $4);
COMMIT;
跨境站点只消费 inventory_ledger 流水来构建只读的“可视化库存”,不直接改写主域库存表。
8)连接池与读写分离(PgBouncer)
/etc/pgbouncer/pgbouncer.ini
[databases]
appdb = host=hk1 port=5432 dbname=appdb auth_user=pgbouncer
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 200
server_reset_query = DISCARD ALL
业务要强一致读时,直连主库端点;容忍读延迟的报表/查询走跨境只读副本。
9)自动故障转移(Patroni 简配)
/etc/patroni/hk1.yml(节选):
scope: hk-cluster
name: hk1
restapi:
listen: 0.0.0.0:8008
connect_address: hk1:8008
etcd:
hosts: 10.10.0.10:2379,10.10.0.11:2379,10.10.0.12:2379
postgresql:
listen: 0.0.0.0:5432
connect_address: hk1:5432
data_dir: /var/lib/postgresql/16/main
parameters:
wal_level: logical
synchronous_commit: on
synchronous_standby_names: 'FIRST 1 (hk2,hk3)'
bootstrap:
dcs:
synchronous_mode: true
synchronous_mode_strict: true
initdb:
- encoding: UTF8
- data-checksums
选择 synchronous_mode_strict=true 确保没有同步副本时拒绝提交,代价是可用性下降但一致性提升。我们只对账务路径启用。
10)监控与告警
- 主从延迟:pg_stat_replication 的 write_lag/flush_lag/replay_lag。
- 逻辑复制延迟:pg_stat_subscription 的 latency。
- WAL 积压:pg_stat_replication.sent_lsn - replay_lsn。
- 磁盘与 IO:node_exporter + postgres_exporter + Grafana。
示例查询:
SELECT subname, stats.apply_lag
FROM pg_stat_subscription AS stats;
11)压测与演练
模拟跨境网络:
# 在 SZ 节点对 HK 方向增加 40ms 往返延迟、0.1% 丢包
apt install -y iproute2
tc qdisc add dev eth0 root netem delay 20ms 1ms loss 0.1%
回放订单写入 5 万条,观察:
| 指标 | 站内强一致 | 跨境订阅 |
| 写入 P95 | 142ms | N/A |
| 复制追赶 | 即时 | 4.2s 收敛(5w/秒级) |
| WAL 峰值 | 18GB | —— |
六、常见坑与现场解决记录
- 序列值不一致:已在上文提及,用全局 ID 或手动对齐。
- 冲突写(多活误用):跨境站点严禁改写主域事实表。需要多活请引入冲突解决策略(如 pglogical 的 conflict_resolution),但复杂度陡增。
- 大事务复制阻塞:拆分为批量小事务;或为 inventory_ledger 单独 publication。
- 跨境 SSL:sslmode=require 并配置 CA;否则中间人风险难以接受。
- 磁盘写放大:WAL 压缩开启;ZFS 选好 recordsize;为 WAL 单独盘。
- 同步副本抖动:随时切换 FIRST 1 的顺序或临时降级为 LOCAL(业务允许时)。
七、验收标准与回归清单
一致性:
- 香港站内模拟宕机 + 账务写入不丢(RPO=0)。
- 跨境链路中断 30 分钟内恢复,事件全量回放成功;inbox 去重命中率 100%。
性能:
- 下单主链路 P95 < 120ms;账务 P95 < 200ms。
- 观测:Grafana 面板显示复制延迟 < 5s;WAL 保留安全阈值 > 2 小时。
- 演练:每月一次 patroni failover;每季度一次跨境断链回放演练。
八、参数速查表(可直接抄用再微调)
| 类别 | 参数 | 建议值 | 说明 |
| WAL | wal_level | logical | 同时支持物理/逻辑复制 |
| WAL | wal_keep_size | 24GB | 保障追赶空间 |
| 复制 | max_wal_senders | 32 | 并发发送上限 |
| 复制 | max_replication_slots | 32 | 逻辑槽数量 |
| 同步 | synchronous_commit | on | 强一致提交 |
| 同步 | synchronous_standby_names | FIRST 1 (hk2,hk3) | 站内一票通过 |
| 存储 | wal_compression | on | 降低写放大 |
| 池化 | PgBouncer.pool_mode | transaction | 降低连接开销 |
九、收尾:把账对上号的那个周末
周日晚 11 点,我把最后一条演练记录贴进了维基:RPO=0、跨境 5 秒收敛、库存流水与订单行数一致。财务那边发来一张截图,月末对账首次零差异。
那一刻我知道,这套在 Debian + PostgreSQL 上“站内强一致 + 跨境最终一致”的组合,是我们这个跨境 ERP 的最小复杂度解:该强则强,该松则松。以后再有人问我“跨境一致性怎么做”,我会把这篇笔记丢给他——然后补一句:先把同步副本的 IO 打稳,再谈架构。