数据库基础体系 · 第 77/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

数据库可观测性:连接、事务、锁、执行计划、复制和 SLO

数据库问题通常不会以“数据库坏了”的形式出现,而是表现为接口延迟升高、连接池耗尽、事务堆积、锁等待、CPU 或 IO 饱和、只读副本延迟、查询结果变旧,最终触发业务 SLO 违约。

要定位这类问题,不能只看一个“慢查询排行榜”。一次请求可能经历:

Trequest=Tqueue+Tconnection+Ttransaction+Tlock+Tplan+Texecute+Treplication+TnetworkT_{\text{request}} = T_{\text{queue}} + T_{\text{connection}} + T_{\text{transaction}} + T_{\text{lock}} + T_{\text{plan}} + T_{\text{execute}} + T_{\text{replication}} + T_{\text{network}}

这里的各项并不总是串行发生,但这个分解有助于回答一个关键问题:延迟究竟消耗在哪个阶段。数据库可观测性要做的,就是把这些阶段映射到可查询的状态、指标、日志和链路上下文中,并将它们与业务 SLO 连接起来。

本文以 PostgreSQL 和 MySQL 8.4 的公开稳定语义为边界。两者的 SQL、事务隔离、锁监控和复制状态并不相同,示例会明确引擎和部署边界。


一、先建立正确的可观测性模型

1.1 监控、日志、追踪和剖析分别回答什么问题

数据库可观测性常被简化成“采集几个指标”,但不同信号回答的问题不同。

信号 适合回答的问题 典型内容
指标 Metrics 是否持续变差、影响范围多大 QPS、连接数、事务数、锁等待、复制延迟、缓存命中率
日志 Logs 某次事件发生了什么 慢 SQL、死锁、连接失败、复制错误、执行计划变化
追踪 Traces 一次请求在哪个环节耗时 trace ID、数据库 span、SQL 模板、等待时间
剖析 Profiles CPU、内存或 IO 消耗在什么代码路径 数据库进程、应用进程或系统调用的采样信息

例如,接口 P99 从 100 ms 上升到 2 s:

  • 指标可以证明上升发生在什么时候,以及是否集中在某个实例;
  • 追踪可以确认 1.8 s 是否花在数据库调用;
  • 数据库活动视图可以确认 SQL 是运行、等待锁,还是排队获取连接;
  • 执行计划可以判断是否发生了扫描方式变化;
  • 日志可以提供死锁、复制断连或 SQL 错误的具体事件。

单独看任意一项都可能误导。例如:

  • 平均查询延迟正常,但 P99 被少量锁等待拉高;
  • CPU 不高,但连接池都在等待数据库连接;
  • 数据库执行时间正常,但应用在事务提交后等待同步复制确认;
  • SQL 文本相同,但参数分布变化导致实际行数远超估算行数;
  • 副本延迟为零,但副本上执行的是旧事务快照,业务读取仍不满足实时性要求。

1.2 观测对象必须带有边界

同一个指标如果没有边界,通常无法解释。

至少要区分:

  • 数据库实例;
  • 数据库节点角色:主库、备库、只读副本;
  • 数据库、schema、表或索引;
  • 用户、应用服务、连接池;
  • SQL 模板,而不是包含敏感参数的完整 SQL;
  • 事务;
  • 时间窗口;
  • 地域、租户或业务操作类型。

SQL 统计应优先按“归一化模板”聚合。例如:

SELECT * FROM orders WHERE user_id = 1001;
SELECT * FROM orders WHERE user_id = 1002;

应该归为同一个模板:

SELECT * FROM orders WHERE user_id = ?;

否则高基数 SQL 文本会污染监控系统,也可能泄露个人数据。

1.3 观测本身会改变系统

数据库观测不是零成本的:

  • 过于频繁地查询活动视图,会增加管理开销;
  • 开启完整 SQL 日志,会增加磁盘 IO 和日志处理成本;
  • EXPLAIN ANALYZE 会实际执行查询;
  • 在生产环境对写 SQL 使用 EXPLAIN ANALYZE,可能真的修改数据;
  • 采集完整参数可能违反隐私和合规要求;
  • 高基数标签会导致指标系统内存和查询成本上升。

因此,观测需要区分:

  1. 常驻低成本指标;
  2. 发生异常时启用的增强日志;
  3. 对单条查询进行的临时诊断;
  4. 线下复现和压测。

二、连接:连接数不是并发量,连接池也不是容量

2.1 数据库连接的生命周期

一次典型数据库连接经历以下状态:

  1. 应用创建连接;
  2. 数据库完成认证和会话初始化;
  3. 连接进入空闲状态,等待请求;
  4. 应用发送 SQL;
  5. 数据库执行 SQL,可能等待锁、IO 或 CPU;
  6. 事务提交或回滚;
  7. 连接回到连接池,或被关闭。

因此,连接数至少要拆成:

Ctotal=Cactive+Cidle+Cidle in transaction+CwaitingC_{\text{total}} = C_{\text{active}} + C_{\text{idle}} + C_{\text{idle in transaction}} + C_{\text{waiting}}

其中:

  • active:正在执行 SQL;
  • idle:连接存在但没有当前 SQL;
  • idle in transaction:事务已经打开,但当前没有执行 SQL;
  • waiting:应用、连接池或数据库内部正在等待资源。

“连接数低”并不等于系统健康。比如 100 个连接中,99 个处于锁等待,数据库连接数并不高,但业务已经无法完成请求。

2.2 连接池为什么可能放大故障

假设数据库能够同时有效处理 32 个活跃查询,应用连接池却配置了 500 个连接。当某个慢查询或锁冲突使每个查询占用连接的时间变长时,500 个连接不会提高数据库处理能力,反而可能造成:

  • 上下文切换增加;
  • 内存占用增加;
  • CPU 在大量会话之间调度;
  • 锁竞争扩大;
  • 新请求在应用连接池中排队;
  • 数据库达到连接上限,管理连接或高优先级请求也无法进入。

可以用排队模型理解:

L=λWL = \lambda W

其中:

  • LL 是系统内平均请求数;
  • λ\lambda 是平均到达率;
  • WW 是请求平均停留时间。

当查询平均耗时 WW 增长时,在到达率 λ\lambda 不变的情况下,系统内同时占用的连接数 LL 必然增加。连接池看到的是“连接越来越忙”,而数据库看到的是“更多会话同时等待”。

连接池大小应基于数据库可承受的活跃并发、查询类型、CPU 核数、IO 能力和事务时长测试,而不是简单设置成“越大越好”。

2.3 PostgreSQL:从活动视图识别连接状态

在 PostgreSQL 中,pg_stat_activity 展示服务器进程对应的会话状态。以下查询用于查看当前连接、SQL 状态和等待信息:

SELECT
    pid,
    usename,
    datname,
    application_name,
    client_addr,
    state,
    wait_event_type,
    wait_event,
    xact_start,
    query_start,
    state_change,
    query
FROM pg_stat_activity
WHERE backend_type = 'client backend'
ORDER BY query_start NULLS LAST;

关键字段含义:

  • state = 'active':当前正在执行查询;
  • state = 'idle':当前没有事务或查询;
  • state = 'idle in transaction':事务已打开,但当前没有执行查询;
  • state = 'idle in transaction (aborted)':事务中某条语句失败,事务处于失败状态,只能回滚;
  • wait_event_typewait_event:当前等待事件。state = 'active' 并不代表正在消耗 CPU,也可能正在等待锁或 IO;
  • xact_start:当前事务开始时间;
  • query_start:当前查询开始时间。

找出长时间保持打开的事务:

SELECT
    pid,
    usename,
    datname,
    application_name,
    client_addr,
    now() - xact_start AS xact_age,
    now() - state_change AS idle_age,
    state,
    query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

这里的风险不是“连接闲置”本身,而是事务快照和锁可能长期存在。一个事务即使没有持续执行 SQL,也可能:

  • 阻止 VACUUM 回收旧版本;
  • 保持行锁或表锁;
  • 使表膨胀;
  • 让其他事务读取或写入等待;
  • 长期占用连接池中的连接。

2.4 MySQL:进程列表与 Performance Schema

MySQL 可以先用:

SHOW FULL PROCESSLIST;

查看连接、命令、耗时、状态和当前 SQL。生产诊断通常还应使用 Performance Schema,因为它提供更结构化的等待和语句统计。

例如查看当前线程和事件:

SELECT
    THREAD_ID,
    PROCESSLIST_ID,
    PROCESSLIST_USER,
    PROCESSLIST_HOST,
    PROCESSLIST_DB,
    PROCESSLIST_COMMAND,
    PROCESSLIST_TIME,
    PROCESSLIST_STATE,
    PROCESSLIST_INFO
FROM performance_schema.threads
WHERE TYPE = 'FOREGROUND';

Performance Schema 是否启用、采集哪些消费者和 instrument,取决于实例配置。不能假定所有历史事件都已采集。需要检查相关配置和表是否可用。

2.5 连接故障的分层诊断

“连接失败”至少有四层:

  1. 网络层:DNS、路由、防火墙、端口;
  2. 协议和认证层:TLS、用户名、密码、认证插件;
  3. 数据库资源层:达到连接上限、进程或线程资源耗尽;
  4. 应用池层:连接泄漏、池配置过小、连接归还不及时。

生产排查顺序应先区分:

  • 是新连接建立失败;
  • 还是连接建立成功但获取不到连接;
  • 还是拿到连接后 SQL 执行变慢;
  • 还是 SQL 已完成但提交或网络读取变慢。

这四种情况的修复方向完全不同。


三、事务:从快照、锁和提交理解延迟

3.1 事务不是一组 SQL 的简单包装

事务提供的是一组原子性和隔离语义。常见抽象包括:

  • 原子性:事务中的修改要么全部生效,要么全部回滚;
  • 一致性:提交前后满足数据库约束和应用要求;
  • 隔离性:并发事务之间按照隔离级别观察彼此;
  • 持久性:提交成功后,系统应按其持久化语义保留结果。

数据库可观测性尤其关心两个时间:

Ttransaction=Tfirst statementTcommit/rollbackT_{\text{transaction}} = T_{\text{first statement}} \rightarrow T_{\text{commit/rollback}}

以及:

Tstatement=Tparse/prepare+Tlock wait+Texecution+Tcommit-related waitT_{\text{statement}} = T_{\text{parse/prepare}} + T_{\text{lock wait}} + T_{\text{execution}} + T_{\text{commit-related wait}}

一个事务可能没有慢 SQL,却因为应用在两条 SQL 之间执行了远程调用而持续数十秒。这类问题通常表现为长事务和锁、版本回收堆积。

3.2 MVCC 与“读不加锁”不是同一件事

PostgreSQL 和 InnoDB 都使用多版本并发控制(MVCC),但具体实现和语义不同。

MVCC 的基本思路是:写入不会简单地覆盖所有读者正在观察的版本,读操作根据自己的快照选择可见版本。这样,普通读通常不必等待普通写。

但这不意味着“读永远不加锁”:

  • SELECT ... FOR UPDATE 会锁定将要修改的行;
  • DDL 可能需要表级锁;
  • 外键检查、唯一性检查可能产生锁等待;
  • UPDATEDELETE 需要锁定目标行;
  • 某些访问路径和隔离级别会产生谓词或间隙相关的并发效果。

3.3 隔离级别必须结合引擎理解

以默认设置为例:

  • PostgreSQL 默认是 READ COMMITTED
  • InnoDB 默认是 REPEATABLE READ

这不是说两个引擎的行为可以简单用名称替换。隔离级别描述的是一组并发可见性和异常保证,具体结果还取决于语句类型、快照创建时机、锁定读和引擎实现。

一个重要区别是“普通一致性读”和“锁定读”:

  • PostgreSQL 中,SELECT 的快照语义与 SELECT ... FOR UPDATE 不同;
  • InnoDB 中,普通一致性读通常读取快照,而锁定读可能使用当前读并取得记录或范围相关的锁。

不能仅凭“我执行了 SELECT,所以不会阻塞”作出判断。

3.4 完整锁等待示例:PostgreSQL

先创建测试表:

CREATE TABLE account (
    id bigint PRIMARY KEY,
    balance numeric NOT NULL
);

INSERT INTO account VALUES (1, 100);

打开会话 A:

BEGIN;

UPDATE account
SET balance = balance - 10
WHERE id = 1;

此时会话 A 尚未提交,仍持有该行相关锁。

打开会话 B:

BEGIN;

UPDATE account
SET balance = balance + 10
WHERE id = 1;

会话 B 会等待,因为它要修改同一行。此时在第三个会话执行:

SELECT
    a.pid,
    a.usename,
    a.state,
    a.wait_event_type,
    a.wait_event,
    now() - a.query_start AS query_age,
    pg_blocking_pids(a.pid) AS blocking_pids,
    a.query
FROM pg_stat_activity AS a
WHERE a.datname = current_database()
  AND a.wait_event_type IS NOT NULL;

可能看到 B 的 wait_event_typeLockpg_blocking_pids(a.pid) 返回 A 的 PID。具体等待事件名称和其他字段会因版本、锁类型和当前状态而不同,因此诊断时应重点看等待类别和阻塞关系,而不是硬编码某个字符串。

进一步查看锁:

SELECT
    l.pid,
    l.locktype,
    l.mode,
    l.granted,
    l.relation::regclass AS relation_name,
    l.page,
    l.tuple,
    l.transactionid,
    l.virtualxid
FROM pg_locks AS l
WHERE l.pid IN (<会话A_PID>, <会话B_PID>)
ORDER BY l.pid, l.granted;

granted = false 表示请求中的锁尚未获得。这里的 <会话A_PID><会话B_PID> 需要替换成实际 PID,不能直接作为 SQL 运行。

会话 A 执行:

COMMIT;

之后会话 B 才能继续执行。若会话 A 执行:

ROLLBACK;

会话 B 也会解除等待,但最终余额变化不同。

这个例子说明:

  1. B 的 SQL 文本本身可能很快;
  2. B 的执行计划可能完全正常;
  3. B 的延迟主要来自锁等待;
  4. 只查看 CPU、扫描行数或平均 SQL 耗时,可能找不到原因;
  5. 真正的根因可能是 A 的事务范围过大,而不是 B 的 UPDATE 写法错误。

3.5 MySQL InnoDB 的锁等待

在 MySQL 8.4 中,应优先使用 Performance Schema 的 InnoDB 锁表观察等待关系。常见表包括:

  • performance_schema.data_locks:当前 InnoDB 锁;
  • performance_schema.data_lock_waits:等待锁与阻塞锁的关系;
  • performance_schema.events_transactions_current:当前事务事件;
  • performance_schema.events_statements_current:当前语句事件。

可以先查看当前锁等待关系:

SELECT
    r.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx_id,
    r.BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx_id,
    r.REQUESTING_THREAD_ID AS waiting_thread_id,
    r.BLOCKING_THREAD_ID AS blocking_thread_id,
    r.REQUESTING_ENGINE_LOCK_ID AS waiting_lock_id,
    r.BLOCKING_ENGINE_LOCK_ID AS blocking_lock_id
FROM performance_schema.data_lock_waits AS r;

再关联锁详情:

SELECT
    ENGINE_TRANSACTION_ID,
    THREAD_ID,
    EVENT_ID,
    OBJECT_SCHEMA,
    OBJECT_NAME,
    INDEX_NAME,
    LOCK_TYPE,
    LOCK_MODE,
    LOCK_STATUS,
    LOCK_DATA
FROM performance_schema.data_locks
ORDER BY ENGINE_TRANSACTION_ID, THREAD_ID;

LOCK_STATUS 用于区分已授予和等待中的锁,LOCK_MODELOCK_TYPELOCK_DATA 帮助判断是记录锁、间隙相关锁、表锁还是其他 InnoDB 锁。

如果需要快速查看 InnoDB 的死锁和最近锁等待信息,也可以使用:

SHOW ENGINE INNODB STATUS\G

其输出是诊断文本,不适合作为稳定机器接口。自动化监控应优先使用 Performance Schema,并确认相关采集项已启用。

3.6 死锁与长等待不是一回事

锁等待是等待者暂时无法继续,通常在阻塞事务提交或回滚后恢复。

死锁是等待关系形成环:

T1T2T1T_1 \rightarrow T_2 \rightarrow T_1

例如:

  • A 先锁住行 1,再请求行 2;
  • B 先锁住行 2,再请求行 1。

两者互相等待,任何一个都无法自然继续。数据库通常会检测死锁并主动回滚其中一个事务,但具体选择规则是引擎实现细节,不能依赖某个固定事务一定会被回滚。

避免和处理死锁需要同时做两件事:

  1. 让所有事务以一致顺序访问资源;
  2. 应用正确处理死锁错误并重试整个事务。

不能只重试失败的单条 SQL,因为事务已经可能被回滚或处于失败状态。重试应包含完整的业务事务,并限制次数和退避时间。

3.7 PostgreSQL 失败事务状态是常见陷阱

在 PostgreSQL 中,如果事务中的一条语句失败,事务通常进入 aborted 状态。例如:

BEGIN;

INSERT INTO account(id, balance) VALUES (1, 50);
-- 因主键冲突失败

SELECT * FROM account;
-- 会继续报错:当前事务已中止

必须执行:

ROLLBACK;

才能恢复会话,或者在显式保存点的情况下回滚到保存点:

BEGIN;
SAVEPOINT before_insert;

-- 可能失败的语句

ROLLBACK TO SAVEPOINT before_insert;
-- 事务仍可继续
COMMIT;

如果连接池把这种连接直接归还而没有回滚,后续请求可能反复遇到“当前事务已中止”。因此连接归还前必须清理事务状态。


四、执行计划:估算、实际执行与等待必须分开

4.1 执行计划是什么

执行计划是优化器为 SQL 选择的执行策略,通常包括:

  • 表扫描方式;
  • 索引访问方式;
  • 过滤位置;
  • Join 顺序;
  • Join 算法;
  • 聚合和排序方式;
  • 并行执行;
  • 估算成本和估算行数。

优化器通常不会穷举所有可能计划,而是依据统计信息和成本模型选择候选方案。因此“计划成本低”不是“真实耗时低”的规范保证,而是当前模型下的估计。

4.2 一个可运行的 PostgreSQL 计划示例

以下示例在 PostgreSQL 中创建订单表并生成数据:

CREATE TABLE orders (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id bigint NOT NULL,
    status text NOT NULL,
    created_at timestamptz NOT NULL,
    amount numeric NOT NULL
);

INSERT INTO orders(user_id, status, created_at, amount)
SELECT
    (g % 10000) + 1,
    CASE WHEN g % 10 = 0 THEN 'cancelled' ELSE 'paid' END,
    now() - (g % 365) * interval '1 day',
    (g % 1000) + 1
FROM generate_series(1, 200000) AS g;

ANALYZE orders;

查询某个用户近期已支付订单:

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT id, amount, created_at
FROM orders
WHERE user_id = 42
  AND status = 'paid'
  AND created_at >= now() - interval '30 days'
ORDER BY created_at DESC
LIMIT 20;

重要字段包括:

  • cost=起始成本..总成本:优化器成本单位,不是毫秒;
  • rows:估算输出行数;
  • actual time:实际执行时间;
  • actual rows:实际输出行数;
  • loops:节点实际执行次数;
  • Buffers:命中缓存和读取数据页的情况;
  • Planning Time:生成计划所需时间;
  • Execution Time:执行完成时间。

判断估算是否失真时,不能只比较一个数字,应结合循环次数:

实际访问规模actual rows×loops\text{实际访问规模} \approx \text{actual rows} \times \text{loops}

例如某节点估算 rows=10,实际 rows=100000,且 loops=1,说明估算偏小四个数量级,可能导致优化器选择错误的 Join 或扫描方式。

ANALYZE 更新统计信息后,计划可能改变。它不会创建索引,也不会修复所有性能问题;如果数据分布复杂,单列统计信息仍然可能不足以描述列之间的相关性。

4.3 计划慢、执行慢和等待慢必须区分

一条 SQL 的观测时间可能包括三部分:

Tobserved=Tqueue/wait+Texecution+Tclient/networkT_{\text{observed}} = T_{\text{queue/wait}} + T_{\text{execution}} + T_{\text{client/network}}

EXPLAIN ANALYZE 主要告诉你数据库执行节点的实际情况,但不能自动解释:

  • 执行前是否在连接池排队;
  • 是否等待锁;
  • 客户端是否迟迟不读取结果;
  • 提交时是否等待 WAL 或复制确认;
  • 网络传输是否占用大量时间。

因此,执行计划需要与会话等待事件、事务日志、链路追踪和客户端耗时结合分析。

4.4 Join 算法与基数估计的关系

常见 Join 算法包括:

  • Nested Loop:外表每一行驱动内表查找,适合外表较小且内表有有效索引的情况;
  • Hash Join:构建一侧的哈希表,再扫描另一侧,适合等值 Join 和较大的输入;
  • Merge Join:对两侧有序输入进行合并,适合已有排序或可高效获得有序数据的情况。

优化器选择 Join 算法依赖估算的输入行数。如果估算外表只有 10 行,选择 Nested Loop 可能合理;但实际外表有 100 万行时,Nested Loop 可能变成灾难。

简化地说,Nested Loop 的工作量常近似为:

CNLNouter×Cinner lookupC_{\text{NL}} \approx N_{\text{outer}} \times C_{\text{inner lookup}}

Hash Join 的工作量常近似为:

CHashCbuild+CprobeC_{\text{Hash}} \approx C_{\text{build}} + C_{\text{probe}}

这不是精确成本公式,但表达了核心直觉:错误的外表基数会使 Nested Loop 的实际代价被成倍放大。

4.5 MySQL 的计划查看方式

MySQL 8.4 可以使用:

EXPLAIN FORMAT=TREE
SELECT id, amount, created_at
FROM orders
WHERE user_id = 42
  AND status = 'paid'
  AND created_at >= NOW() - INTERVAL 30 DAY
ORDER BY created_at DESC
LIMIT 20;

若要实际执行并获得运行时统计:

EXPLAIN ANALYZE
SELECT id, amount, created_at
FROM orders
WHERE user_id = 42
  AND status = 'paid'
  AND created_at >= NOW() - INTERVAL 30 DAY
ORDER BY created_at DESC
LIMIT 20;

EXPLAIN ANALYZE 会执行语句,因此对 INSERTUPDATEDELETE 使用时必须把它当作真实写操作处理。在生产环境中,通常先在隔离环境复现,或使用只读查询、事务回滚等经过验证的方式控制风险;不能假定所有引擎和语句都自动回滚。

MySQL 的 EXPLAIN 输出中常见的 rows 是估算行数,filtered 是估算通过条件的比例。它们不是实际结果。使用 EXPLAIN ANALYZE 后,应比较估算和实际行数,并检查是否出现:

  • 预期使用索引,实际全表扫描;
  • 估算行数远小于实际行数;
  • Join 顺序与数据分布不匹配;
  • 排序或临时表消耗过大;
  • 需要回表读取大量记录。

4.6 不要把“用了索引”当成优化完成

索引只是访问路径之一。以下情况中,“使用索引”仍可能很慢:

  • 选择性很低,索引扫描后要读取大量行;
  • 需要大量回表;
  • 排序无法利用索引顺序;
  • Join 的另一侧基数估算错误;
  • 频繁更新导致索引维护成本高;
  • 查询返回大量结果,网络传输成为主耗时;
  • 索引与过滤条件顺序不匹配;
  • 查询被锁等待或 IO 等待拖慢。

诊断时应使用完整计划、实际行数、缓冲区或 IO 统计,而不是只看 keyIndex Scan 等单个字段。

4.7 统计信息变化会导致计划回归

SQL 文本不变并不意味着计划不变。计划可能因以下原因变化:

  • 表数据量增长;
  • 数据分布改变;
  • 统计信息更新;
  • 索引新增或删除;
  • 参数值改变;
  • 内存、并行和成本参数变化;
  • 版本升级;
  • 预处理语句的参数化计划行为变化。

因此应记录:

  • SQL 模板;
  • 执行计划摘要或计划指纹;
  • 估算行数与实际行数;
  • 规划时间和执行时间;
  • 计划出现时间;
  • 数据库版本和相关参数。

这样才能区分“数据增长导致查询自然变慢”和“计划回归导致突然变慢”。


五、复制:复制延迟不是一个数字

5.1 复制数据流

数据库复制通常包含以下路径:

事务提交日志产生日志发送副本接收副本持久化副本重放业务可读\text{事务提交} \rightarrow \text{日志产生} \rightarrow \text{日志发送} \rightarrow \text{副本接收} \rightarrow \text{副本持久化} \rightarrow \text{副本重放} \rightarrow \text{业务可读}

不同阶段代表不同故障:

  • 主库产生日志慢:可能是写入、WAL 或 redo 生成压力;
  • 网络发送慢:链路、带宽或拥塞问题;
  • 副本接收但未持久化:副本 IO 或存储问题;
  • 已持久化但未重放:副本 CPU、锁冲突、长查询或 DDL 影响;
  • 重放完成但业务仍读不到:路由、连接池、缓存或读一致性策略问题。

因此只看“主库和副本时间差”是不够的。

5.2 PostgreSQL:物理流复制的 LSN 位置

PostgreSQL 物理流复制以 WAL 为基础。LSN(Log Sequence Number)是 WAL 中的位置标识。主库可以查看发送端状态:

SELECT
    pid,
    application_name,
    client_addr,
    state,
    sync_state,
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn,
    write_lag,
    flush_lag,
    replay_lag
FROM pg_stat_replication;

这些字段描述不同进度:

  • sent_lsn:主库发送到的位置;
  • write_lsn:副本已写入接收缓冲或 WAL 文件的位置;
  • flush_lsn:副本已持久化的位置;
  • replay_lsn:副本已重放、对查询可见的 WAL 位置;
  • sync_state:同步复制相关状态。

可以用 LSN 差值衡量字节级滞后:

SELECT
    application_name,
    pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)
        AS bytes_behind_replay
FROM pg_stat_replication;

这个值是“尚未重放的 WAL 字节量”,不是严格的时间延迟。WAL 产生速率变化时,同样的字节差可能对应完全不同的时间。

在副本上可以查看重放位置:

SELECT
    pg_is_in_recovery() AS in_recovery,
    pg_last_wal_receive_lsn() AS receive_lsn,
    pg_last_wal_replay_lsn() AS replay_lsn,
    pg_last_xact_replay_timestamp() AS last_replay_time;

pg_last_xact_replay_timestamp() 的时间差容易受到主库时钟、事务产生频率和时间戳语义影响,不能单独作为精确延迟指标。更可靠的做法是同时记录 LSN、接收位置、持久化位置和重放位置,并进行端到端读验证。

同步复制与提交延迟

在 PostgreSQL 中,如果配置了同步复制,提交可能等待同步副本确认。于是:

Tcommit=Tlocal flush+Tremote acknowledgementT_{\text{commit}} = T_{\text{local flush}} + T_{\text{remote acknowledgement}}

同步复制提高了某些故障条件下的数据持久性或可见性保证,但会把网络和副本状态引入主库提交延迟。副本网络抖动可能表现为主库业务写请求变慢,而不是副本查询变慢。

不能把“同步”理解为所有副本都实时可读,也不能把“异步延迟当前为零”理解为永久的数据一致性保证。

5.3 MySQL:复制线程和 GTID 进度

MySQL 8.4 使用 source/replica 术语。传统状态查看命令为:

SHOW REPLICA STATUS\G

重点关注的字段通常包括:

  • Replica_IO_Running:复制接收线程是否运行;
  • Replica_SQL_Running:复制应用线程是否运行;
  • Seconds_Behind_Source:复制状态报告的时间估计;
  • Source_Log_FileRead_Source_Log_Pos:接收进度;
  • Relay_Source_Log_FileExec_Source_Log_Pos:已执行进度;
  • Last_IO_ErrorLast_SQL_Error:最近的接收或应用错误;
  • GTID 相关字段:已接收、已执行或待执行的事务集合。

字段名称和含义应以具体复制配置、版本和 SHOW REPLICA STATUS 输出为准。尤其是 Seconds_Behind_Source

  • 可能为 NULL
  • 复制线程停止时不能表示正常延迟;
  • 在空闲写入期间不一定有有意义的时间差;
  • 多源、并行复制或复杂事务场景下可能不能精确反映业务可读延迟。

因此应同时监控:

  1. 接收线程是否运行;
  2. 应用线程是否运行;
  3. SQL 错误是否存在;
  4. GTID 或日志位置差;
  5. 副本端的实际重放和业务读验证。

5.4 复制延迟的故障路径

副本延迟常见路径包括:

路径一:副本 IO 能力不足

副本接收日志速度低于主库产生速度,表现为接收位置落后。可能原因:

  • 磁盘写入慢;
  • 网络带宽不足;
  • WAL/redo 量突然增加;
  • 副本资源被备份或其他任务占用。

路径二:副本应用能力不足

副本已经接收到日志,但重放速度低。可能原因:

  • 副本 CPU 不足;
  • 大事务导致重放长时间占用;
  • 并行复制配置不足或存在冲突;
  • 复制应用受锁、DDL 或长查询影响。

路径三:副本可读但业务读到旧数据

复制已经正常,但业务在写入主库后立即读取只读副本。此时读到旧值不一定是复制故障,而是读写分离下的会话一致性问题。

一种常见解决方式是“读自己的写”:

  1. 写请求在主库提交后获得一个位置标记,例如 PostgreSQL 的 LSN 或 MySQL 的 GTID;
  2. 后续读请求选择已经追上该位置的副本;
  3. 如果没有满足条件的副本,则回主库或等待有限时间;
  4. 超时后明确返回降级结果,而不是无限等待。

这是一种业务一致性策略,不是简单调大副本线程数就能解决的问题。


六、SLO:把数据库状态转化为用户可感知的目标

6.1 SLI、SLO 和错误预算

  • SLI(Service Level Indicator):实际测量的指标;
  • SLO(Service Level Objective):目标,例如一个月内 99.9% 的成功请求延迟不超过 300 ms;
  • SLA(Service Level Agreement):对外承诺,可能带有合同和赔偿责任;
  • 错误预算:允许不满足 SLO 的额度。

例如,30 天按 24 小时计算:

Tmonth=30×24×60=43200 minT_{\text{month}} = 30 \times 24 \times 60 = 43200\text{ min}

99.9% 可用性允许的不可用时间为:

Tbudget=43200×(10.999)=43.2 minT_{\text{budget}} = 43200 \times (1 - 0.999) = 43.2\text{ min}

这只是时间型可用性预算。对于数据库,更有意义的 SLI 通常还包括:

  • 请求成功率;
  • 写入延迟;
  • 查询延迟;
  • 锁等待时间;
  • 连接获取等待时间;
  • 复制新鲜度;
  • 数据库错误率;
  • 事务回滚率。

6.2 延迟 SLO 不应只看平均值

平均值无法表达长尾。一个窗口有 99 个请求耗时 20 ms,1 个请求耗时 10 s:

average=99×20+10000100=119.8 ms\text{average} = \frac{99 \times 20 + 10000}{100} = 119.8\text{ ms}

平均值看起来不高,但 P99 已经达到 10 s。对在线请求,通常应同时观察:

  • P50:典型体验;
  • P95:大多数请求的尾部;
  • P99 或更高分位:极端长尾;
  • 超过阈值的请求比例。

分位数不能简单跨实例、跨时间窗口求平均。正确做法是保留直方图桶或在统一样本集合上计算分位数。

6.3 数据库 SLO 的边界

数据库内部 SLO 和业务 SLO 不一定相同。

例如业务请求总延迟目标为 500 ms:

Tbusiness=Tapplication+Tqueue+Tdatabase+Tnetwork500 msT_{\text{business}} = T_{\text{application}} + T_{\text{queue}} + T_{\text{database}} + T_{\text{network}} \leq 500\text{ ms}

数据库不能直接把全部预算都占满。应用还需要处理序列化、网络、业务逻辑和外部依赖。因此应给数据库设定内部预算,例如数据库查询和提交只允许消耗业务请求预算的一部分。

但也不能机械地把所有数据库请求设成同一个目标:

  • 点查、写入、后台报表的延迟分布不同;
  • 主库写入和副本读取的一致性要求不同;
  • 事务提交可能包含同步复制等待;
  • 备份、批处理和在线请求不应共用同一个延迟目标。

6.4 复制新鲜度 SLO

对读副本,延迟 SLO 不应只写成“副本延迟小于 5 秒”,而应明确测量对象:

  • 日志接收延迟;
  • 日志持久化延迟;
  • 日志重放延迟;
  • 业务数据可读延迟;
  • 写入主库后,指定读请求读到新值的时间。

例如:

99.9% 的用户订单查询,在订单写入主库成功后 2 秒内,能够从读路径读取到该订单。

这个目标比“副本 Seconds_Behind_Source 小于 2”更接近用户体验,但需要端到端探针验证。探针应使用专门的数据,避免把测试写入混入正常业务统计。

6.5 错误预算如何指导取舍

如果数据库 SLO 连续违约,错误预算被消耗,工程决策应倾向于降低变更风险:

  • 暂停非必要索引变更和大规模迁移;
  • 降低批处理并发;
  • 推迟高风险版本升级;
  • 优先修复连接泄漏、锁冲突和复制异常;
  • 为高风险查询建立回滚计划和计划基线。

错误预算不是“允许故障的借口”,而是把可靠性和变更速度放进同一套决策框架。一个只追求吞吐量而没有预算约束的系统,可能在高峰期通过过度并发耗尽连接和锁资源,最终使业务 SLO 大面积违约。


七、把连接、事务、锁、计划和复制串成一条因果链

7.1 一个典型故障:长事务导致锁等待和副本延迟

考虑以下因果链:

  1. 应用开启事务;
  2. 事务更新一行后调用外部 HTTP 服务;
  3. 外部服务响应变慢;
  4. 事务长时间不提交;
  5. 其他请求更新同一行,形成锁等待;
  6. 等待请求占用连接池;
  7. 新请求无法获取连接;
  8. 主库产生的事务和 WAL 增加;
  9. 副本重放受到大事务或资源压力影响;
  10. 读副本延迟增加;
  11. 业务请求同时出现连接超时、写超时和旧数据读取。

如果只看最后一个现象,可能分别得出“连接池太小”“副本性能不足”“SQL 太慢”等错误结论。正确的排查应沿时间和阻塞关系回溯:

  • 先看业务请求的数据库 span;
  • 确定是获取连接等待,还是拿到连接后执行等待;
  • 查看活动会话中的长事务;
  • 查看锁等待图;
  • 找到最早持锁的事务;
  • 检查该事务是否包含外部调用或用户交互;
  • 再观察 WAL/redo 产生和副本接收、重放进度;
  • 最后确认 SLO 违约的业务范围。

7.2 用“等待图”代替单一慢查询表

锁问题适合表示成有向图:

TwaitingTblockingT_{\text{waiting}} \rightarrow T_{\text{blocking}}

如果存在环,就是死锁;如果某个节点被大量请求指向,它是阻塞根节点。

在 PostgreSQL 中,pg_blocking_pids() 可以直接帮助构造阻塞关系。在 MySQL 中,可以使用 data_lock_waits 将等待事务和阻塞事务关联起来。

诊断时要区分:

  • 根阻塞者:它本身可能没有等待,但持有资源;
  • 等待链中间节点:既在等待别人,又阻塞后续事务;
  • 受害者:最先表现为超时或连接池耗尽的业务请求。

杀掉一个等待者可能只能缓解表象,真正根因仍在根阻塞事务。终止会话还可能触发回滚,回滚本身持续较长时间并继续持有部分资源,因此操作前必须确认事务内容、影响范围和恢复方式。

7.3 把计划变化和数据增长关联起来

执行计划诊断至少要保存三类时间序列:

  1. SQL 模板的调用量和延迟;
  2. 计划的估算与实际行数;
  3. 表、索引、缓存和 IO 的容量变化。

例如某查询从 50 ms 变成 2 s:

  • 如果实际扫描行数同步增长,可能是工作集和数据量增长;
  • 如果扫描行数不变但计划从索引扫描变为全表扫描,可能是统计信息或成本估算变化;
  • 如果计划和扫描行数不变,但等待锁时间增长,问题不在计划;
  • 如果数据库执行时间不变而端到端时间增长,可能是连接池、网络或应用线程池问题。

“慢查询”只是症状标签,不是根因分类。


八、容量规划:观测指标如何指导扩容

数据库可观测性最终要支持容量决策。以下指标应结合业务负载观察。

8.1 工作集和缓存命中

工作集是某个时间窗口内被频繁访问的数据页和索引页集合。若工作集大部分能驻留在内存中,读取更可能命中缓存;若工作集持续超过有效缓存,物理 IO 会增加。

但“缓存命中率高”不能单独证明性能好:

  • 热点小表可能使命中率很高,但大查询仍然慢;
  • 顺序扫描可能不需要高随机缓存命中;
  • 命中率是比例,不代表绝对 IO 量;
  • 内存压力下,命中率可能短期仍然正常而延迟已经抖动。

应同时查看缓存命中、读取字节、IO 等待、查询扫描行数和设备延迟。

8.2 IOPS、吞吐和 IO 延迟

IOPS 是每秒 IO 操作数,吞吐是每秒传输字节数,延迟是单次 IO 完成时间。三者不是同一个容量指标:

  • 小块随机读可能先受 IOPS 限制;
  • 大量顺序读可能先受吞吐限制;
  • 队列变长时,平均 IO 延迟和 P99 延迟会明显上升;
  • 数据库日志写入通常更关注持久化延迟和写入带宽。

不能只说“磁盘 IOPS 够用”。应将数据库请求的 IO 模式、块大小、并发、读写比例和峰值时间结合起来。

8.3 连接容量与 CPU 容量

连接上限是硬资源约束,但有效并发通常更低。可以将连接分为:

  • 空闲连接;
  • 正在执行的连接;
  • 等待锁的连接;
  • 等待 IO 的连接;
  • 等待应用归还或连接池调度的连接。

扩容连接上限只能解决“上限过低”,不能解决锁冲突、慢 SQL 或 CPU 饱和。若活跃查询数继续增加而 CPU 已接近饱和,增加连接通常只会扩大排队和调度开销。

8.4 增长与压测

容量规划不能只用当前平均负载。至少应模拟:

  • 峰值 QPS;
  • 读写比例;
  • 参数分布和热点;
  • 长事务;
  • 批量写入;
  • 副本重放;
  • 备份或维护任务;
  • 网络和存储抖动。

压测结果必须记录:

  • 吞吐;
  • P50/P95/P99 延迟;
  • 连接获取等待;
  • 锁等待;
  • CPU;
  • 内存和缓存;
  • IO 延迟;
  • WAL/redo 产生速率;
  • 副本接收和重放延迟;
  • 错误率和回滚率。

如果只记录 QPS,可能得到“吞吐增长了,但 P99 已经无法满足 SLO”的错误结论。


九、常见误解与失败表现

误解一:数据库连接越多,吞吐越高

当并发低于资源承载能力时,增加连接可能提高利用率;超过有效并发后,连接只会增加排队、上下文切换和锁竞争。

验证方法:观察活跃连接、连接池等待、CPU、锁等待和请求 P99 的联合变化。

误解二:SQL 执行计划正常,所以查询不会慢

计划只描述执行策略,不包含所有外部等待。相同计划在锁等待、缓存失效、IO 抖动或网络拥塞下可能有完全不同的端到端耗时。

验证方法:将计划中的执行时间与活动视图等待事件、客户端耗时和链路 span 对齐。

误解三:副本延迟为零,所以读一定是最新的

零延迟可能只是当前没有新的日志,或者时间型指标没有发现有效差异。读路由、事务快照、缓存和跨节点时钟都可能造成旧读。

验证方法:使用带时间戳或版本号的端到端写后读探针。

误解四:锁等待只能通过杀连接解决

杀连接是应急手段,不是根因修复。被杀事务可能需要回滚,期间仍可能消耗 IO 和锁资源。

验证方法:先定位根阻塞事务,确认业务影响和事务内容,再选择回滚、终止会话、修复应用事务边界或降低并发。

误解五:平均延迟达标,所以 SLO 达标

平均值会掩盖长尾。连接池等待、死锁重试和少量大事务尤其容易制造 P99 问题。

验证方法:使用请求级成功率和延迟直方图,按接口、SQL 模板、实例和错误类型切分。

误解六:只需要采集 SQL 文本

SQL 文本没有执行上下文时难以诊断。至少需要关联:

  • SQL 模板;
  • 执行次数;
  • 总耗时和分位数;
  • 扫描或返回行数;
  • 锁等待;
  • 事务 ID 或会话;
  • 数据库实例和角色;
  • trace ID;
  • 计划版本或计划摘要。

同时必须控制参数脱敏和标签基数。


十、生产诊断的最小闭环

一次可复用的数据库异常诊断,可以按以下顺序进行。

第一步:确认 SLO 违约的业务范围

确定是:

  • 所有请求变慢,还是某个接口;
  • 主库还是副本;
  • 读、写还是提交;
  • 新请求失败,还是已有请求超时;
  • P50、P95 还是 P99 变差。

第二步:分解端到端耗时

从应用链路或连接池指标确认:

  • 获取连接耗时;
  • SQL 执行耗时;
  • 事务提交耗时;
  • 结果读取耗时;
  • 应用自身耗时。

第三步:检查连接和事务状态

重点找:

  • 连接池等待;
  • 长时间 active 会话;
  • idle in transaction
  • PostgreSQL aborted transaction;
  • MySQL 长事务;
  • 连接错误和达到上限的记录。

第四步:检查等待和阻塞

构造等待图,区分:

  • 锁等待;
  • IO 等待;
  • CPU 饱和导致的运行队列;
  • 元数据或 DDL 相关等待;
  • 提交和复制确认等待。

第五步:检查执行计划和统计信息

对代表性参数执行只读诊断:

  • 查看估算行数与实际行数;
  • 查看 Join 顺序和算法;
  • 查看扫描、排序、临时结构和回表;
  • 检查统计信息是否陈旧;
  • 对比历史计划。

第六步:检查复制数据流

确认:

  • 日志是否产生异常增加;
  • 是否已经发送;
  • 副本是否接收并持久化;
  • 是否完成重放;
  • 复制线程是否报错;
  • 业务读是否经过正确路由。

第七步:验证修复

修复不能只看数据库 CPU 降低,还应验证:

  • 业务成功率恢复;
  • P95/P99 恢复;
  • 连接池不再排队;
  • 长事务消失;
  • 锁等待链解除;
  • 计划和实际行数合理;
  • 副本新鲜度满足目标;
  • 错误预算停止继续消耗。

数据库可观测性的核心不是收集更多图表,而是建立从业务请求到数据库内部状态的可验证因果链:连接说明请求是否进入系统,事务说明资源保持了多久,锁说明并发如何互相影响,执行计划说明数据库打算如何工作,复制说明结果何时传播到其他节点,SLO 则说明这些内部状态最终是否已经影响用户。只有精品


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。