由 John Doe 十月 27, 2026
大多数开发者在数据库遇到写入性能压力时,第一反应往往会是升级成分布式分片架构,实际上大多数情况下都是没有必要的。
目录

作者:Abul Kalam Azad
当工程团队在 PostgreSQL 中遭遇写入吞吐量瓶颈时,架构上的默认思路往往是采用分布式分片方案(例如 Citus、应用层手动分区,或是迁移到 NoSQL 数据库)。
分片会带来沉重的运维负担:两阶段提交、跨分片外键失效、复杂的分布式事务,以及高难度的表结构迁移。
在引入多节点分片之前,借助现代硬件并对 PostgreSQL 引擎机制进行针对性调优,单台主库实例就可以突破每秒 10 万次写入。
要实现这一目标,需要消除磁盘同步瓶颈、批量执行引擎操作、降低索引更新开销,并优化 PostgreSQL 的预写日志(WAL)。
消除每次写入的 fsync 瓶颈
默认情况下,PostgreSQL 优先保障数据持久性,而非极限吞吐量。每次事务提交都会执行 fsync() 系统调用,在向客户端返回成功之前,强制磁盘盘片或 NVMe 控制器缓存将 WAL 数据刷写到非易失性存储中。
一块高性能 NVMe 硬盘可承载 1 万 ~ 4 万 IOPS,无法支撑每秒来自独立线程的 10 万次阻塞式 fsync() 调用。
解决方案:异步提交
如果你的业务可以接受主机发生硬崩溃时丢失数毫秒的数据(例如遥测、事件、动态信息流、监控指标等场景),可以开启异步提交:
-- 在 postgresql.conf 中全局配置,或在会话级设置:
synchronous_commit = off
wal_writer_delay = 10ms
当 synchronous_commit = off 时,事务将 WAL 记录写入 PostgreSQL 内存中的 WAL 缓冲区后就立即返回成功,无需等待物理磁盘刷写完成。
内置的 walwriter 后台进程会每隔 wal_writer_delay 设定的时间(默认 10ms),或在缓冲区写满时,将 WAL 数据块批量刷写到磁盘。
持久性权衡:数据库一致性与崩溃恢复能力完全不受影响。崩溃不会损坏数据库,你仅可能丢失最近 10~20 毫秒内未刷盘的提交数据。
写入批量处理:摒弃单条 INSERT 语句
每秒执行 10 万条独立的 INSERT INTO tbl VALUES (...) 语句,会产生 10 万次网络往返、SQL 解析与事务边界开销。即便使用持久化连接池,应用吞吐量也会因此大幅下降。
方案 A:多值 INSERT 批量写入
在应用层聚合写入请求(例如在 Redis 或内存中缓冲 10 毫秒,或攒够 500 条记录),然后以多行插入的方式批量刷入:
INSERT INTO event_logs (device_id, event_type, payload, created_at) VALUES
(101, 'ping', '{"temp": 22}', NOW()),
(102, 'alert', '{"temp": 85}', NOW()),
-- ... 每批建议 500~1000 行
(500, 'ping', '{"temp": 21}', NOW());
1000 行的批量写入仅承担一次查询的解析与事务开销,就能完成 1000 条数据写入。
方案 B:COPY 协议(吞吐量最高)
若追求极致写入性能,PostgreSQL 流式 COPY 协议可以完全绕过标准的查询解析与执行计划流程。
使用各语言原生驱动的流式 API(如 Go 的 pgcopy、Node/Python 的 COPY FROM STDIN BINARY、.NET 的 NpgsqlCopyBinaryImporter),数据可直接流式写入原始表页:
COPY event_logs (device_id, event_type, payload, created_at)
FROM STDIN WITH (FORMAT binary);
基准测试普遍显示,与参数化多行 INSERT 查询相比,COPY 流式写入的吞吐量可提升 3~5 倍。
精简二级索引,善用 HOT 更新
二级索引会严重拖累写入负载。如果一张表包含 5 个索引,写入单行数据就需要更新表的堆页面,外加 5 个独立的 B 树索引页面。
带有 4 个二级索引的单行写入流程:
[写入行数据]
├──> 写入表堆页面
├──> 更新索引 1(created_at B 树)
├──> 更新索引 2(user_id B 树)
├──> 更新索引 3(status B 树)
└──> 更新索引 4(device_id B 树)
这会造成 5 倍写入放大,并频繁触发索引页分裂。
降低索引放大的技巧
- 移除低效用二级索引:写入密集的数据表要严格精简索引。高频写入的接入表只保留主键,最多再加 1~2 个必要的二级索引。
- 降低表填充因子(fillfactor)以启用 HOT 更新: 对于高频 UPDATE 操作,只要更新的列未被索引,PostgreSQL 的**仅堆元组(HOT)**优化可以让更新在同一个数据页内完成,无需修改二级索引:
-- 为每个表页预留 20% 的空闲空间,用于就地更新
ALTER TABLE active_sessions SET (fillfactor = 80);
WAL 缓冲区与检查点调优
PostgreSQL 的默认配置参数是针对普通服务器设计的。在每秒 10 万行的写入压力下,默认 WAL 配置会因检查点过于频繁,导致写入卡顿。
在 postgresql.conf 中进行如下配置:
# 调整 WAL 缓冲区大小,应对写入流量突增
wal_buffers = 64MB
# 增大检查点之间的最大 WAL 容量,平滑磁盘 I/O
max_wal_size = 64GB
min_wal_size = 8GB
# 拉长检查点写入时长,避免磁盘 I/O 尖峰
checkpoint_completion_target = 0.9
checkpoint_timeout = 15min
配置原理
- max_wal_size:如果设置过小(例如默认的 1GB),高强度写入会在数秒内耗尽阈值,触发强制即时检查点。这会导致磁盘写入延迟飙升,阻塞后端工作进程。
- checkpoint_completion_target = 0.9:指示后台检查点进程将脏页写入分散在
checkpoint_timeout窗口的 90% 时长内完成,平缓 I/O 曲线。
声明式表分区(无需跨节点分片)
原生声明式分区不等同于分片,它运行在单个 PostgreSQL 服务器实例上,只是将一张巨大的逻辑表拆分为磁盘上多个独立的物理文件。
CREATE TABLE metric_streams (
id BIGINT GENERATED ALWAYS AS IDENTITY,
device_id INT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL,
val DOUBLE PRECISION
) PARTITION BY RANGE (recorded_at);
-- 创建按天分区
CREATE TABLE metrics_2026_09_19 PARTITION OF metric_streams
FOR VALUES FROM ('2026-09-19 00:00:00+00') TO ('2026-09-20 00:00:00+00');
性能优势
- 索引工作集更紧凑:每天的分区对应小型、紧凑的 B 树索引,可完全存放在
shared_buffers(共享内存缓冲区)中,写入时避免磁盘换入换出。 - 即时清理(DROP TABLE):无需执行速度慢、会产生表膨胀的
DELETE FROM metric_streams WHERE recorded_at < NOW() - INTERVAL '30 days'操作,直接删除旧分区即可,零写入开销。
硬件与存储拓扑
要达到每秒 10 万次写入,需要足够的物理硬件带宽:
- 存储:采用 RAID 10 的专属 NVMe 硬盘。将预写日志目录(
pg_wal)与主数据库集群目录(base)拆分到不同的物理 NVMe 硬盘/控制器上,避免检查点刷写与 WAL 写入争抢 I/O 资源。 - 连接:在 PostgreSQL 前端部署 PgBouncer 这类高性能连接池,使用事务级连接池模式。PostgreSQL 每个连接都会 fork 一个完整的操作系统进程;数千个活跃连接会造成 CPU 上下文切换瓶颈。建议将活跃服务器连接数控制在 50~100 之间。
实施清单
- 在高吞吐量写入链路中将
synchronous_commit设为off - 将行写入聚合为 500~1000 行的多值批量,或使用二进制 COPY 协议
- 移除写入密集表中不必要的二级索引
- 将
max_wal_size扩大到 32GB~64GB,并设置checkpoint_completion_target = 0.9 - 将
pg_wal磁盘存储与主数据存储分离 - 通过 PgBouncer 以事务池模式转发应用流量
- 对高写入量表按时间范围分区,让活跃 B 树索引常驻内存
请先充分挖掘这些单节点优化手段。在绝大多数工程场景中,远不需要用到分布式多节点分片,PostgreSQL 就足以承载企业级的写入体量。
参考
How to Scale PostgreSQL to 100,000 Writes Per Second Without Sharding