多区域主动-主动(PostgreSQL 逻辑复制 + 冲突解决)在 ABP 的落地
多区域主动-主动(PostgreSQL 逻辑复制 + 冲突解决)在 ABP 的落地 🚀
📚 目录
- 多区域主动-主动(PostgreSQL 逻辑复制 + 冲突解决)在 ABP 的落地 🚀
- 0) 摘要(TL;DR)🧭
- 1) 选型与边界 🎯
- 2) 架构全景图 🗺️
- 2.1 分区式主动-主动(推荐多数 SaaS)
- 2.2 真多主(双活/多活)
- 3) PostgreSQL 配置要点 ⚙️
- 4) Schema/副本标识与过滤(强约束!)🔒
- 5) 路线 A:分区式主动-主动(工程模板)🧰
- 5.1 发布端(区域 A:租户 `A%`)
- 5.2 订阅端(区域 B:只读副本)
- 5.3 ABP 多租户路由(连接串解析,异步·保留回退链路)🧩
- 5.4 网关(YARP)+ 主动健康检查 🛡️
- 6) 路线 B:真多主(双向订阅 + 冲突治理)🔁
- 6.1 互订防回环(上线互订“黄金组合”)
- 6.2 冲突行为(PG 原生)与观测
- 7) DDL/Schema 变更 🧱
- 8) 观测与 SLO(最小查询)📊
- 9) 故障切换/演练 SOP(分区式)🧪
- 10) 可复现环境(Docker Compose + SQL)🧪
- 11) ABP 侧最佳实践(小结)🧩
- 12) 性能与稳定性清单 🏎️
0) 摘要(TL;DR)🧭
-
分区式主动-主动(主推 ✅)
按 TenantId 将租户路由到各自“主区域-主库”;跨区仅订阅只读副本(逻辑复制)。注意:TRUNCATE
不受行/列过滤影响;- 发布
UPDATE/DELETE
时,过滤列必须在副本标识(Replica Identity)中; - PG16 起可
streaming=parallel
;PG16 起有origin
防回环;PG17 起可failover=true
(与物理 HA 的 failover slot 协同)。
-
真多主(双活/多活 🔁)
双向订阅 + 表级冲突策略。PG 原生不自动合并冲突(缺失行的UPDATE/DELETE
会跳过,PG18 起可被统计/写日志);如需自动合并,选用 pglogical 或 EDB PGD(BDR) 的 LWW(update_if_newer) 等策略,并开启track_commit_timestamp
作为参考。
1) 选型与边界 🎯
- PG 原生逻辑复制:发布/订阅;PG15+ 支持行过滤/列清单;不复制 DDL/大对象/序列值;多主场景无内置冲突合并(需要流程/应用兜底)。
- pglogical 扩展:多源合并 + 可配置冲突(
last_update_wins/keep_local/...
),LWW 需track_commit_timestamp=on
。 - EDB PGD(BDR):企业级多主,默认 update_if_newer(LWW),具备冲突统计/工具。
实战建议:SaaS 多租户优先“分区式主动-主动”;真多主仅在确需“任意区可写”时小范围试点,并严格控表(只选天然幂等/CRDT 友好表)。
2) 架构全景图 🗺️
2.1 分区式主动-主动(推荐多数 SaaS)
要点:每个租户仅在自己的主区写入;跨区只读订阅,无跨主冲突。TRUNCATE
跨区直达⚠️。
2.2 真多主(双活/多活)
要点:双向订阅时在双方显式设置 origin='none'
(防回环),并在“已对齐数据”场景下 copy_data=false
上线互订(见 §6.1)。
3) PostgreSQL 配置要点 ⚙️
-
基础:
wal_level=logical
;合理设置max_replication_slots
/max_wal_senders
。 -
提交时间戳:
track_commit_timestamp=on
(供 LWW/取证用;有一定开销,且非严格全序)。 -
订阅关键选项(PG16–18):
streaming=parallel
(PG16 引入;PG18 默认parallel
,仍建议显式设置以消除版本歧义);synchronous_commit=off
(订阅端默认,有利于降低发布端提交等待);origin=none
(互订防回环;默认any
);failover=true
(PG17+,与物理 HA 的 failover slot 协同)。
4) Schema/副本标识与过滤(强约束!)🔒
- 行过滤 + UPDATE/DELETE:
WHERE
表达式涉及的列必须属于副本标识(Replica Identity);否则复制会失败/不一致。 - 列清单/行过滤对
TRUNCATE
无效:一旦发布,订阅端整表生效⚠️。
推荐建模(Tenant 入主键/唯一索引):
CREATE TABLE orders (tenant_id text NOT NULL,order_id uuid NOT NULL,amount numeric(18,2) NOT NULL,status text NOT NULL,updated_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY (tenant_id, order_id)
);-- 如果不是复合主键,也可用唯一索引(列需 NOT NULL)并指定为副本标识:
-- CREATE UNIQUE INDEX uq_orders_tid_oid ON orders(tenant_id, order_id);
-- ALTER TABLE orders REPLICA IDENTITY USING INDEX uq_orders_tid_oid;
主键/ID:跨区建议 UUIDv7/ULID 或“区域前缀+序列”;避免序列碰撞与切主
setval()
负担(PG18 可uuidv7()
;.NET 9 可Guid.CreateVersion7()
)。
5) 路线 A:分区式主动-主动(工程模板)🧰
5.1 发布端(区域 A:租户 A%
)
CREATE PUBLICATION pub_orders_a
FOR TABLE orders
WHERE (tenant_id LIKE 'A%')
WITH (publish = 'insert, update, delete, truncate'); -- TRUNCATE 仍会整表复制
5.2 订阅端(区域 B:只读副本)
CREATE SUBSCRIPTION sub_a_to_bCONNECTION 'host=a.db port=5432 dbname=app user=rep password=***'PUBLICATION pub_orders_aWITH (copy_data = true, streaming = parallel, synchronous_commit = off);
5.3 ABP 多租户路由(连接串解析,异步·保留回退链路)🧩
继承
DefaultConnectionStringResolver
,先尝试“{Name}__{Region}
”命名的连接串,找不到再回退到{Name}
(保留 ABP 的模块级命名/默认连接串语义)。
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Data;
using Microsoft.Extensions.Options;[Dependency(ReplaceServices = true)]
[ExposeServices(typeof(IConnectionStringResolver))]
public class TenantDbConnectionStringResolver : DefaultConnectionStringResolver
{private readonly ICurrentTenant _currentTenant;public TenantDbConnectionStringResolver(IOptionsMonitor<AbpDbConnectionOptions> abpDbConnOptions,IOptionsSnapshot<ConnectionStrings> appConnStrings,ICurrentTenant currentTenant): base(abpDbConnOptions, appConnStrings){_currentTenant = currentTenant;}public override async Task<string> ResolveAsync(string? connectionStringName = null){var name = string.IsNullOrWhiteSpace(connectionStringName) ? "Default" : connectionStringName;var tenantId = _currentTenant.Id?.ToString() ?? "host";var region = ResolveRegionFromTenant(tenantId); // 你的逻辑:缓存/配置中心/DB 映射var key = $"{name}__{region}"; // 例如:Default__A / Default__Bvar cs = await base.ResolveAsync(key);return string.IsNullOrWhiteSpace(cs) ? await base.ResolveAsync(name) : cs;}private static string ResolveRegionFromTenant(string tenantId)=> tenantId.StartsWith("A", StringComparison.OrdinalIgnoreCase) ? "A" : "B";
}
5.4 网关(YARP)+ 主动健康检查 🛡️
"Clusters": {"abp-api": {"Destinations": { "a": { "Address": "https://api-a/" }, "b": { "Address": "https://api-b/" } },"HealthCheck": { "Active": { "Enabled": true, "Interval": "00:00:10", "Timeout": "00:00:02", "Path": "/health" } }}
}
6) 路线 B:真多主(双向订阅 + 冲突治理)🔁
6.1 互订防回环(上线互订“黄金组合”)
-- ✓ 先离线或一次性脚本完成两端数据对齐,再启动实时流
-- A 订 B:只接收 B 的“本地起源”事务;上线互订时建议不要 copy 首批
CREATE SUBSCRIPTION sub_b_to_aCONNECTION 'host=b.db port=5432 dbname=app user=rep password=***'PUBLICATION pub_all_bWITH (copy_data = false, origin = 'none', streaming = parallel);-- B 订 A 同理
ℹ️ 小贴士:
copy_data=true
+origin='none'
在互订场景可能出现“起源不明”告警;已对齐数据后互订,建议copy_data=false
。
6.2 冲突行为(PG 原生)与观测
- 缺失行的
UPDATE/DELETE
:订阅端跳过(不报错);PG18 起会记录并在pg_stat_subscription_stats
计数。 - 唯一键冲突:按类型计数(
insert_exists
/update_exists
等)。 - 自动合并:使用 pglogical/PGD 的 LWW(需
track_commit_timestamp=on
),仅适合幂等覆盖类表(如资料/配置)。
7) DDL/Schema 变更 🧱
PG 原生不复制 DDL:需统一迁移流程(一般“先订阅端加列→后发布端;删除相反顺序”)。涉及副本标识/过滤列务必谨慎,避免复制中断。
8) 观测与 SLO(最小查询)📊
-- 订阅端延迟/健康(逻辑复制)
SELECT subname, pid, received_lsn, latest_end_lsn,last_msg_send_time, last_msg_receipt_time,EXTRACT(EPOCH FROM (now() - latest_end_time)) AS apply_delay_s
FROM pg_stat_subscription;-- PG18+ 冲突/错误统计(可重置)
SELECT subname,apply_error_count, sync_error_count,confl_insert_exists, confl_update_exists, confl_update_missing,confl_delete_missing, confl_update_origin_differs, confl_delete_origin_differs
FROM pg_stat_subscription_stats;
-- 重置统计(需要超级用户)
-- SELECT pg_stat_reset_subscription_stats(NULL);
9) 故障切换/演练 SOP(分区式)🧪
TRUNCATE 警示框 ⚠️
TRUNCATE
不看过滤/列清单 → 跨区整表生效。- SOP:变更窗口 + 双人复核 + 暂停订阅或在所有订阅方一致执行 + 恢复前二次核验。
10) 可复现环境(Docker Compose + SQL)🧪
docker-compose.yml(PG18 示例;PG16/17 可切镜像)
services:pg_a:image: postgres:18environment: { POSTGRES_PASSWORD: pgpass }command: >postgres -c wal_level=logical-c max_wal_senders=10-c max_replication_slots=10-c track_commit_timestamp=onports: ["5433:5432"]pg_b:image: postgres:18environment: { POSTGRES_PASSWORD: pgpass }command: >postgres -c wal_level=logical-c max_wal_senders=10-c max_replication_slots=10-c track_commit_timestamp=onports: ["5434:5432"]
初始化(两边同构表 + 发布/订阅)
-- A / B 同步执行
-- PG18 可用 uuidv7();若为 PG16/17,可换 gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS pgcrypto;CREATE TABLE orders(tenant_id text NOT NULL,order_id uuid NOT NULL DEFAULT uuidv7(),amount numeric(18,2) NOT NULL,status text NOT NULL,updated_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY (tenant_id, order_id)
);-- A 发布:A% 租户
CREATE PUBLICATION pub_orders_a
FOR TABLE orders WHERE (tenant_id LIKE 'A%')
WITH (publish='insert,update,delete,truncate');-- B 发布:B% 租户
CREATE PUBLICATION pub_orders_b
FOR TABLE orders WHERE (tenant_id LIKE 'B%')
WITH (publish='insert,update,delete,truncate');-- B 订 A(只读副本)
CREATE SUBSCRIPTION sub_a_to_b
CONNECTION 'host=pg_a port=5432 dbname=postgres user=postgres password=pgpass'
PUBLICATION pub_orders_a
WITH (copy_data=true, streaming=parallel, synchronous_commit=off);
若验证真多主:两边发布可不加过滤(或互写租户),并以
origin='none'
+copy_data=false
建立双向订阅(先离线对齐首批数据)。
11) ABP 侧最佳实践(小结)🧩
- 连接串解析:
ICurrentTenant
+ 自定义IConnectionStringResolver
(继承默认实现,保留回退链路),按“租户→区域”映射到{Name}__{Region}
。 - API 网关:YARP 注入/透传
X-TenantId
,开启主动健康检查与必要的 Header/Path 变换。 - 领域建模:把 TenantId 放入主键/唯一索引与副本标识;软删墓碑避免“删除复活”。
12) 性能与稳定性清单 🏎️
streaming=parallel
(PG16+;PG18 默认)+ 调整max_parallel_apply_workers_per_subscription
,并保证max_logical_replication_workers
总池足够。- 订阅端
synchronous_commit=off
(默认),降低发布端提交等待。 - 尽量用 PK/唯一索引做副本标识,少用
REPLICA IDENTITY FULL
。 - 用
pg_stat_subscription_stats
做冲突类型计数+阈值告警;配合差异报表与回放脚本闭环。