PostgreSQL实例进程:从启动到运行的完整故事

PostgreSQL实例进程:从启动到运行的完整故事

最近在深入研究PostgreSQL的内部机制,发现这玩意儿的进程架构真的挺有意思的。今天就和大家聊聊PostgreSQL实例是怎么运作的,从按下启动按钮到处理查询的整个过程。

先说说整体架构

我一开始接触PostgreSQL的时候,总以为它就是一个单体程序在跑。后来用ps命令一看,好家伙,一堆postgres进程,当时就懵了。研究了一阵子才搞明白,PostgreSQL其实更像一个工厂系统。

graph TB subgraph clients["客户端"] C1[应用程序 1] C2[应用程序 2] C3[应用程序 N] end subgraph connection["连接管理"] PM[Postmaster
主进程] end subgraph backend["查询执行"] BE1[Backend 1] BE2[Backend 2] BE3[Backend N] end subgraph memory["共享内存"] SB[Shared Buffers
数据缓存] WB[WAL Buffers
日志缓冲] CL[CLOG
事务状态] end subgraph bgworkers["后台维护"] BG[Background Writer] CK[Checkpointer] WW[WAL Writer] AV[AutoVacuum] end subgraph storage["存储"] DF[(数据文件)] WF[(WAL日志)] end C1 --> PM C2 --> PM C3 --> PM PM --> BE1 PM --> BE2 PM --> BE3 BE1 --> SB BE2 --> SB BE3 --> SB BE1 --> WB BG --> SB CK --> SB WW --> WB BG --> DF CK --> DF WW --> WF AV --> DF style PM fill:#ff6b6b,stroke:#333,stroke-width:3px style SB fill:#4ecdc4,stroke:#333,stroke-width:2px style WB fill:#ffe66d,stroke:#333,stroke-width:2px

这里面有几个关键角色:

Postmaster是老大,负责总调度。它就像厂长一样,监听5432端口等客户来连,每来一个连接就fork出一个Backend进程去服务。我当时好奇为什么不用线程,后来才知道这样做的好处是进程间隔离得好,一个进程崩了不影响其他的。

Backend进程是真正干活的。一个客户端连接对应一个Backend,这个设计挺有意思的。每个Backend有自己独立的内存空间,负责解析SQL、优化查询、执行操作。不过这也意味着如果你的max_connections设置成500,理论上就可能有500个Backend进程,吃内存还是挺猛的。

后台进程这帮家伙是维护团队,各司其职:

  • Background Writer定时扫描脏页异步写入磁盘
  • Checkpointer创建检查点,把所有脏页强制刷盘
  • WAL Writer负责把日志缓冲刷到磁盘
  • AutoVacuum清理死元组,这个后面会详细说

共享内存就像工厂的仓库,所有进程都能访问。主要是Shared Buffers存数据页,WAL Buffers存日志,还有一些锁表、事务状态之类的东西。

启动过程是怎么回事

我们来看看执行pg_ctl start之后发生了什么。

sequenceDiagram actor Admin as 管理员 participant OS as 操作系统 participant PM as Postmaster participant Conf as 配置文件 participant SM as 共享内存 participant WAL as WAL日志 participant BG as 后台进程 Note over Admin,BG: 阶段1: 启动命令 Admin->>OS: pg_ctl start -D /data OS->>PM: 启动Postmaster进程 activate PM Note over Admin,BG: 阶段2: 读取配置 PM->>Conf: 读取 postgresql.conf Conf-->>PM: 返回配置参数 PM->>Conf: 读取 pg_hba.conf Conf-->>PM: 返回认证规则 Note over Admin,BG: 阶段3: 分配共享内存 PM->>SM: 分配 Shared Buffers activate SM PM->>SM: 分配 WAL Buffers PM->>SM: 初始化 Lock Tables PM->>SM: 初始化 CLOG Note right of SM: 总共约4.2GB
共享内存 Note over Admin,BG: 阶段4: 崩溃恢复检查 PM->>WAL: 检查 pg_control 文件 WAL-->>PM: 返回数据库状态 alt 数据库异常关闭 Note right of PM: 需要恢复! PM->>WAL: 读取WAL日志 PM->>SM: 重放WAL记录 Note right of SM: 恢复到一致状态 else 数据库正常关闭 Note right of PM: 无需恢复 end Note over Admin,BG: 阶段5: 启动后台进程 PM->>BG: fork() Background Writer activate BG Note right of BG: 每200ms扫描 PM->>BG: fork() Checkpointer Note right of BG: 每10min检查点 PM->>BG: fork() WAL Writer Note right of BG: 每200ms刷盘 PM->>BG: fork() AutoVacuum Launcher Note right of BG: 每1min检查 PM->>BG: fork() Stats Collector Note right of BG: 收集统计 Note over Admin,BG: 阶段6: 准备就绪 PM->>OS: 监听端口 5432 Note over PM: 开始接受连接 PM-->>Admin: 启动完成 deactivate SM deactivate BG deactivate PM

这个过程其实有几个点值得注意:

启动的时候会读两个重要配置文件,postgresql.conf是主配置,pg_hba.conf管认证规则。我之前改了postgresql.conf忘了reload,查了半天为什么不生效,后来才发现有些参数必须重启才行。

共享内存的分配比较关键。Postmaster会一次性分配好所有共享内存,这就是为什么shared_buffers改了必须重启的原因。我当时在一台16GB的机器上把shared_buffers设成了12GB,结果启动都启动不起来,操作系统直接拒绝分配这么大的共享内存。后来查资料才知道,一般建议设置为物理内存的25-40%就够了。

如果上次是异常关闭,启动时会做崩溃恢复。Postmaster会检查pg_control文件,发现状态不对就会去读WAL日志重放。这个机制挺靠谱的,我经历过几次服务器断电,重启后PostgreSQL都能自动恢复过来。

启动完后台进程的顺序也是有讲究的。Background Writer和Checkpointer是写数据的,WAL Writer是写日志的,AutoVacuum是清理垃圾的。这些进程都是从Postmaster fork出来的,它们会一直在后台运行,直到数据库关闭。

验证启动是否成功,可以看看进程列表:

bash 复制代码
ps -ef | grep postgres

# 你会看到类似这样的输出
postgres  1234     1  Ss   09:00   postgres
postgres  1235  1234  Ss   09:00   postgres: checkpointer
postgres  1236  1234  Ss   09:00   postgres: background writer
postgres  1237  1234  Ss   09:00   postgres: walwriter
postgres  1238  1234  Ss   09:00   postgres: autovacuum launcher

第一个进程就是Postmaster,后面的都是它的子进程。

内存这块真的很重要

内存配置是PostgreSQL性能的关键,我当时踩了不少坑。先看看内存的整体结构:

graph TD subgraph shared["共享内存区(所有进程共享)"] SB["Shared Buffers
数据页缓存
推荐: RAM的25-40%
示例: 4GB (16GB服务器)"] WB["WAL Buffers
事务日志缓冲
推荐: 16MB
或 shared_buffers的1/32"] CL["CLOG
事务提交日志
记录事务提交状态"] LS["Lock Space
锁管理表
管理各种锁"] PA["Proc Array
进程数组
活动进程信息"] end subgraph local1["Backend 1 本地内存"] WM1["work_mem
查询操作
排序/哈希/聚合
每个操作独立!"] MM1["maintenance_work_mem
维护操作
VACUUM/CREATE INDEX"] TB1["temp_buffers
临时表缓冲"] end subgraph local2["Backend 2 本地内存"] WM2["work_mem"] MM2["maintenance_work_mem"] TB2["temp_buffers"] end subgraph os["操作系统层"] OC["OS Page Cache
文件系统缓存
effective_cache_size
推荐: RAM的50-75%"] end SB --> OC WB --> OC WM1 -.使用.-> SB WM2 -.使用.-> SB style SB fill:#4ecdc4,stroke:#333,stroke-width:3px style WB fill:#ffe66d,stroke:#333,stroke-width:3px style WM1 fill:#f38181,stroke:#333,stroke-width:2px style WM2 fill:#f38181,stroke:#333,stroke-width:2px style OC fill:#a8e6cf,stroke:#333,stroke-width:2px

几个容易搞错的地方

Shared Buffers不是越大越好

我最开始以为这个越大性能越好,就把32GB服务器的shared_buffers设成了24GB。结果发现checkpoint的时候IO压力巨大,而且启动时间变得超长。后来研究才明白,PostgreSQL的设计是依赖操作系统缓存的,shared_buffers设置太大反而会和OS cache抢内存。

一般的建议是物理内存的25-40%,比如16GB的机器设4GB就挺合适:

conf 复制代码
# 16GB服务器的配置
shared_buffers = 4GB                    # 25% of 16GB
effective_cache_size = 12GB             # 75% of 16GB,这个只是告诉优化器的,不实际分配

work_mem是个大坑

这个参数我被坑惨了。work_mem是每个查询操作使用的内存,注意是每个操作!一个复杂查询可能有多个排序、哈希操作,每个都会用work_mem。

假设你设置了work_mem = 256MB,max_connections = 500,某个查询有5个操作同时跑,那就是256MB × 5 = 1.28GB,如果有100个这样的并发查询,理论上就需要128GB内存!服务器直接OOM。

我的建议是保守设置,比如16MB:

conf 复制代码
work_mem = 16MB                         # 保守设置

# 计算公式参考:
# (RAM - shared_buffers) / (max_connections × 预期并发操作数)
# = (16GB - 4GB) / (200 × 3) ≈ 20MB

如果某些查询确实需要更大的work_mem,可以会话级别临时调整:

sql 复制代码
SET work_mem = '256MB';  -- 仅对当前会话生效
SELECT * FROM huge_table ORDER BY ...;

effective_cache_size容易被忽略

这个参数不分配内存,但它告诉查询优化器操作系统大概有多少缓存可用。设置合理的话,优化器会做出更好的查询计划。一般设置为物理内存的50-75%。

怎么知道配置合不合理

监控缓存命中率是个好办法:

sql 复制代码
SELECT 
    sum(heap_blks_read) as disk_reads,
    sum(heap_blks_hit) as buffer_hits,
    round(
        sum(heap_blks_hit)::numeric / 
        nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) * 100,
        2
    ) as hit_ratio_percent
FROM pg_statio_user_tables;

一般来说,hit_ratio_percent应该在99%以上。如果低于90%,说明缓存太小了,考虑增加shared_buffers。

客户端连接进来后发生了什么

现在我们来看看一个SQL查询的完整生命周期。假设有个客户端要查询数据:

sequenceDiagram participant C as 客户端 participant PM as Postmaster participant BE as Backend participant SM as Shared Memory participant D as 磁盘 C->>PM: 1. 连接请求 PM->>BE: 2. fork Backend进程 BE-->>C: 3. 连接成功 C->>BE: 4. 发送SQL查询 Note over BE: 解析阶段 BE->>BE: 5. 词法+语法分析 BE->>BE: 6. 语义检查 Note over BE: 规划阶段 BE->>SM: 7. 获取统计信息 SM-->>BE: 表大小/索引信息 BE->>BE: 8. 生成执行计划 Note over BE: 执行阶段 BE->>SM: 9. 查找数据页 alt 缓存命中 SM-->>BE: 10a. 返回数据 Note right of SM: Buffer Hit else 缓存未命中 SM->>D: 10b. 读取文件 D-->>SM: 11b. 返回数据 SM-->>BE: 12b. 返回数据 Note right of SM: Buffer Miss end BE->>BE: 13. 过滤/聚合 BE-->>C: 14. 返回结果

这个过程其实挺复杂的:

解析阶段会把SQL文本转成内部结构,检查语法是否正确,表和字段是否存在。这一步比较快。

规划阶段是重点。优化器会从统计信息里获取表的行数、数据分布等,然后评估不同的执行方案。比如是全表扫描还是用索引,是用嵌套循环还是哈希连接。这个过程相当于给查询找一条最优路径。

我们可以用EXPLAIN看看优化器在想什么:

sql 复制代码
EXPLAIN (ANALYZE, BUFFERS) 
SELECT * FROM users WHERE age > 18;

-- 输出类似这样:
--  Seq Scan on users  
--      (cost=0.00..15.50 rows=100 width=40)
--      (actual time=0.012..0.156 rows=95 loops=1)
--    Filter: (age > 18)
--    Rows Removed by Filter: 5
--    Buffers: shared hit=5
--  Planning Time: 0.123 ms
--  Execution Time: 0.234 ms

这里面有几个关键信息:

  • cost是估算的成本,越小越好
  • rows是估计返回的行数
  • Buffers: shared hit=5说明从缓存读了5个页,这是好事
  • 如果是shared read=5,说明从磁盘读的,会慢很多

执行阶段Backend就开始真正读数据了。它会先去Shared Buffers里找,找到了就直接用(Buffer Hit),找不到就得去磁盘读(Buffer Miss)。读进来的数据会放到Shared Buffers里,下次其他查询可能就能直接用了。

写数据的时候更复杂

INSERT、UPDATE、DELETE这些写操作涉及的东西更多。PostgreSQL用的是WAL(Write-Ahead Logging)机制,简单说就是先写日志,再改数据。

sequenceDiagram participant C as 客户端 participant BE as Backend participant WB as WAL Buffers participant SB as Shared Buffers participant WF as WAL Files participant DF as Data Files C->>BE: BEGIN BE->>BE: 分配事务ID C->>BE: INSERT INTO users ... Note over BE,WF: 先写日志 (WAL) BE->>WB: 写WAL记录 Note over BE,DF: 再改数据 BE->>SB: 修改数据页 BE->>SB: 标记为脏页 BE-->>C: OK C->>BE: COMMIT Note over BE,WF: 提交时强制刷盘 BE->>WB: 写COMMIT记录 WB->>WF: fsync刷到磁盘 Note right of WF: 持久化保证 BE-->>C: COMMIT成功 Note over SB,DF: 后台异步写入 BG->>SB: 扫描脏页 BG->>DF: 异步写入磁盘

这个设计挺巧妙的。WAL日志是顺序写,速度快。数据文件是随机写,速度慢,但可以异步慢慢写。就算数据还没完全写到数据文件,只要WAL写成功了,崩溃后也能恢复。

MVCC机制

PostgreSQL用的多版本并发控制(MVCC)挺有意思。UPDATE操作不是原地修改,而是创建新版本,旧版本保留着。每行数据有个xmin(创建它的事务ID)和xmax(删除它的事务ID)。

不同事务根据自己的快照看到不同的数据版本,这样读不阻塞写,写也不阻塞读。但代价是会产生很多死元组(dead tuples),需要VACUUM来清理。

我们经历过一次表膨胀的问题。有张表频繁UPDATE,结果发现查询越来越慢,用下面的SQL一看,死元组占了30%:

sql 复制代码
SELECT 
    tablename,
    n_live_tup AS live_rows,
    n_dead_tup AS dead_rows,
    round(
        n_dead_tup * 100.0 / 
        NULLIF(n_live_tup + n_dead_tup, 0), 
        2
    ) AS bloat_percent
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;

后来手动VACUUM了一下,查询速度才恢复正常。

WAL配置

WAL的配置也有几个要注意的:

conf 复制代码
# WAL基础配置
wal_level = replica                     # minimal/replica/logical
wal_buffers = 16MB                      # WAL缓冲区大小
wal_writer_delay = 200ms                # WAL Writer间隔

# WAL文件管理
min_wal_size = 1GB                      # 保留的WAL最小大小
max_wal_size = 2GB                      # 触发checkpoint的WAL大小

# 同步提交配置
synchronous_commit = on                 # 持久性级别
# on: 等待WAL写入并fsync(最安全)
# local: 写入但不等待fsync(较快)
# off: 不等待写入(最快,可能丢数据)

synchronous_commit这个参数很关键。设成on最安全但最慢,off最快但崩溃可能丢最近几个事务的数据。我们在生产环境都是用on,但在一些允许丢数据的场景(比如日志收集)会用off来提升性能。

AutoVacuum这个东西必须搞懂

AutoVacuum是PostgreSQL的清洁工,负责清理MVCC产生的死元组。刚开始我没太重视这个,后来吃了大亏。

graph TD Start[AutoVacuum Launcher
每1分钟醒来] --> Check[检查所有表统计] Check --> Calc{dead_tuples >
threshold + scale * live} Calc -->|是| Launch[启动Worker进程] Calc -->|否| Sleep[继续休眠 1分钟] Launch --> Worker[Worker扫描表] Worker --> Clean[标记死元组空间] Clean --> FSM[更新Free Space Map] FSM --> VM[更新Visibility Map] VM --> Stats[更新统计信息] Stats --> Done[Worker完成] Done --> Sleep Sleep --> Check style Start fill:#4facfe,stroke:#333,stroke-width:2px style Worker fill:#95e1d3,stroke:#333,stroke-width:2px style Done fill:#ffe66d,stroke:#333

AutoVacuum默认每分钟检查一次,看哪些表需要清理。触发条件是:

复制代码
dead_tuples > 50 + 0.2 * live_tuples

比如10000行的表,需要积累50 + 2000 = 2050个死元组才触发。对于频繁更新的表,这个阈值可能太高了。

我们有张订单表,每秒几百个UPDATE,结果AutoVacuum总是跟不上,表越来越大。后来给这张表单独调了参数:

sql 复制代码
ALTER TABLE orders SET (
    autovacuum_vacuum_scale_factor = 0.02,  -- 从0.2降到0.02
    autovacuum_vacuum_threshold = 100
);

这样10000行的表只需要100 + 200 = 300个死元组就触发,膨胀问题基本解决了。

监控AutoVacuum执行情况也很重要:

sql 复制代码
SELECT 
    schemaname, 
    tablename,
    last_autovacuum,
    last_autoanalyze,
    n_live_tup,
    n_dead_tup,
    round(
        n_dead_tup * 100.0 / 
        NULLIF(n_live_tup, 0), 
        2
    ) AS dead_percent
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

如果发现某些表的last_autovacuum是很久以前,或者dead_percent很高,就得关注了。

还有一点,长事务会阻止VACUUM清理。我们遇到过有个分析查询跑了几个小时,期间所有的VACUUM都无法清理这个查询可见的旧版本,导致表膨胀。所以尽量避免长时间的事务。

几个实用的监控

生产环境跑PostgreSQL,监控是必不可少的。

连接数监控

sql 复制代码
SELECT 
    count(*) AS current,
    current_setting('max_connections')::int AS max,
    round(
        count(*)::numeric / 
        current_setting('max_connections')::numeric * 100, 
        1
    ) AS usage_percent
FROM pg_stat_activity;

如果usage_percent经常超过80%,要么增大max_connections,要么考虑用连接池(比如PgBouncer)。我们现在都用PgBouncer,几千个应用连接对应几十个数据库连接,省资源。

找出慢查询

sql 复制代码
SELECT 
    pid,
    now() - query_start AS duration,
    state,
    substring(query, 1, 50) AS query
FROM pg_stat_activity
WHERE state != 'idle'
  AND query_start < now() - interval '5 seconds'
ORDER BY duration DESC;

这个查询能找出运行超过5秒的SQL。我们在生产环境设置了log_min_duration_statement = 1000,自动记录超过1秒的查询。

锁等待检查

sql 复制代码
SELECT 
    pid, 
    wait_event_type, 
    wait_event, 
    query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';

如果发现有查询长时间等锁,可能是有长事务或者死锁。找到阻塞的进程后,必要时可以用pg_terminate_backend杀掉。

磁盘空间

sql 复制代码
-- 查看最大的表
SELECT 
    schemaname,
    tablename,
    pg_size_pretty(
        pg_total_relation_size(
            schemaname||'.'||tablename
        )
    ) AS total_size
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(
    schemaname||'.'||tablename
) DESC
LIMIT 10;

-- 查看WAL占用
SELECT 
    pg_size_pretty(sum(size)) 
FROM pg_ls_waldir();

我们有次WAL目录把磁盘撑爆了,原因是archive_command配置错误,WAL文件归档失败堆积。后来修复了归档命令,老的WAL才被清理掉。

踩过的一些坑

连接数耗尽

症状是应用报错"FATAL: sorry, too many clients already"。查了一下发现有几百个idle连接不释放,原来是应用的连接池配置有问题,连接泄露了。

临时解决办法是杀掉空闲太久的连接:

sql 复制代码
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle'
  AND now() - state_change > interval '2 hours';

长期解决还是要修应用代码,确保连接正确释放。

查询突然变慢

有次线上查询突然慢了10倍,排查发现统计信息过期了,优化器选错了执行计划。手动ANALYZE后恢复正常:

sql 复制代码
ANALYZE table_name;

后来把autovacuum_analyze_scale_factor调小了,确保统计信息及时更新。

表膨胀

前面提到过,MVCC会产生死元组。如果AutoVacuum跟不上,表会越来越大,查询越来越慢。除了调整AutoVacuum参数,严重的时候可能需要VACUUM FULL:

sql 复制代码
VACUUM FULL table_name;

但要注意VACUUM FULL会锁表重写,生产环境要慎用。我们一般会在低峰期做,或者用pg_repack这种在线重建的工具。

一些建议

用了几年PostgreSQL,总结一些经验:

启用AutoVacuum并定期检查执行情况,这个真的很重要。定期ANALYZE保持统计信息准确,特别是数据变化大的表。

使用连接池管理连接,别让应用直连数据库。我们用PgBouncer,效果挺好。

监控缓存命中率,保持在99%以上。如果太低说明shared_buffers不够或者查询模式有问题。

避免长事务,它会阻止VACUUM清理,导致表膨胀。我们现在规定分析类查询必须在只读副本上跑。

检查表膨胀率,及时处理超过20%的表。设置慢查询日志,log_min_duration_statement = 1000能帮你找到性能瓶颈。

在测试环境验证所有配置变更,有些参数改错了可能导致性能下降甚至启动失败。

建立完善的备份和恢复策略,定期演练恢复流程。我们用的是pg_basebackup做基础备份,加上WAL归档做增量。

Checkpoint机制也得了解下

Checkpoint是PostgreSQL的一个重要机制,它负责把内存中的脏页全部刷到磁盘,创建一个一致性检查点。崩溃恢复的时候,只需要从最近的checkpoint开始重放WAL就行了。

graph LR Start[Checkpoint触发] --> Scan[扫描Shared Buffers] Scan --> Find[找出所有脏页] Find --> Sort[按文件/页号排序] Sort --> Write[顺序写入磁盘] Write --> Sync[fsync强制刷盘] Sync --> Update[更新pg_control] Update --> Done[Checkpoint完成] style Start fill:#ff6b6b,stroke:#333,stroke-width:2px style Write fill:#4ecdc4,stroke:#333,stroke-width:2px style Done fill:#95e1d3,stroke:#333

Checkpoint的触发条件有几个:

  • 达到checkpoint_timeout时间(默认5分钟)
  • WAL日志达到max_wal_size大小(默认1GB)
  • 手动执行CHECKPOINT命令
  • 数据库关闭时

Checkpoint配置要小心:

conf 复制代码
checkpoint_timeout = 10min              # 检查点最大间隔
checkpoint_completion_target = 0.9      # 在90%时间内完成

# 这个配置的意思是:
# 如果10分钟触发checkpoint,那会在9分钟内慢慢把脏页写完
# 这样可以平滑IO压力,不会一下子涌入大量写请求

我们之前checkpoint_completion_target设成0.1,结果checkpoint时IO瞬间打满,业务查询都变慢了。改成0.9后,IO压力平滑了很多。

可以在日志里看checkpoint的执行情况:

conf 复制代码
log_checkpoints = on

日志会输出类似这样的信息:

ini 复制代码
LOG:  checkpoint starting: time
LOG:  checkpoint complete: wrote 1953 buffers (11.9%); 
      0 WAL file(s) added, 0 removed, 1 recycled; 
      write=8.505 s, sync=0.024 s, total=8.556 s; 
      sync files=7, longest=0.013 s, average=0.003 s

如果看到checkpoint频繁或者时间很长,可能需要调整参数。

后台进程的协作

几个后台进程其实是配合工作的,理解它们的关系能更好地调优。

sequenceDiagram participant BE as Backend进程 participant SB as Shared Buffers participant BW as Background Writer participant CK as Checkpointer participant WW as WAL Writer participant WF as WAL Files participant DF as Data Files Note over BE,DF: 正常写入流程 BE->>SB: 修改数据页(标记脏页) BE->>WF: 写WAL日志 Note over BW: 每200ms醒来 BW->>SB: 扫描脏页 BW->>DF: 异步写入部分脏页 Note right of BW: 减轻checkpoint压力 Note over WW: 每200ms醒来 WW->>WF: 刷WAL缓冲 Note over CK: 每10分钟或WAL满 CK->>SB: 扫描所有脏页 CK->>DF: 全部强制写入 CK->>DF: fsync确保落盘 Note right of CK: 创建一致性检查点

Background Writer是个好帮手,它会提前把一些脏页写掉,这样checkpoint到来的时候压力就小多了。但它不会太激进,有个成本控制:

conf 复制代码
bgwriter_delay = 200ms                  # 扫描间隔
bgwriter_lru_maxpages = 100             # 每次最多写100页
bgwriter_lru_multiplier = 2.0           # 成本倍数

我们根据服务器的IO能力调整了这些参数。SSD的话可以把bgwriter_lru_maxpages调大一些,让它写得更积极。

进程间通信

PostgreSQL的进程间通信主要靠共享内存和信号量。

所有进程都能访问共享内存,读写数据页、WAL缓冲、锁表等。但为了避免冲突,访问共享资源时需要用轻量锁(LWLock)或自旋锁(SpinLock)保护。

信号量用于进程间的同步,比如一个Backend等待另一个Backend释放锁。

查看当前进程信息:

sql 复制代码
SELECT 
    pid,
    backend_type,
    backend_start,
    state,
    wait_event_type,
    wait_event
FROM pg_stat_activity
ORDER BY backend_start;

backend_type会显示进程类型:

  • client backend:客户端连接的Backend进程
  • autovacuum worker:AutoVacuum工作进程
  • background writer:后台写进程
  • checkpointer:检查点进程
  • walwriter:WAL写进程

如果看到很多进程的wait_event_type是Lock,说明锁竞争比较严重,可能需要优化业务逻辑。

一些调试技巧

遇到问题的时候,有几个调试方法挺好用。

查看进程在干什么

sql 复制代码
SELECT 
    pid,
    usename,
    application_name,
    client_addr,
    backend_start,
    state,
    state_change,
    query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start;

能看到每个活跃进程正在执行的SQL,执行了多久。

查看锁等待关系

sql 复制代码
SELECT 
    blocked.pid AS blocked_pid,
    blocked.query AS blocked_query,
    blocking.pid AS blocking_pid,
    blocking.query AS blocking_query
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking 
    ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;

这个能找出谁阻塞了谁,对排查锁等待很有用。

查看表和索引的使用情况

sql 复制代码
-- 表扫描统计
SELECT 
    schemaname,
    tablename,
    seq_scan,           -- 顺序扫描次数
    seq_tup_read,       -- 顺序扫描读取的行数
    idx_scan,           -- 索引扫描次数
    idx_tup_fetch       -- 索引扫描读取的行数
FROM pg_stat_user_tables
ORDER BY seq_scan DESC;

如果某个表的seq_scan很高但没有idx_scan,可能需要加索引。

查看索引大小和使用情况

sql 复制代码
SELECT 
    schemaname,
    tablename,
    indexname,
    idx_scan,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY idx_scan;

idx_scan为0的索引可能是无用索引,占着空间还拖慢写入速度,可以考虑删掉。

分析慢查询

sql 复制代码
-- 启用慢查询日志
ALTER SYSTEM SET log_min_duration_statement = 1000;  -- 记录超过1秒的查询
SELECT pg_reload_conf();

-- 或者临时设置
SET log_min_duration_statement = 1000;

慢查询会记录到日志文件,可以用pgBadger这种工具分析。

复制和高可用

PostgreSQL的流复制也是基于WAL的。主库把WAL日志流式传输给备库,备库不断重放WAL来保持同步。

graph LR Primary[主库] -->|WAL Stream| Standby1[备库1] Primary -->|WAL Stream| Standby2[备库2] Primary -.Archive.-> Archive[WAL归档] Archive -.Restore.-> Standby1 Archive -.Restore.-> Standby2 style Primary fill:#ff6b6b,stroke:#333,stroke-width:3px style Standby1 fill:#4ecdc4,stroke:#333,stroke-width:2px style Standby2 fill:#4ecdc4,stroke:#333,stroke-width:2px

主库需要配置:

conf 复制代码
wal_level = replica                     # 启用复制级别WAL
max_wal_senders = 10                    # 最大WAL发送进程数
wal_keep_size = 1GB                     # 保留的WAL大小

备库通过一个叫WAL Receiver的进程接收WAL,然后由Startup进程重放。备库可以设置成hot standby模式,允许只读查询:

conf 复制代码
hot_standby = on                        # 允许备库只读

我们用备库做查询分流,把报表、分析这些查询都扔到备库上,减轻主库压力。不过要注意,备库重放WAL的时候可能和只读查询冲突,需要调整:

conf 复制代码
max_standby_streaming_delay = 30s       # 备库查询最多延迟重放30秒

如果备库查询时间太长,可能导致主备延迟增大。

一些性能优化的思路

最后说说性能优化,这块其实没有银弹,要具体问题具体分析。

索引优化

索引是最直接有效的优化手段。但索引不是越多越好,每个索引都会拖慢写入速度。我们的原则是:

  • 经常用于WHERE、JOIN的列建索引
  • 经常用于ORDER BY的列建索引
  • 高选择性的列建索引(值分布比较均匀的)
  • 定期检查无用索引,及时删除

查询优化

用EXPLAIN ANALYZE看执行计划,找出瓶颈:

  • 全表扫描能否改成索引扫描
  • 嵌套循环能否改成哈希连接
  • 是否可以加条件减少扫描的行数

有时候改写SQL比加索引效果更好。比如用EXISTS替代IN,用JOIN替代子查询等。

分区表

数据量特别大的表可以考虑分区。我们有张日志表,按月分区,查询只扫描相关分区,速度快很多:

sql 复制代码
CREATE TABLE logs (
    id bigint,
    created_at timestamp,
    message text
) PARTITION BY RANGE (created_at);

CREATE TABLE logs_2024_01 PARTITION OF logs
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
    
CREATE TABLE logs_2024_02 PARTITION OF logs
    FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

分区表的维护比较麻烦,需要定期创建新分区、删除旧分区。我们写了个定时任务自动管理。

连接池

前面提过,用PgBouncer这种连接池能大大减少数据库的连接开销。PgBouncer支持三种池化模式:

  • session:整个会话使用同一个数据库连接
  • transaction:每个事务使用一个连接
  • statement:每个语句使用一个连接

我们用的是transaction模式,兼顾了连接复用和事务隔离。

读写分离

把只读查询分流到备库,可以减轻主库压力。但要注意主备延迟,如果业务对数据一致性要求高,可能不适合读写分离。

我们的做法是,实时性要求高的查询走主库,报表、分析这些可以容忍几秒延迟的走备库。

写在最后

PostgreSQL的进程架构和内存管理其实还有很多细节,这篇文章只是覆盖了主要的部分。真正要用好PostgreSQL,还需要在实践中不断摸索和积累经验。

有些东西我也还在研究,比如JIT编译、并行查询、逻辑复制等。这些特性在特定场景下能带来显著的性能提升,但也有各自的适用条件和限制。

建议多看官方文档,多做测试,多关注社区的讨论。遇到问题的时候,查日志、看执行计划、分析统计信息,大部分问题都能找到线索。

如果这篇文章对你理解PostgreSQL有帮助,那就太好了。有问题或者想交流的,欢迎留言。

相关推荐
q***18842 小时前
redis的下载和安装详解
数据库·redis·缓存
腾讯云数据库2 小时前
「腾讯云NoSQL」技术之向量数据库篇:腾讯云向量数据库如何实现召回不变,成本减半?
数据库·nosql·腾讯云·向量数据库·腾讯云nosql
yaso_zhang2 小时前
jetson开机之前自启脚本sudo ifconfig 如何不需要输入密码
数据库·postgresql
aoxiang_ywj2 小时前
SQLite 速成学习
数据库·sqlite
普普通通的南瓜3 小时前
网站提示 “不安全”?免费 SSL 证书一键解决
网络·数据库·网络协议·算法·安全·iphone·ssl
张3蜂3 小时前
import org.junit.Test; 是什么
数据库·junit
不惑_3 小时前
[特殊字符] 在 macOS 上设置 SQLite
数据库·macos·sqlite
不惑_3 小时前
[特殊字符] 在 Windows 上设置 SQLite
数据库·windows·sqlite
上线就吃代码3 小时前
【等保测评】数据库数据库配置have_ssl参数为yes
服务器·数据库·ssl