数据库基础体系 · 第 77/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
数据库可观测性:连接、事务、锁、执行计划、复制和 SLO
数据库问题通常不会以“数据库坏了”的形式出现,而是表现为接口延迟升高、连接池耗尽、事务堆积、锁等待、CPU 或 IO 饱和、只读副本延迟、查询结果变旧,最终触发业务 SLO 违约。
要定位这类问题,不能只看一个“慢查询排行榜”。一次请求可能经历:
这里的各项并不总是串行发生,但这个分解有助于回答一个关键问题:延迟究竟消耗在哪个阶段。数据库可观测性要做的,就是把这些阶段映射到可查询的状态、指标、日志和链路上下文中,并将它们与业务 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,可能真的修改数据; - 采集完整参数可能违反隐私和合规要求;
- 高基数标签会导致指标系统内存和查询成本上升。
因此,观测需要区分:
- 常驻低成本指标;
- 发生异常时启用的增强日志;
- 对单条查询进行的临时诊断;
- 线下复现和压测。
二、连接:连接数不是并发量,连接池也不是容量
2.1 数据库连接的生命周期
一次典型数据库连接经历以下状态:
- 应用创建连接;
- 数据库完成认证和会话初始化;
- 连接进入空闲状态,等待请求;
- 应用发送 SQL;
- 数据库执行 SQL,可能等待锁、IO 或 CPU;
- 事务提交或回滚;
- 连接回到连接池,或被关闭。
因此,连接数至少要拆成:
其中:
active:正在执行 SQL;idle:连接存在但没有当前 SQL;idle in transaction:事务已经打开,但当前没有执行 SQL;waiting:应用、连接池或数据库内部正在等待资源。
“连接数低”并不等于系统健康。比如 100 个连接中,99 个处于锁等待,数据库连接数并不高,但业务已经无法完成请求。
2.2 连接池为什么可能放大故障
假设数据库能够同时有效处理 32 个活跃查询,应用连接池却配置了 500 个连接。当某个慢查询或锁冲突使每个查询占用连接的时间变长时,500 个连接不会提高数据库处理能力,反而可能造成:
- 上下文切换增加;
- 内存占用增加;
- CPU 在大量会话之间调度;
- 锁竞争扩大;
- 新请求在应用连接池中排队;
- 数据库达到连接上限,管理连接或高优先级请求也无法进入。
可以用排队模型理解:
其中:
- 是系统内平均请求数;
- 是平均到达率;
- 是请求平均停留时间。
当查询平均耗时 增长时,在到达率 不变的情况下,系统内同时占用的连接数 必然增加。连接池看到的是“连接越来越忙”,而数据库看到的是“更多会话同时等待”。
连接池大小应基于数据库可承受的活跃并发、查询类型、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_type和wait_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 连接故障的分层诊断
“连接失败”至少有四层:
- 网络层:DNS、路由、防火墙、端口;
- 协议和认证层:TLS、用户名、密码、认证插件;
- 数据库资源层:达到连接上限、进程或线程资源耗尽;
- 应用池层:连接泄漏、池配置过小、连接归还不及时。
生产排查顺序应先区分:
- 是新连接建立失败;
- 还是连接建立成功但获取不到连接;
- 还是拿到连接后 SQL 执行变慢;
- 还是 SQL 已完成但提交或网络读取变慢。
这四种情况的修复方向完全不同。
三、事务:从快照、锁和提交理解延迟
3.1 事务不是一组 SQL 的简单包装
事务提供的是一组原子性和隔离语义。常见抽象包括:
- 原子性:事务中的修改要么全部生效,要么全部回滚;
- 一致性:提交前后满足数据库约束和应用要求;
- 隔离性:并发事务之间按照隔离级别观察彼此;
- 持久性:提交成功后,系统应按其持久化语义保留结果。
数据库可观测性尤其关心两个时间:
以及:
一个事务可能没有慢 SQL,却因为应用在两条 SQL 之间执行了远程调用而持续数十秒。这类问题通常表现为长事务和锁、版本回收堆积。
3.2 MVCC 与“读不加锁”不是同一件事
PostgreSQL 和 InnoDB 都使用多版本并发控制(MVCC),但具体实现和语义不同。
MVCC 的基本思路是:写入不会简单地覆盖所有读者正在观察的版本,读操作根据自己的快照选择可见版本。这样,普通读通常不必等待普通写。
但这不意味着“读永远不加锁”:
SELECT ... FOR UPDATE会锁定将要修改的行;- DDL 可能需要表级锁;
- 外键检查、唯一性检查可能产生锁等待;
UPDATE、DELETE需要锁定目标行;- 某些访问路径和隔离级别会产生谓词或间隙相关的并发效果。
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_type 为 Lock,pg_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 也会解除等待,但最终余额变化不同。
这个例子说明:
- B 的 SQL 文本本身可能很快;
- B 的执行计划可能完全正常;
- B 的延迟主要来自锁等待;
- 只查看 CPU、扫描行数或平均 SQL 耗时,可能找不到原因;
- 真正的根因可能是 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_MODE、LOCK_TYPE 和 LOCK_DATA 帮助判断是记录锁、间隙相关锁、表锁还是其他 InnoDB 锁。
如果需要快速查看 InnoDB 的死锁和最近锁等待信息,也可以使用:
SHOW ENGINE INNODB STATUS\G
其输出是诊断文本,不适合作为稳定机器接口。自动化监控应优先使用 Performance Schema,并确认相关采集项已启用。
3.6 死锁与长等待不是一回事
锁等待是等待者暂时无法继续,通常在阻塞事务提交或回滚后恢复。
死锁是等待关系形成环:
例如:
- A 先锁住行 1,再请求行 2;
- B 先锁住行 2,再请求行 1。
两者互相等待,任何一个都无法自然继续。数据库通常会检测死锁并主动回滚其中一个事务,但具体选择规则是引擎实现细节,不能依赖某个固定事务一定会被回滚。
避免和处理死锁需要同时做两件事:
- 让所有事务以一致顺序访问资源;
- 应用正确处理死锁错误并重试整个事务。
不能只重试失败的单条 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:执行完成时间。
判断估算是否失真时,不能只比较一个数字,应结合循环次数:
例如某节点估算 rows=10,实际 rows=100000,且 loops=1,说明估算偏小四个数量级,可能导致优化器选择错误的 Join 或扫描方式。
ANALYZE 更新统计信息后,计划可能改变。它不会创建索引,也不会修复所有性能问题;如果数据分布复杂,单列统计信息仍然可能不足以描述列之间的相关性。
4.3 计划慢、执行慢和等待慢必须区分
一条 SQL 的观测时间可能包括三部分:
EXPLAIN ANALYZE 主要告诉你数据库执行节点的实际情况,但不能自动解释:
- 执行前是否在连接池排队;
- 是否等待锁;
- 客户端是否迟迟不读取结果;
- 提交时是否等待 WAL 或复制确认;
- 网络传输是否占用大量时间。
因此,执行计划需要与会话等待事件、事务日志、链路追踪和客户端耗时结合分析。
4.4 Join 算法与基数估计的关系
常见 Join 算法包括:
- Nested Loop:外表每一行驱动内表查找,适合外表较小且内表有有效索引的情况;
- Hash Join:构建一侧的哈希表,再扫描另一侧,适合等值 Join 和较大的输入;
- Merge Join:对两侧有序输入进行合并,适合已有排序或可高效获得有序数据的情况。
优化器选择 Join 算法依赖估算的输入行数。如果估算外表只有 10 行,选择 Nested Loop 可能合理;但实际外表有 100 万行时,Nested Loop 可能变成灾难。
简化地说,Nested Loop 的工作量常近似为:
Hash Join 的工作量常近似为:
这不是精确成本公式,但表达了核心直觉:错误的外表基数会使 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 会执行语句,因此对 INSERT、UPDATE、DELETE 使用时必须把它当作真实写操作处理。在生产环境中,通常先在隔离环境复现,或使用只读查询、事务回滚等经过验证的方式控制风险;不能假定所有引擎和语句都自动回滚。
MySQL 的 EXPLAIN 输出中常见的 rows 是估算行数,filtered 是估算通过条件的比例。它们不是实际结果。使用 EXPLAIN ANALYZE 后,应比较估算和实际行数,并检查是否出现:
- 预期使用索引,实际全表扫描;
- 估算行数远小于实际行数;
- Join 顺序与数据分布不匹配;
- 排序或临时表消耗过大;
- 需要回表读取大量记录。
4.6 不要把“用了索引”当成优化完成
索引只是访问路径之一。以下情况中,“使用索引”仍可能很慢:
- 选择性很低,索引扫描后要读取大量行;
- 需要大量回表;
- 排序无法利用索引顺序;
- Join 的另一侧基数估算错误;
- 频繁更新导致索引维护成本高;
- 查询返回大量结果,网络传输成为主耗时;
- 索引与过滤条件顺序不匹配;
- 查询被锁等待或 IO 等待拖慢。
诊断时应使用完整计划、实际行数、缓冲区或 IO 统计,而不是只看 key、Index Scan 等单个字段。
4.7 统计信息变化会导致计划回归
SQL 文本不变并不意味着计划不变。计划可能因以下原因变化:
- 表数据量增长;
- 数据分布改变;
- 统计信息更新;
- 索引新增或删除;
- 参数值改变;
- 内存、并行和成本参数变化;
- 版本升级;
- 预处理语句的参数化计划行为变化。
因此应记录:
- SQL 模板;
- 执行计划摘要或计划指纹;
- 估算行数与实际行数;
- 规划时间和执行时间;
- 计划出现时间;
- 数据库版本和相关参数。
这样才能区分“数据增长导致查询自然变慢”和“计划回归导致突然变慢”。
五、复制:复制延迟不是一个数字
5.1 复制数据流
数据库复制通常包含以下路径:
不同阶段代表不同故障:
- 主库产生日志慢:可能是写入、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 中,如果配置了同步复制,提交可能等待同步副本确认。于是:
同步复制提高了某些故障条件下的数据持久性或可见性保证,但会把网络和副本状态引入主库提交延迟。副本网络抖动可能表现为主库业务写请求变慢,而不是副本查询变慢。
不能把“同步”理解为所有副本都实时可读,也不能把“异步延迟当前为零”理解为永久的数据一致性保证。
5.3 MySQL:复制线程和 GTID 进度
MySQL 8.4 使用 source/replica 术语。传统状态查看命令为:
SHOW REPLICA STATUS\G
重点关注的字段通常包括:
Replica_IO_Running:复制接收线程是否运行;Replica_SQL_Running:复制应用线程是否运行;Seconds_Behind_Source:复制状态报告的时间估计;Source_Log_File、Read_Source_Log_Pos:接收进度;Relay_Source_Log_File、Exec_Source_Log_Pos:已执行进度;Last_IO_Error、Last_SQL_Error:最近的接收或应用错误;- GTID 相关字段:已接收、已执行或待执行的事务集合。
字段名称和含义应以具体复制配置、版本和 SHOW REPLICA STATUS 输出为准。尤其是 Seconds_Behind_Source:
- 可能为
NULL; - 复制线程停止时不能表示正常延迟;
- 在空闲写入期间不一定有有意义的时间差;
- 多源、并行复制或复杂事务场景下可能不能精确反映业务可读延迟。
因此应同时监控:
- 接收线程是否运行;
- 应用线程是否运行;
- SQL 错误是否存在;
- GTID 或日志位置差;
- 副本端的实际重放和业务读验证。
5.4 复制延迟的故障路径
副本延迟常见路径包括:
路径一:副本 IO 能力不足
副本接收日志速度低于主库产生速度,表现为接收位置落后。可能原因:
- 磁盘写入慢;
- 网络带宽不足;
- WAL/redo 量突然增加;
- 副本资源被备份或其他任务占用。
路径二:副本应用能力不足
副本已经接收到日志,但重放速度低。可能原因:
- 副本 CPU 不足;
- 大事务导致重放长时间占用;
- 并行复制配置不足或存在冲突;
- 复制应用受锁、DDL 或长查询影响。
路径三:副本可读但业务读到旧数据
复制已经正常,但业务在写入主库后立即读取只读副本。此时读到旧值不一定是复制故障,而是读写分离下的会话一致性问题。
一种常见解决方式是“读自己的写”:
- 写请求在主库提交后获得一个位置标记,例如 PostgreSQL 的 LSN 或 MySQL 的 GTID;
- 后续读请求选择已经追上该位置的副本;
- 如果没有满足条件的副本,则回主库或等待有限时间;
- 超时后明确返回降级结果,而不是无限等待。
这是一种业务一致性策略,不是简单调大副本线程数就能解决的问题。
六、SLO:把数据库状态转化为用户可感知的目标
6.1 SLI、SLO 和错误预算
- SLI(Service Level Indicator):实际测量的指标;
- SLO(Service Level Objective):目标,例如一个月内 99.9% 的成功请求延迟不超过 300 ms;
- SLA(Service Level Agreement):对外承诺,可能带有合同和赔偿责任;
- 错误预算:允许不满足 SLO 的额度。
例如,30 天按 24 小时计算:
99.9% 可用性允许的不可用时间为:
这只是时间型可用性预算。对于数据库,更有意义的 SLI 通常还包括:
- 请求成功率;
- 写入延迟;
- 查询延迟;
- 锁等待时间;
- 连接获取等待时间;
- 复制新鲜度;
- 数据库错误率;
- 事务回滚率。
6.2 延迟 SLO 不应只看平均值
平均值无法表达长尾。一个窗口有 99 个请求耗时 20 ms,1 个请求耗时 10 s:
平均值看起来不高,但 P99 已经达到 10 s。对在线请求,通常应同时观察:
- P50:典型体验;
- P95:大多数请求的尾部;
- P99 或更高分位:极端长尾;
- 超过阈值的请求比例。
分位数不能简单跨实例、跨时间窗口求平均。正确做法是保留直方图桶或在统一样本集合上计算分位数。
6.3 数据库 SLO 的边界
数据库内部 SLO 和业务 SLO 不一定相同。
例如业务请求总延迟目标为 500 ms:
数据库不能直接把全部预算都占满。应用还需要处理序列化、网络、业务逻辑和外部依赖。因此应给数据库设定内部预算,例如数据库查询和提交只允许消耗业务请求预算的一部分。
但也不能机械地把所有数据库请求设成同一个目标:
- 点查、写入、后台报表的延迟分布不同;
- 主库写入和副本读取的一致性要求不同;
- 事务提交可能包含同步复制等待;
- 备份、批处理和在线请求不应共用同一个延迟目标。
6.4 复制新鲜度 SLO
对读副本,延迟 SLO 不应只写成“副本延迟小于 5 秒”,而应明确测量对象:
- 日志接收延迟;
- 日志持久化延迟;
- 日志重放延迟;
- 业务数据可读延迟;
- 写入主库后,指定读请求读到新值的时间。
例如:
99.9% 的用户订单查询,在订单写入主库成功后 2 秒内,能够从读路径读取到该订单。
这个目标比“副本 Seconds_Behind_Source 小于 2”更接近用户体验,但需要端到端探针验证。探针应使用专门的数据,避免把测试写入混入正常业务统计。
6.5 错误预算如何指导取舍
如果数据库 SLO 连续违约,错误预算被消耗,工程决策应倾向于降低变更风险:
- 暂停非必要索引变更和大规模迁移;
- 降低批处理并发;
- 推迟高风险版本升级;
- 优先修复连接泄漏、锁冲突和复制异常;
- 为高风险查询建立回滚计划和计划基线。
错误预算不是“允许故障的借口”,而是把可靠性和变更速度放进同一套决策框架。一个只追求吞吐量而没有预算约束的系统,可能在高峰期通过过度并发耗尽连接和锁资源,最终使业务 SLO 大面积违约。
七、把连接、事务、锁、计划和复制串成一条因果链
7.1 一个典型故障:长事务导致锁等待和副本延迟
考虑以下因果链:
- 应用开启事务;
- 事务更新一行后调用外部 HTTP 服务;
- 外部服务响应变慢;
- 事务长时间不提交;
- 其他请求更新同一行,形成锁等待;
- 等待请求占用连接池;
- 新请求无法获取连接;
- 主库产生的事务和 WAL 增加;
- 副本重放受到大事务或资源压力影响;
- 读副本延迟增加;
- 业务请求同时出现连接超时、写超时和旧数据读取。
如果只看最后一个现象,可能分别得出“连接池太小”“副本性能不足”“SQL 太慢”等错误结论。正确的排查应沿时间和阻塞关系回溯:
- 先看业务请求的数据库 span;
- 确定是获取连接等待,还是拿到连接后执行等待;
- 查看活动会话中的长事务;
- 查看锁等待图;
- 找到最早持锁的事务;
- 检查该事务是否包含外部调用或用户交互;
- 再观察 WAL/redo 产生和副本接收、重放进度;
- 最后确认 SLO 违约的业务范围。
7.2 用“等待图”代替单一慢查询表
锁问题适合表示成有向图:
如果存在环,就是死锁;如果某个节点被大量请求指向,它是阻塞根节点。
在 PostgreSQL 中,pg_blocking_pids() 可以直接帮助构造阻塞关系。在 MySQL 中,可以使用 data_lock_waits 将等待事务和阻塞事务关联起来。
诊断时要区分:
- 根阻塞者:它本身可能没有等待,但持有资源;
- 等待链中间节点:既在等待别人,又阻塞后续事务;
- 受害者:最先表现为超时或连接池耗尽的业务请求。
杀掉一个等待者可能只能缓解表象,真正根因仍在根阻塞事务。终止会话还可能触发回滚,回滚本身持续较长时间并继续持有部分资源,因此操作前必须确认事务内容、影响范围和恢复方式。
7.3 把计划变化和数据增长关联起来
执行计划诊断至少要保存三类时间序列:
- SQL 模板的调用量和延迟;
- 计划的估算与实际行数;
- 表、索引、缓存和 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 则说明这些内部状态最终是否已经影响用户。只有精品
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:数据库测试体系:事务测试、迁移测试、Testcontainers 和故障注入
- 下一篇:数据库容量规划:工作集、缓存命中、IOPS、连接、增长和压测
- 延伸:SQL 执行计划与查询优化:基数估计、Join 算法和慢查询诊断
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论