上一篇 下一篇 分享链接 返回 返回顶部

香港服务器上用 CentOS 7 解决跨境 ERP 的 PostgreSQL 性能瓶颈:分片落地、索引调优、全流程部署与踩坑记录

发布人:Minchunlin 发布时间:2025-09-25 09:26 阅读量:544


我们部署在香港机房的电商网站,从其业务监控屏上,订单同步的 95 线延迟飙到 900ms,库存扣减队列堆到 12 万条。业务在珠三角、长三角、成渝三线同时打点,跨境链路穿过内地—香港的网络边界,零点大促刚过,秒杀流量却像潮水回灌。CFO 在电话那头只问了一句:“还能稳住吗?”

我蹲在 42U 机柜前,把两台 NVMe 条子重新热插拔做了校验,心里很清楚:数据库已经顶到单机极限,继续加 CPU 没意义。得分片,而且得把最痛的慢 SQL 一口气掐死。这篇就是那一夜我在香港机房上,用 CentOS 7 + PostgreSQL 做分片与索引优化、从系统到数据库全链路落地的完整记录。

一、现场环境与目标

1.1 硬件与网络(香港机房,BGP/CN2 GIA 线路)

角色 型号/配置 存储 内存 网络 备注
COORD(协调/入口) 2×Xeon Silver 4216 2×1.92TB NVMe(RAID1, XFS) 256GB 2×10GbE(Bond) 运行 PostgreSQL + FDW + pgbouncer
SHARD-0(数据节点) 2×Xeon Silver 4216 2×1.92TB NVMe(RAID1, XFS) 256GB 2×10GbE(Bond) 主库
SHARD-1(数据节点) 同上 同上 同上 同上 主库
备库(每个 SHARD 各一台) 1×Xeon Silver 1×1.92TB NVMe 128GB 1×10GbE 流复制只读

1.2 业务画像与瓶颈

多租户(tenant_id),订单写入尖峰高、库存扣减强一致要求高。

热点 SQL:

  • 最近订单列表:WHERE tenant_id = ? AND order_time >= ? ORDER BY order_time DESC LIMIT 50
  • 库存变更:UPDATE stock SET qty = qty - ? WHERE tenant_id=? AND sku=?
  • 模糊搜索:LIKE '%keyword%' 命中 sku/customer_name
  • 痛点:单实例索引膨胀 + 锁争用 + I/O 抖动,跨境延迟带来连接数堆积。

1.3 改造目标(SLO)

指标 改造前 目标 最终结果
p95 延迟(订单写) 480–900ms ≤120ms 95ms
p95 延迟(订单查列表) 650ms ≤150ms 110ms
峰值 TPS(混合读写) ~1.2k ≥3.0k 3.5k
复制延迟 偶发 5–20s ≤2s ≤1.2s
连接数 1.5k 活跃/抖动 <200(经池化) 稳定 ~120

二、操作系统层:CentOS 7(保持可复现)

注意:CentOS 7 已 EOL,但本案为历史环境复盘与可复现教程。

2.1 BIOS/内核/文件系统

关闭 BIOS C-States(低延迟优先)。

NVMe 调度器使用 none(NVMe 默认如此)。

使用 XFS,挂载 noatime,定期 fstrim.timer。

# XFS + noatime
UUID=<YOUR-UUID> /var/lib/pgsql xfs defaults,noatime 0 0
systemctl enable fstrim.timer --now

2.2 tuned + sysctl + THP
yum install -y tuned
tuned-adm profile throughput-performance
cat >>/etc/sysctl.d/99-pg.conf <<'EOF'
vm.swappiness=1
vm.dirty_background_ratio=5
vm.dirty_ratio=20
vm.overcommit_memory=2
fs.file-max=1000000
net.core.somaxconn=1024
kernel.numa_balancing=0
EOF
sysctl -p /etc/sysctl.d/99-pg.conf

# 关闭 Transparent Huge Pages
echo 'never' > /sys/kernel/mm/transparent_hugepage/enabled
echo 'never' > /sys/kernel/mm/transparent_hugepage/defrag
echo 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' >> /etc/rc.d/rc.local
chmod +x /etc/rc.d/rc.local

# limits
cat >>/etc/security/limits.d/postgres.conf <<'EOF'
postgres soft nofile 100000
postgres hard nofile 100000
EOF

三、安装 PostgreSQL 14(PGDG),开启关键扩展

# PGDG 仓库
yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm
yum install -y postgresql14-server postgresql14-contrib
/usr/pgsql-14/bin/postgresql-14-setup initdb
systemctl enable --now postgresql-14

3.1 基础配置(以 256GB RAM 为例)

/var/lib/pgsql/14/data/postgresql.conf:

listen_addresses = '*'
max_connections = 200           # pgbouncer 前置,DB 端尽量小
shared_buffers = 64GB           # ~25%
effective_cache_size = 180GB    # ~70%
work_mem = 64MB                 # 小心连接数放大
maintenance_work_mem = 2GB
wal_level = replica
max_wal_size = 8GB
min_wal_size = 2GB
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9
random_page_cost = 1.1
effective_io_concurrency = 200
autovacuum_naptime = 10s
autovacuum_vacuum_cost_limit = 2000
autovacuum_work_mem = 1GB
log_min_duration_statement = 500ms
log_checkpoints = on
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
pg_stat_statements.track = all
password_encryption = scram-sha-256

pg_hba.conf(仅示意):

hostssl all all 10.0.0.0/16 scram-sha-256

启用扩展:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS btree_gin;

四、连接池:pgbouncer(事务级)

yum install -y epel-release pgbouncer
cat >/etc/pgbouncer/pgbouncer.ini <<'EOF'
[databases]
erp = host=127.0.0.1 port=5432 dbname=erp auth_user=pgbouncer

[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 3000
default_pool_size = 100
server_reset_query = DISCARD ALL
ignore_startup_parameters = extra_float_digits
EOF

# 用户
cat >/etc/pgbouncer/userlist.txt <<'EOF'
"pgbouncer" "SCRAM-SHA-256$<hash>"
"erpapp" "SCRAM-SHA-256$<hash>"
EOF

systemctl enable --now pgbouncer

坑 1:部分 ORM 开启 server-side prepared statements,搭配 pool_mode=transaction 可能重用失败,建议对 ORM 关闭或在连接串上禁用 prepare。

五、索引优化:先把最疼的慢 SQL 止血

5.1 捕获热点

-- 热点 SQL 摘要
SELECT calls, mean_exec_time, query
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;

5.2 针对场景的索引策略

最近订单列表(覆盖 + 顺序一致)

SQL 模式:

SELECT id, status, amount
FROM orders
WHERE tenant_id = $1 AND order_time >= now() - interval '7 days'
ORDER BY order_time DESC
LIMIT 50;

索引(在数据节点上):

-- 注意:DESC 顺序一致以避免排序回表
CREATE INDEX CONCURRENTLY idx_orders_tenant_time_desc
  ON orders (tenant_id, order_time DESC)
  INCLUDE (status, amount);

库存更新(点查 + 行级锁尽量短)

-- 确保 tenant_id + sku 唯一
ALTER TABLE stock ADD CONSTRAINT uk_stock_tenant_sku UNIQUE (tenant_id, sku);

-- 定位索引
CREATE INDEX CONCURRENTLY idx_stock_tenant_sku ON stock (tenant_id, sku);

模糊搜索(SKU/姓名)

CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- GIN + trigram,加速 LIKE '%xxx%'
CREATE INDEX CONCURRENTLY idx_orders_sku_trgm ON orders USING gin (sku gin_trgm_ops);
CREATE INDEX CONCURRENTLY idx_orders_cust_trgm ON orders USING gin (customer_name gin_trgm_ops);

-- 对前缀匹配较多的列,可加 pattern_ops
CREATE INDEX CONCURRENTLY idx_orders_sku_prefix ON orders (sku varchar_pattern_ops);

效果对比(EXPLAIN 片段,简化):

查询 优化前(ms) 优化后(ms) 说明
最近订单列表 420 95 覆盖索引 + 顺序一致
库存更新 38 8 唯一约束配合点查
模糊搜索 680 130 trigram GIN

坑 2:多列组合索引要与 WHERE/ORDER BY 的实际顺序一致;INCLUDE 覆盖列减少回表带来的随机 I/O。

六、分片方案:PostgreSQL 原生分区 + FDW 外部分区

目标:按 tenant_id 做哈希分片,COORD 作为“协调入口”,SHARD-0/1 作为数据节点。
方案优点:无侵入 SQL(分区路由自动),易扩展(新增分片即可)。
要求:PostgreSQL ≥ 11(支持将外部表挂为分区)。

6.1 数据节点(SHARD-0/1)建库

-- 每个数据节点执行
CREATE DATABASE erp;
\c erp

-- 订单表(与现有单表结构一致)
CREATE TABLE orders (
  id bigint PRIMARY KEY,
  tenant_id int NOT NULL,
  order_time timestamptz NOT NULL DEFAULT now(),
  status smallint NOT NULL DEFAULT 0,
  amount numeric(12,2) NOT NULL,
  sku text,
  customer_name text
);

-- 该节点的 shard_id 固定函数
CREATE OR REPLACE FUNCTION shard_id() RETURNS smallint LANGUAGE sql STABLE AS $$ SELECT 0; $$; -- 在 SHARD-1 改成 SELECT 1

-- 雪花式简化:shard_id 前缀 + 序列
CREATE SEQUENCE order_id_seq START 1 INCREMENT 1;
ALTER TABLE orders ALTER COLUMN id SET DEFAULT (shard_id()::bigint*1000000000000 + nextval('order_id_seq'));

-- 必要索引(与上节一致)
CREATE INDEX idx_orders_tenant_time_desc ON orders (tenant_id, order_time DESC) INCLUDE (status, amount);
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_orders_sku_trgm ON orders USING gin (sku gin_trgm_ops);

坑 3(ID 冲突):分片前请设计 全局唯一 ID。这里用 “shard_id × 10^12 + 序列”。迁移时注意旧数据 ID。

6.2 协调节点(COORD)配置 FDW 与分区

CREATE DATABASE erp;
\c erp
CREATE EXTENSION IF NOT EXISTS postgres_fdw;

-- 连接到两个数据节点
CREATE SERVER shard0 FOREIGN DATA WRAPPER postgres_fdw
  OPTIONS (host '10.0.0.11', port '5432', dbname 'erp');
CREATE SERVER shard1 FOREIGN DATA WRAPPER postgres_fdw
  OPTIONS (host '10.0.0.12', port '5432', dbname 'erp');

CREATE USER MAPPING FOR erpapp SERVER shard0 OPTIONS (user 'erp', password '<pwd>');
CREATE USER MAPPING FOR erpapp SERVER shard1 OPTIONS (user 'erp', password '<pwd>');

-- 分区母表
CREATE TABLE orders (
  id bigint PRIMARY KEY,
  tenant_id int NOT NULL,
  order_time timestamptz NOT NULL DEFAULT now(),
  status smallint NOT NULL DEFAULT 0,
  amount numeric(12,2) NOT NULL,
  sku text,
  customer_name text
) PARTITION BY HASH (tenant_id);

-- 外部分区(映射到各自数据节点实际表)
CREATE FOREIGN TABLE orders_s0 (
  id bigint,
  tenant_id int,
  order_time timestamptz,
  status smallint,
  amount numeric(12,2),
  sku text,
  customer_name text
) SERVER shard0 OPTIONS (schema_name 'public', table_name 'orders');

CREATE FOREIGN TABLE orders_s1 (...) SERVER shard1 OPTIONS (schema_name 'public', table_name 'orders');

-- 挂载为哈希分区
ALTER TABLE orders ATTACH PARTITION orders_s0 FOR VALUES WITH (MODULUS 2, REMAINDER 0);
ALTER TABLE orders ATTACH PARTITION orders_s1 FOR VALUES WITH (MODULUS 2, REMAINDER 1);

插入路由:应用只需 INSERT INTO orders(...) VALUES(...),协调节点按 tenant_id 哈希自动路由到对应 SHARD。
索引位置:必须在数据节点建索引;协调节点的外部分区不需要本地索引。

6.3 扩容(新增分片)思路

新增 SHARD-2,创建同结构表与索引,作为空分片。

创建新外部分区 orders_s2,改为 MODULUS 4, REMAINDER 2 等;在线 Rehash 可通过:

双写(应用层)+ 后台 rehash 迁移;或

维护 “路由表(tenant_id -> shard)”,过渡期路由不必严格哈希;

迁移用 INSERT ... SELECT + 逻辑复制,分租户批次切换。

七、零停机迁移(单表 -> 分片表)

读写分离窗口:先把非关键读走只读库,写压保持。

创建分片母表与分区(如上)。

逻辑复制(PG 原生 PUBLICATION/SUBSCRIPTION):

-- 旧单表库
CREATE PUBLICATION pub_orders FOR TABLE orders;

-- COORD
CREATE SUBSCRIPTION sub_orders
  CONNECTION 'host=OLD_DB port=5432 dbname=erp user=replicator password=***'
  PUBLICATION pub_orders WITH (copy_data = true);

按 tenant_id 批次切换写入:应用路由到新库(COORD),对已迁完租户强制在新表写;

一致性校验:每批校验行数与校验和;

切换完成:撤销旧库写入,清理订阅。

坑 4(排序/Collation 差异):请确保各节点 DB 的 lc_collate 与 lc_ctype 一致;否则 ORDER BY 在不同分片上可能顺序差异,汇总排序出错。统一 en_US.UTF-8 或一致的 zh_CN.UTF-8。

八、写放大与 VACUUM:别让膨胀吞掉 NVMe

大表(orders/stock)设置表级参数(在数据节点):

ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.05, autovacuum_vacuum_threshold = 50000);
ALTER TABLE orders SET (autovacuum_analyze_scale_factor = 0.02);
ALTER TABLE orders SET (toast.autovacuum_vacuum_scale_factor = 0.03);

周期性 REINDEX CONCURRENTLY 针对重写成本高的 GIN:

REINDEX INDEX CONCURRENTLY idx_orders_sku_trgm;

Checkpoint 调优已在 postgresql.conf 体现;观察 pg_stat_bgwriter,保证 checkpoint 时间占比平滑。

九、压测与结果

9.1 压测脚本(简化)

# 自定义混合事务:50% 查询最近订单,30% 下单,20% 库存更新
# 可用 pgbench -f mix.sql -j 16 -c 128 -T 600

9.2 结果摘要

场景 单实例(优化前) 单实例(仅索引优化) 分片后(2 分片)
TPS(混合) 1.2k 1.9k 3.5k
p95 写延迟 480–900ms 210ms 95ms
p95 查延迟 650ms 240ms 110ms
复制延迟 N/A N/A ≤1.2s
活跃连接 1.5k 抖动 600 ~120(pgbouncer)

十、运维要点与故障备忘

  • 连接池与预编译:部分驱动(如 PgJDBC)默认 prepared statements,pool_mode=transaction 下建议禁用或调大 max_prepared_statements 并谨慎使用。
  • WAL 压力:大促期间 max_wal_size 适度加大(如 16–32GB),并启用 wal_compression = on(PG14 默认支持 LZ4/PLAIN)。
  • 备份:推荐 pgBackRest;不要在峰值做全量备份,走增量 + WAL 归档。
  • 跨片事务:FDW 非分布式两段提交,涉及多分片强一致要求的业务,要么路由到同片、要么在应用层设计幂等/补偿。
  • 监控:pg_stat_statements + auto_explain(仅在 COORD 开启,慎控开销);I/O 层 iostat -x 1,观测 NVMe QD 与 await。
  • Collation 一致性:前文强调再强调。

十一、完整配置片段(可直接复用)

11.1 auto_explain(仅 COORD,避免全局开销)

shared_preload_libraries = 'pg_stat_statements,auto_explain'
auto_explain.log_min_duration = '300ms'
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.log_nested_statements = off

11.2 psql 连接串(通过 pgbouncer)

postgresql://erpapp:***@coord-vip:6432/erp?sslmode=require

十二、收尾:3 点 20 分,报警恢复

回酒店的时候是 3:20。地铁口的 7-11 还亮着,我买了瓶水,脑子里回放了一遍:先索引止血,再分片疏洪,连接池把高并发“压平”,VACUUM 把写放大“磨平”。CFO 后来发来一条消息:“库存损益今天小于 0.03% 了。”
那一刻我知道:这套在香港机房里跑起来的 CentOS 7 + PostgreSQL 分片架构,值了。下一步,我们会把分片数从 2 拉到 4,在深圳边界再加一组只读,继续把链路时延往下拧。

附:一步到位的部署清单(Checklist)

  •  CentOS 7 tuned、sysctl、THP 关闭、XFS noatime
  •  PGDG 安装 PostgreSQL 14,开启 pg_stat_statements/pg_trgm
  •  pgbouncer 事务级池化,连接数降至 200 内
  •  针对热点 SQL 的顺序一致与覆盖索引
  •  FDW 外部分区 + 哈希分片(按 tenant_id)
  •  雪花式 ID(shard_id 前缀)避免跨片冲突
  •  autovacuum 表级参数 + 周期性 REINDEX
  •  逻辑复制做零停机迁移,按租户分批切换
  •  监控与备份策略在峰值期间做“降冲击”配置

如果你也在香港机房为跨境 ERP 扛压,照着这篇把“索引先止血、分片再疏洪”的顺序走一遍,先把单点拧到极致,再把水平分出去。当报警静下来、库存对上数的那一刻,你会知道一切都值得。

目录结构
全文