抽丝剥茧:慢 SQL 诊断方法论
8 抽丝剥茧:慢 SQL 诊断方法论
“数据库慢”不是根因,甚至还不是一个足够好的问题。它可能表示某个请求在连接池排队、某条 SQL 被事务锁住、一个参数命中了错误的通用计划、结果集已经算完却写不进慢客户端,也可能只是用户把一次偶发抖动概括成了整体退化。
本章把第 5 章的事务与锁、第 7 章的计划与统计放回真实请求链路,建立一条可复核的诊断闭环:
顺序很重要。先跑 EXPLAIN 会漏掉锁与客户端背压;先建索引会把相关性当因果;只看面板截图则容易丢失指标定义、时间范围与原始身份。正确做法是从用户可见 SLI 向内收敛,再从平台视图回到 PostgreSQL 原生证据。
本章目标
完成本章后,读者应当能够:
- 用时间窗、样本数、p50/p95/p99、吞吐、并发和错误率定义“慢”;
- 区分单次慢、参数簇慢、持续退化、实例退化与全链路退化;
- 把端到端时间拆成排队、应用、数据库执行、传输和客户端消费;
- 正确联合解释
pg_stat_activity.state、wait_event_type、wait_event; - 用
pg_blocking_pids()证明阻塞边,不把等待者误当根因; - 从
pg_stat_statements按总预算、调用数、均值、最大值和资源量排序; - 知道累计查询统计没有原生延迟分位数,且不能跨边界滥用
queryid; - 建立带会话、查询、时间和变更身份的日志最小基线;
- 关联 SQL 指标、主机资源、锁、日志、部署和配置变更;
- 把“计划、锁、I/O、CPU、内存、客户端、网络、连接池”写成可证伪假设;
- 设计单变量、可回退、记录冷热缓存与参数分布的实验;
- 在 Pigsty 中定位范围,并用 SQL、日志和机器可读计划复核;
- 独立区分估算/计划、锁等待与客户端慢消费三种相似的“请求不返回”;
- 产出证据包、假设树、修复对照、负对照与复位结果。
实验边界
实验基线为 PostgreSQL 18.6、Pigsty v4.5.0、Ubuntu 24.04 L1;SQL 与诊断原则保持 PostgreSQL 14–18 可用。实验复用:
- ch07 的 90000/10 tenant skew,制造 generic/custom estimate 对照;
- ch05 的 rollback-only 行锁编排,制造一条可证明的 blocker edge;
generate_series派生结果与受控慢 reader,制造Client/ClientWrite。
本章不创建持久对象。锁实验最终回滚,客户端实验只生成结果流,estimate 实验只读 ch07 fixture。若 ch07 fixture 缺失,task.sh setup/all 会通过 marker guard 受控重建专属对象,属于 L1/R1;不会自动清理业务对象、全局重置 pg_stat_statements、修改日志/连接池参数或取消非本章会话。
每个并发 worker 都有唯一 application_name。取消动作必须同时匹配 PID、backend_start、database 与 application identity;最终验收要求 active_lab_workers=0,并重新计算 ch04-v1 业务 checksum。
下载资产:
- 实验合同
- 诊断记录模板
- estimate case
- lock case
- client case
- ClientWrite 编排
- 慢 reader
- 中性信号构建器
- 无答案诊断器
- 盲测入口
- 稳定断言
- v0.3 规则提案
- 状态验收
- 任务入口
本章目录
8.1 先定义“慢”
8.2 从会话到语句定位范围
8.3 关联日志、指标与计划
8.4 建立而不是猜测假设
8.5 设计受控实验
8.6 从可观测面板回到原生证据
8.7 实战:三种“慢”只修真正瓶颈
实测摘要
一次 PostgreSQL 18.6 全量验收得到:
节点类型、cost、buffers、PID 和时间不是 golden。稳定断言是证据关系:generic estimate 严重偏离且 custom 对照改善;Lock wait 必须存在 blocker edge;ClientWrite 必须没有数据库 blocker;错误盲测答案必须失败;所有会话与业务状态最终恢复。
章节验收
- 事件描述包含 UTC 时间窗、样本数、分位数、吞吐、并发、错误率和影响范围;
- 不平均不同窗口的 p99,不用单次最大值冒充分位数;
- 能解释端到端慢为什么可能完全发生在 PostgreSQL 之外;
- 联合读取 state 与 wait,不把普通 idle/ClientRead 当慢查询;
- 取消会话前验证 PID + backend_start + database + application;
- 只用
pg_blocking_pids()/锁证据确认 blocker,不按最长 SQL 猜; pg_stat_statements排序至少覆盖总预算、均值、调用数和资源;- 知道累计统计的 reset/采样边界,不为一次调查全局 reset;
- 日志含时间、PID/session、用户、数据库、application 与必要 query identity;
- 参数日志有脱敏、长度、成本与保留策略;
- 假设写明预测、反证、最小实验与回退条件;
- 对比实验控制 cache、参数、数据量、并发与重复次数;
- 面板发现必须能落回 SQL、日志、计划或 exporter 指标语义;
- 三类 case 均能在不读取 answer artifact 时正确分类;
- 错误 diagnosis 的 reveal 返回失败;
task.sh all通过,answer mode 为0600,worker 为 0,业务 checksum 不变。
下一章 ch09《巧夺天工:索引设计与效果验证》 将在诊断已证明访问路径是主要瓶颈后,再讨论该不该建、建什么、怎样验证和怎样安全发布索引。
参考资料
- PostgreSQL 18:Monitoring Database Activity
- PostgreSQL 18:Cumulative Statistics System
- PostgreSQL 18:pg_stat_statements
- PostgreSQL 18:Error Reporting and Logging
- PostgreSQL 18:Using EXPLAIN
- Pigsty:PostgreSQL Dashboards
上一章:追本溯源:执行计划与统计信息 · 返回上卷导读 · 下一章:巧夺天工:索引设计与效果验证 · 查看全书目录 · 查看索引中心
8.1 先定义“慢”
一次有效诊断从可检验的事件定义开始。若工单只有“数据库今天很慢”,不同的人会任意选择时间、实例和指标,最后得到彼此矛盾却都无法反驳的故事。
最小事件边界可以写成:
这段话尚未宣称 PostgreSQL 有问题,却已经给出了可在 trace、应用、连接池、代理和数据库各层复核的同一范围。
8.1.1 延迟分位数、吞吐、并发与错误率
延迟不是一个数,而是一组事件的分布。p99 的含义是:在给定样本集合中,约 99% 的观测不超过该值;它必须和以下限定一起出现:
- 测量对象:HTTP 路由、业务动作、数据库 query family,还是单个 backend;
- 起止时间与时区;
- 成功请求、全部请求还是某类错误;
- 样本数与采样方式;
- histogram bucket、客户端 timer 或 tracing span 的来源;
- 是否包含重试、排队、结果传输和超时。
没有样本数的 p99 很危险。一个窗口只有 40 个请求时,所谓 p99 几乎就是最大样本;一个窗口有 100 万请求时,尾部才有足够事件可供分层。也不要把十个一分钟 p99 求平均来冒充十分钟 p99:分位数一般不可加、不可平均。只有保留可合并的原始分布或边界一致的 histogram bucket,才可能重新计算整体分位数。
分位数还必须和吞吐、并发、错误率联合阅读:
| 现象 | 可能解释 | 还缺什么证据 |
|---|---|---|
| p99 上升,吞吐稳定 | 少数参数/租户退化、锁尖峰、下游抖动 | 慢样本标签、参数、wait |
| p50/p99 同时上升,吞吐下降 | 普遍排队或资源饱和 | 队列长度、CPU/I/O、连接占用 |
| 延迟下降,错误率上升 | 请求更早失败或超时,不是性能改善 | SQLSTATE、HTTP 状态、取消来源 |
| QPS 上升,均值稳定,总数据库时间上升 | 单次没变但预算消耗变大 | calls、total_exec_time、容量余量 |
| 并发上升,吞吐不再增长 | 到达瓶颈后开始排队 | active sessions、pool wait、服务率 |
在近似稳定系统中,Little 定律给出:
其中 $L$ 是系统内平均并发,$\lambda$ 是吞吐,$W$ 是平均停留时间。它不是用来从三个噪声瞬时值“算根因”的,而是做一致性检查:若吞吐近似不变、停留时间增大,系统内请求数理应上升;若监控没有上升,可能是测量边界不同、采样漏掉队列,或请求已在上游被拒绝。
一个适合告警和诊断的 SLI 组合通常至少包括:
平均值仍有价值,它适合预算、容量和与累计 query time 对齐;但它不能替代尾延迟。反过来,p99 能说明尾部体验,却不能告诉你该 query family 消耗了多少总 CPU/执行时间。
8.1.2 单次慢、持续慢与整体退化
“慢”至少要从时间范围和影响范围两个轴分类:
| 时间形态 | 局部范围 | 全局范围 |
|---|---|---|
| 单次/尖峰 | 特定参数、一次锁等待、一次冷读 | checkpoint、主机抖动、网络事件 |
| 周期性 | 报表、定时任务、批量发布 | 定时备份、周期流量、共享资源争用 |
| 持续性 | query plan/数据分布变化 | 容量不足、配置/版本变更、长期膨胀 |
单次慢样本适合做法证:保存 trace、PID、参数、日志与当时 wait,但不能据此证明系统长期退化。持续慢需要同口径的对照窗口,例如:
“昨天平均 10 ms,今天一次 2 s”混合了统计粒度;“所有数据库都慢”也常把一个共享连接池、一个可用区或一个应用版本误写成数据库全局。诊断前依次收窄:
- 哪个用户动作或后台任务;
- 哪个路由、租户、参数簇与返回规模;
- 哪个应用版本、实例、可用区和服务入口;
- 哪个 PostgreSQL cluster、instance、database、user、application;
- 哪个查询族与事务;
- 是执行慢、等待慢、排队慢,还是消费结果慢。
持续退化也不等于“从某次发布起就一定由发布造成”。发布是高优先级假设,因为时间顺序与作用范围吻合;它仍需机制证据,例如 SQL shape 改变、calls/rows 改变、plan estimate 偏离、连接池并发改变,或错误重试放大负载。
建议把事件分为三个状态:
- 未确认:用户报告存在,但监控口径或范围尚未复现;
- 已确认:同一时间窗的 SLI 证明退化,根因未知;
- 已归因:存在机制、对照与反证,修复后同口径指标恢复。
这样能避免在“已确认慢”和“已证明 PostgreSQL 根因”之间偷换概念。
8.1.3 应用时间、排队时间与数据库时间
端到端时间可以用核账式分解:
这不是说各组件一定能无缝相加。计时器可能使用不同 clock,span 可能重叠,重试会创建多次数据库调用,连接池也可能没有 trace。分解的价值是找出“缺失时间”:例如应用记录 2.4 s,而数据库完成日志与 server-side EXPLAIN ANALYZE 都约 80 ms,那么剩余 2.32 s 不能继续用索引解释。
PostgreSQL planner 的 cost 不包含把值转换为文本以及把结果传给客户端的时间;EXPLAIN ANALYZE 的服务器执行时间也不等于用户看到的完整取数时间。相反,连接池排队发生在 backend 分配之前,pg_stat_activity 根本看不到这批尚未进入 PostgreSQL 的请求。
要让时间可以关联,最低限度应统一:
- 日志与展示使用 UTC,并保留原始 timezone;
- duration 使用 monotonic clock,wall clock 只做跨系统关联;
- trace/request ID 贯穿应用,database span 记录 database/service;
- PostgreSQL
application_name能映射应用/worker; - 日志保存 PID 与 session identity,而不是只保存 SQL 文本;
- 明确数据库 span 是“从发起驱动调用到返回”,还是 server duration。
一个常见对照:
这里 PostgreSQL 只用了约 85 ms 计算,但连接池排队与客户端接收共同制造了“SQL 调用 620 ms”。给查询加索引几乎不会改变 1700 ms 排队,也不能修复 500 ms 的慢消费;正确方向是分别调查 pool saturation 与返回规模/客户端读速。
本节的产出不是一张性能图,而是一份写入诊断记录模板的事件边界:
有了这条边界,下一节才开始从会话和查询统计定位 PostgreSQL 内部范围。
返回本章目录 · 下一节:从会话到语句定位范围 · 查看全书目录 · 查看索引中心
8.2 从会话到语句定位范围
事件边界确定后,先回答“backend 此刻在做什么”,再回答“过去一段时间哪个查询族消耗最大”。前者来自动态 activity snapshot,后者来自累计查询统计;把两者混为一谈,会拿一个瞬时会话解释一小时预算,或拿一小时均值解释当前阻塞。
8.2.1 活跃、等待、阻塞与空闲事务
下面的只读查询保留会话身份、事务年龄、当前状态、等待与直接 blocker:
读取时必须联合解释 state 与 wait:
| state / wait | 证据含义 | 不能直接推出 |
|---|---|---|
active / NULL |
正在执行,采样瞬间未报告等待 | 一定消耗 CPU;一定健康 |
active / Lock/* |
查询执行中,正在等 heavyweight lock | 当前等待者就是根因 |
active / IO/* |
正在某个 I/O wait point | 磁盘一定坏;全部时间都在 I/O |
active / Client/ClientWrite |
server 正等待把数据写给客户端 | 查询计算本身慢 |
idle / Client/ClientRead |
backend 等客户端发下一条命令 | 一条“慢 SQL”正在跑 |
idle in transaction |
事务打开但当前没有语句执行 | 没有影响;必须立刻终止 |
PostgreSQL 明确把 state 与 wait 定义为相互独立的维度。采样也可能遇到短暂不一致,所以重要结论应跨数个短间隔采样,而不是冻结一行就下结论。
idle in transaction 尤其需要看 xact_start、backend_xmin、锁和业务上下文。它可能保留锁、阻碍 vacuum 清理旧版本、延长事务边界,却不是“运行很久的当前 query”;query 字段此时是上一条语句。治理措施应优先修复应用事务边界,并使用经过评估的 idle_in_transaction_session_timeout,而非定时粗暴终止所有 idle session。
阻塞关系使用:
等待最久的 PID 未必是 root blocker;它可能也是链中受害者。先建立边,再沿边找到不再被别人阻塞的上游会话。第 5 章已给出锁模式与事务语义,本章强调诊断身份。
若必须缓解,先保存证据,再精确重查:
PID 会复用,单凭截图里的数字取消有伤及无关会话的风险。pg_cancel_backend 取消当前 query,pg_terminate_backend 结束 session;后者影响事务和客户端重连,不能当默认按钮。生产动作还要经过本地权限、SOP 和风险分级。
普通用户只能完整看到自己的会话;调查角色通常需要内置角色 pg_read_all_stats,但这也会暴露 SQL 与活动信息。权限应授予受控诊断角色,不应为了面板方便把业务用户升为 superuser。
最后注意视图一致性:累计统计可能延迟刷新,并在事务内缓存;activity 信息也会在同一事务首次读取后形成一致快照。交互调查若持续开着事务重复查询,先结束事务或按需调用 pg_stat_clear_snapshot(),否则可能把旧快照当实时状态。
8.2.2 按调用、总时长、均值和尾延迟排序
pg_stat_statements 把结构相同、常量不同的语句归一化,适合回答“哪些查询族消耗了累计预算”。先确认扩展已加载、目标数据库有 view,并记录 reset 起点:
不要为一次调查执行全局 pg_stat_statements_reset():它会破坏其他人正在使用的基线。更好的做法是保存两个时点的快照并计算 counter delta,或让监控系统持续抓取 counter。
一次基础排序:
至少从四种视角排序:
total_exec_time:谁吃掉最多执行时间预算,适合容量与总体收益;mean_exec_time/max_exec_time/stddev_exec_time:谁单次慢或波动大;calls:谁极高频,单次少量改善也可能有大收益;- blocks、temp、WAL、rows:谁制造 I/O、spill、写放大或大结果。
一个总时间第一的查询可能只是每次 2 ms、调用数巨大;一个均值第一的查询可能每天只跑一次;一个调用数第一的查询可能返回 0 行且预算很小。索引、缓存、批处理、限流和 SQL 改写针对的是不同问题,不能只保留一个“Top SQL”榜。
pg_stat_statements 记录 min/max/mean/stddev,但不保存每次执行的完整分布,也没有原生 p95/p99 列。因此:
max_exec_time不是 p99;- 不能从 mean/stddev 假定任意分布再可靠推算 p99;
- 尾延迟要来自请求 histogram、trace、采样日志或保存单次事件的系统;
- 累计 max 可能来自很久以前,必须结合
stats_reset/快照窗口。
执行统计只覆盖 PostgreSQL server 侧的一部分时间。连接池等待不在其中;结果传输造成的 server wait 与驱动计时也未必与 total_exec_time 完全同口径。把查询榜与应用 SLI 对齐是下一步,不是直接宣布榜首为根因。
8.2.3 查询指纹、参数与时间窗口
查询身份不是只有一段 SQL 文本。建议至少保存:
queryid 来自 parse analysis 后的结构 hash。它比字符串更适合在同一环境中关联,但保证有限:
- 同一文本可能因
search_path或对象含义不同而分成多个 ID; - 常量通常被归一化,hot tenant 与 cold tenant 可能落在同一 query family;
- drop/recreate 对象、catalog OID 与平台架构会影响身份;
- 不应假设跨 PostgreSQL major version 稳定;
- 物理复制节点通常可对应,逻辑复制环境不能据此保证对应;
- 极低概率仍可能 hash collision。
因此长期证据键通常是 (cluster identity, major version, dbid, userid, toplevel, queryid) 加归一化文本 hash,而不是一个裸 queryid。升级、重建或迁移后应建立新的 epoch。
归一化是优点也是盲区。第 7 章的 tenant 实验中,同一语句:
参数 1 返回 90000 行,参数 1001 只返回 10 行。累计均值可以同时掩盖两端。要恢复参数语义,使用经过脱敏的 trace tag、业务参数分桶、受控日志采样或可重放 fixture;不要把 token、密码、个人数据和任意 payload 倾倒进日志。
时间窗口也必须对齐三种数据:
pg_stat_activity是采样瞬间;pg_stat_statements是自 reset/entry 起的累计量;- Prometheus/日志/trace 是各自 scrape、采样或保留窗口。
例如事故发生五分钟,直接查询累计三个月的 mean 会稀释变化。正确做法是从监控 counter 计算事故窗口 delta/rate,或在事故前后保存两份 snapshot:
counter reset、entry eviction、实例重启和 failover 都会造成不连续,计算前应检查 reset/instance identity,不能把负 delta 当真实负负载。
本节最终应得到一个优先调查列表,而不是一个榜首判决:
下一节把这份列表与日志、主机指标、计划和变更事件放到同一时间轴。
上一节:先定义“慢” · 返回本章目录 · 下一节:关联日志、指标与计划 · 查看全书目录 · 查看索引中心
8.3 关联日志、指标与计划
activity 告诉你“此刻”,查询累计量告诉你“这段 epoch”,日志保存离散事件,指标保存时间序列,计划解释 executor 怎样处理数据。四者只有共享时间、实例、会话和查询身份,才能形成证据链。
8.3.1 日志最小基线与慢语句记录
诊断日志的第一个目标不是“把所有 SQL 打出来”,而是让一条事件可定位:
PostgreSQL 的 log_line_prefix 可以提供这些身份。一个需结合环境评估的示意:
其中 %m 是带毫秒时间,%p 是 PID,%c 由 backend start time 与 PID 组成近似唯一的 session ID,%u/%d/%a 分别是用户、数据库和 application。若启用 compute_query_id,还可评估 %Q;但官方明确指出 log_statement 产生日志时 query ID 可能尚未计算,因此不能把某一类日志里的 %Q=0 当作真实 query identity。
生产日志更适合使用 csvlog 或 jsonlog 等结构化目标,由采集器保留字段,而不是依赖脆弱正则拆纯文本。无论格式如何,都要实测 rotation、磁盘上限、采集延迟、丢弃策略与敏感字段。
记录慢完成语句的核心参数是:
它在语句完成且 duration 达阈值时记录,因此能发现“已结束的慢”,却不能解释当前一直没结束的 blocker。阈值不是通用常数:OLTP、批处理与维护任务的正常时长不同。先根据 SLO、流量和日志预算设基线,再用 role/database/session 或采样策略控制范围。
相关开关解决不同问题:
| 设置 | 能回答 | 主要代价/边界 |
|---|---|---|
log_min_duration_statement |
哪些完成语句越过阈值 | 高流量下日志量;不记录未完成 |
log_min_duration_sample + sample rate |
采样慢/常规语句 | 样本不等于全量;需保留采样率 |
log_lock_waits |
等锁超过 deadlock_timeout 的事件 |
阈值与日志量;不是所有短锁等待 |
log_temp_files |
超阈值临时文件 | 只证明 spill/临时文件,不自动证明根因 |
auto_explain |
被采样语句的执行计划 | ANALYZE/timing 成本、日志量与参数暴露 |
log_statement |
某类语句文本 | all 通常过量且可能泄密;不是性能万能开关 |
参数值比 SQL shape 更敏感。extended query protocol 的日志可能包含 bind parameters;log_parameter_max_length 和 log_parameter_max_length_on_error 可限制或禁止记录,但非零错误参数记录也会增加保存文本表示的开销。策略至少应覆盖:
- token、密码、密钥、个人数据和业务 payload 的禁止/脱敏;
- 每值长度和整条消息长度;
- 谁能读取、传输是否加密、保留多久;
- exporter/log pipeline 是否会复制到更多系统;
- 调查结束后如何恢复临时配置。
不要在事故中即兴全局打开 log_statement=all、auto_explain.log_analyze=on 和完整参数。先估算事件率、单条字节、磁盘余量与性能成本;能按单一 role/database/session 小范围复现时,就不要扩大到全局。
8.3.2 SQL 指标、主机资源与部署事件
慢查询证据通常跨四层:
| 层 | 代表信号 | 用途 |
|---|---|---|
| 请求/应用 | route latency、errors、retries、pool wait、in-flight | 确认用户影响与 PostgreSQL 外时间 |
| PostgreSQL query/session | calls/time/rows、wait、locks、plans、temp/WAL | 锁定查询族与数据库机制 |
| PostgreSQL instance | connections、xacts、checkpoints、WAL、vacuum、I/O | 判断共享资源与后台活动 |
| 主机/平台 | CPU、run queue、memory pressure、disk latency/queue、network | 解释系统资源与邻居效应 |
再叠加一条“变化流”:
正确关联不是看到两条曲线同时升高,而是提出机制:
反例也同样重要:
- 主机 CPU 高,但目标 query 在
Lockwait:CPU 可能是另一 workload; - shared block hit 高:只说明 PostgreSQL buffer hit,不代表没有 CPU 或 kernel page-cache I/O;
- disk latency 上升,但目标 plan buffers 几乎全 hit:时间相关不等于该 query 被磁盘拖慢;
- temp files 上升,但来自报表 user,而事故来自 OLTP application;
- 发布与事故同时发生,但未发布的 control instance 也同样退化:优先调查共享依赖。
PostgreSQL 的 pg_stat_io 和 pg_statio_* 能补充数据库 I/O 视角,但官方提醒它们不能区分数据是从物理介质还是 kernel page cache 取得;仍需与操作系统工具联合。track_io_timing 能提供时间,但有平台计时开销,是否常开需基准验证。
计划证据沿用第 7 章的机器可读格式:
它只应在安全、代表性环境执行。ANALYZE 会真实运行 SQL;写语句即使包在 rollback 中也可能有 sequence、外部函数等不可回滚副作用。事故时已有一条正在阻塞的生产写语句,不应为了“看计划”再执行一遍。
8.3.3 用同一时间轴排除巧合
把证据转换成 UTC 事件表,比叠十张截图更容易看出顺序:
| UTC | 事件 | 身份/范围 | 证据 |
|---|---|---|---|
| 12:03:42 | release 2026.07.29-3 开始 | app-a | deploy log |
| 12:04:01 | route p99 越过 SLO | checkout/create | histogram + count |
| 12:04:05 | pool wait 上升 | app-a | pool metric |
| 12:04:07 | Lock wait 出现 | db/shop, query X | activity snapshot |
| 12:04:07 | blocker edge X→Y | PID + backend_start | pg_blocking_pids() |
| 12:04:31 | 精确取消 Y | approved action | audit log |
| 12:04:32 | X 前进,pool queue 回落 | same scope | session/pool metrics |
| 12:04:45 | p99 恢复 | same route | histogram |
这条链同时满足:
- 先后:候选原因发生在结果之前;
- 同范围:实例、database、user、application/query 能对应;
- 剂量/机制:blocker 存在时等待积累,释放后 waiter 前进;
- 负对照:未受影响路由或无 blocker 窗口不呈现同样现象;
- 可逆性:受控动作后结果按预测变化。
时间对齐时记录数据本身的分辨率:
- Prometheus scrape interval 与 recording rule window;
- Grafana 查询 step、rate window、timezone;
- 日志采集/索引延迟;
- trace sampling rate;
- PostgreSQL 累计统计刷新延迟与事务内 snapshot;
- 应用与数据库主机的 clock synchronization;
- failover 后 instance identity/计数器是否改变。
不要把面板像素对齐当毫秒级因果。某条一分钟 rate 曲线的点代表一个区间,日志 timestamp 代表事件,activity 是一次采样,它们的语义不同。先把每项转换成“事件或区间 + 误差/分辨率”,再讨论顺序。
一个实用的反巧合问题集:
输出时保留原始 artifact,而非只有解释。原始 CSV/JSON plan、日志行、Prometheus expression、时间范围、source hash 和查询参数分桶使其他人能够重算;截图最多是导航附件。
下一节将这些关联结果整理为一个有优先级、可反驳的假设树。
上一节:从会话到语句定位范围 · 返回本章目录 · 下一节:建立而不是猜测假设 · 查看全书目录 · 查看索引中心
8.4 建立而不是猜测假设
假设不是“可能是磁盘”“可能缺索引”的清单。它必须把机制写成一条可被事实推翻的预测:
优先级由“现有证据支持度 × 用户影响 × 最小验证成本 ÷ 风险”决定,而不是由团队最熟悉什么决定。
8.4.1 计划与估算问题
计划假设应在 wait 排查之后进入。一个正在 Lock 等待的 backend,即使计划里有 Seq Scan,当前不返回的直接原因仍是锁;一个 ClientWrite backend 可能已经算出大量结果,planner cost 又不包含把结果传给客户端的时间。
确认计划方向时,从第一个显著偏差节点而非根节点名称开始:
可量化 cardinality error:
若 actual 为 0,应单独描述“估算 N、实际 0”,不要用无穷大排序掩盖业务含义。高误差是调查入口,不是固定阈值自动修复;它是否影响路径选择、内存分配、join order 或响应目标,还要看对照。
常见假设与反证:
| 假设 | 预测 | 最小对照 | 反证 |
|---|---|---|---|
| 统计陈旧 | estimate 偏离当前分布 | 安全副本/fixture ANALYZE 前后 |
estimate 与路径不变且数据分布本就一致 |
| 跨列相关缺失 | 多 predicate 近似独立相乘 | extended statistics 前后 | 单列条件就已偏离,或相关统计不改善 |
| 参数敏感 generic plan | hot/cold 共用 estimate/shape | force generic/custom 对照 | 两类参数 estimate/资源均相近 |
| predicate 不可用于索引/裁剪 | 条件落到 Filter,扫描范围扩大 | 语义等价、可 sargable 的表达式 | 扫描范围未变化 |
| index 缺失 | 选择性高且 heap/blocks 成本主导 | 第 9 章 hypo index/安全建索引实验 | 路径已合适,时间主要在 wait/client |
禁用 planner 方法(如 enable_seqscan=off)最多是受控诊断探针,不是生产修复;它也不能绝对禁止所有路径。不要把“强迫 Index Scan 后这一次更快”直接推广为长期结论,必须覆盖参数分布、cache、并发、写成本和磁盘占用。
计划 change 同样不是根因。统计、参数、配置、数据量或版本变化可能让 planner 合理换路;判断回归要比较结果正确性、SLO、estimate、资源与 workload,而不是 diff 节点名。
8.4.2 锁、I/O、CPU、内存与临时文件
wait event 是“backend 在采样瞬间等待哪里”,不是完整时间账本。把它与 blocker、查询计划、累计资源和 OS 指标组合:
| 候选机制 | PostgreSQL 证据 | 外部/对照证据 | 常见误判 |
|---|---|---|---|
| heavyweight lock | Lock/*、pg_blocking_pids()、pg_locks |
blocker 事务/应用身份 | 只取消 waiter;把长 SQL 当 blocker |
| I/O wait | IO/*、plan buffers、I/O timing、pg_stat_io |
device latency/queue、kernel/cache | 一次 IO sample 就断言磁盘故障 |
| CPU 饱和 | 多次 active 且无稳定 wait,calls/rows/plan 工作量 |
CPU、run queue、steal/throttle | “无 wait”等于 CPU;CPU 高就归目标 SQL |
| temp/spill | plan sort/hash temp、temp_blks_*、temp file log |
memory pressure、并发 | 直接全局增大 work_mem |
| shared memory contention | LWLock/*、BufferPin 等 |
并发/版本/具体 wait 名 | 把所有 Lock/LWLock 当行锁 |
| checkpoint/WAL pressure | WAL/checkpoint/I/O 指标、query WAL | storage 与写 workload | 只凭时间相关归因某个 query |
锁
锁诊断必须保存等待边两端的:
真正修复通常是缩短事务、统一锁顺序、避免事务中等外部 I/O、减少过宽写集合,或把冲突转为显式业务协议。增加 statement timeout 只是限制损失,不能替代根因修复。
I/O
EXPLAIN (ANALYZE, BUFFERS) 的 shared read/hit 是 executor 访问证据;track_io_timing 开启后可补 read/write time;pg_stat_io 给出 backend type/context/target 维度。它们都不能单独证明物理盘读取:PostgreSQL miss 仍可能由 kernel page cache 满足。应与设备层 latency、queue、吞吐和同主机 control workload 对照。
CPU
CPU 没有一个叫 CPU 的 wait event。backend 在执行用户态工作时往往 active 且 wait 为 NULL,但短采样也可能恰好落在两个 wait 之间。证明 CPU 瓶颈需要:
- 多次采样而非一行 activity;
- query calls/rows/plan work 与 CPU 时间窗同范围;
- 主机 run queue、利用率、throttling/steal;
- 限制并发或减少工作量后吞吐/延迟按模型变化。
内存与临时文件
sort/hash spill 是“该节点的内存预算与数据规模/并发不匹配”的证据。全局提高 work_mem 很危险,因为它不是整个实例固定池,而可能被一条 query 的多个节点、多个并发 backend 分别使用。优先:
- 确认 rows/width estimate 与返回规模;
- 减少不必要数据、改善计划;
- 对单一 role/session 做对照;
- 计算最坏并发内存;
- 观察 spill 改善、RSS/pressure 与其他 workload 副作用。
8.4.3 客户端取数、网络与连接池排队
数据库算得快,不代表用户收得快。第 8 章实验用一个大 COPY TO STDOUT 和受控慢 reader 复现:
这组证据表示 server 正在尝试把数据写给客户端,而 socket backpressure 让它等待。可能原因包括:
- 客户端逐行做昂贵处理,读取速度低;
- 客户端线程暂停、GC 或 event loop 被阻塞;
- 网络丢包、拥塞或带宽受限;
- 返回行/列过多、payload 过大;
- 游标/fetch size 与消费方式不合理;
- 下游已放弃请求但连接尚未及时取消。
ClientRead 则表示 server 等客户端发数据。普通 idle backend 经常在 ClientRead 等下一条命令,这不是慢 SQL;active session 在 COPY FROM、协议交互等场景也可能等客户端。必须结合 state、query、协议阶段与应用 trace。
客户端慢消费的修复候选是限制返回规模、分页/流式语义、修复 consumer、调整驱动读取方式、网络与超时传播,而不是先建索引。索引也许能缩短产生第一批行的时间,却不能让慢 reader 更快接收 400 MB。
连接池排队位于另一个边界:
未获得 slot 的请求不会出现在 pg_stat_activity。如果应用 p99 高、数据库 active sessions 刚好打满 pool size、server 单条执行仍快,应同时看:
- application pool acquire duration、waiter count、timeout;
- PgBouncer client/server active/waiting 与 pool mode;
- HAProxy/service route、连接拒绝与 backend health;
- PostgreSQL
max_connections、可用连接与角色/database 限额; - 事务长度、连接泄漏、重试风暴与并发上限。
“把 pool size 加倍”也是需要实验的假设。若数据库已经 CPU/I/O 饱和,更多并发会增加排队与上下文切换;吞吐不升而尾延迟更差。连接池的作用是排队和保护下游,不是消灭容量边界。
一棵够用的初始假设树可以写成:
这棵树不是固定排障脚本。它的价值是强迫每个解释声明边界和证据,下一节再从中选一个最小、可逆的实验。
上一节:关联日志、指标与计划 · 返回本章目录 · 下一节:设计受控实验 · 查看全书目录 · 查看索引中心
8.5 设计受控实验
诊断实验的目标不是“让一次执行变快”,而是区分竞争解释。一次同时 ANALYZE、建索引、改 SQL、增大内存又重启实例的操作即使奏效,也不知道哪项有效、哪项多余、哪项埋下副作用。
8.5.1 每次只改变一个解释变量
把假设写成实验合同:
“每次只改变一个变量”不等于一次只能执行一个命令。重建同一 deterministic fixture、采集 before/after、运行 analyzer 可以是一项完整操作;关键是两组之间只有被检验机制不同。
实验级别应逐级提升:
- 只读观察:activity、wait、统计、日志、已有计划;
- session-local probe:同一 session 改一个 setting,事务结束恢复;
- 隔离 fixture/副本:重放代表数据与并发;
- 受控 canary:少量真实流量、明确 SLO 与自动退出;
- 生产变更:审批、回退、监控和审计齐全。
不要跳级只是为了快。特别是:
- 不在生产对未知写 SQL 随意
EXPLAIN ANALYZE; - 不为对比清空全局 query statistics;
- 不用
pg_terminate_backend代替理解事务; - 不把 planner debug setting 留在连接池 session;
- 不在没有磁盘/写放大评估时直接创建大索引;
- 不把生产 traffic 同时承担探索、验证和上线三个阶段。
若无法控制某个重要变量,应把它记录为混杂因素,而不是从报告中删掉。例如 A/B 两次恰好跨 checkpoint、流量 mix 不同,则结论降级为“支持但未确认”,需要新的对照。
8.5.2 冷热缓存、参数与数据规模控制
数据库性能至少受四种实验条件影响:
缓存
“冷缓存”可能指:
DISCARD ALL 不会清空 shared buffers 或 OS page cache;重连也不等于冷缓存。生产执行 echo 3 > /proc/sys/vm/drop_caches 或重启实例会影响全机 workload,不是普通诊断动作。若真的需要 cold/warm 对照,应在隔离、可重建环境明确 cache 层并记录方法。
更常见也更安全的策略是:
- 先跑 warm-up,不计入测量;
- A/B 交替或随机顺序,减少随时间漂移;
- 重复多次,报告分布和原始值,不只选最快一次;
- 同时保存 buffers、I/O timing 与 OS 指标;
- 若无法制造 cold,明确结论只适用于 warm steady state。
参数
均匀随机参数会掩盖业务分布。先按机制分层:
每层使用脱敏、可重现的代表值,并按真实 traffic weight 汇总。一个只让 cold tenant 快 2 ms、却让占 90% 流量的 hot tenant 慢 100 ms 的索引/计划不是总体改善。
数据规模与分布
一万行测试库上的 Index Scan 不能证明十亿行生产行为。fixture 至少应保持:
- relation/partition 数量级;
- row count、row width、null fraction;
- distinct count、MCV、列间相关;
- index 与 heap physical correlation;
- dead tuple/bloat 与 statistics 状态;
- 参数访问倾斜。
无法复制完整规模时,目标是复制决策边界,而不是复制全部数据。例如第 7/8 章用 90000/10 的 tenant skew 让 generic/custom plan 面临清晰选择率差异。
并发
单 session 提速不保证系统吞吐改善。并发会改变:
- buffer/cache 命中与 I/O queue;
- CPU run queue;
- lock contention;
- 每 query 可用内存与 spill;
- connection pool queue;
- background vacuum/checkpoint 干扰。
至少分别回答:
不要用无限并发压到崩溃后,只比较“谁最后倒下”。容量实验应有 ramp、steady state、 abort threshold 与恢复验证,第 26 章会完整展开。
8.5.3 反证、回退与副作用观察
一个强实验既设计正结果,也预先声明什么会证明自己错。示例:
| 假设 | 支持结果 | 反证/降级 |
|---|---|---|
| 锁是直接原因 | blocker 释放后 waiter 立即前进,其他变量不变 | 无 blocker edge;释放后仍慢 |
| generic estimate 是原因 | custom estimate/资源显著改善 | 两者 estimate 与资源相近 |
| ClientWrite 是原因 | 无 blocker,慢 reader 恢复后 query 完成 | server 内仍有主要 I/O/Lock wait |
work_mem 太小 |
单 session 增大后 spill 消失且端到端改善 | spill 消失但延迟不变/内存压力恶化 |
| 缺索引 | 代表参数与并发下 blocks/延迟改善 | 只冷门参数改善,写放大/SLO 变差 |
“未能反证”不等于“证明”。尽量加入负对照:
- 同一 SQL 的 unaffected parameter;
- 同时间的 unaffected instance/route;
- 同 seed 重跑;
- 故意错误的诊断必须被 reveal 拒绝;
- 恢复原设置后现象按预测返回。
效果报告应同时包含:
副作用经常决定一个“快方案”不可上线:
- 新索引增加写 latency、WAL、磁盘、vacuum 工作;
- 提高
work_mem增加并发 OOM 风险; - 缩短 timeout 降低资源占用,却提高错误/重试风暴;
- 增大 pool 提高 backend 并发,却让数据库饱和;
- 缓存结果改善读取,却引入失效与一致性问题;
- denormalization 减少 join,却增加写路径和校验复杂度。
回退不是文档最后一行“必要时回滚”。实验前就应验证:
本章三个 case 都把复位写进执行路径:
- estimate 只改变 session-local plan mode;
- lock 的 blocker 与 waiter 最终 rollback;
- client 只生成派生结果并精确取消本实验 backend;
- final
verify.sql检查 worker=0、ch07 fixture 和 ch04-v1 checksum。
运行全量实验:
这里 all 是 L1 教学环境动作。它会在必要时受控重建 ch07 专属 fixture、制造短暂锁与 socket backpressure,并精确取消自己的 worker;不会成为生产诊断脚本。
下一节把同一方法映射到 Pigsty:用面板快速发现范围,用原生证据确认,而不依赖某个版本的按钮位置。
上一节:建立而不是猜测假设 · 返回本章目录 · 下一节:从可观测面板回到原生证据 · 查看全书目录 · 查看索引中心
8.6 从可观测面板回到原生证据
Pigsty 的价值之一,是把 PostgreSQL、主机、连接池、代理、日志和 catalog 信息放进同一套时间与标签体系。面板擅长发现“何时、哪里、哪个查询族”异常;最终解释仍要能落回 exporter expression、PostgreSQL view、日志字段和计划 artifact。
8.6.1 用指标语义定位时间、实例与查询
从宽到窄浏览,而不是从某张“慢查询”表直接猜根因:
在当前 Pigsty dashboard 分类中,可按问题选择入口:
| 问题 | 参考 dashboard 家族 | 要带走的身份 |
|---|---|---|
| 全局/cluster 是否退化 | PGSQL Overview、Alert、Cluster | cluster、instance、role、UTC window |
| session、负载、锁 | PGSQL Activity、Session、Xacts、PGCAT Locks | database、application、state/wait、PID/session |
| query family | PGSQL Query、PGCAT Query、Database | db/user/queryid、calls/time/rows |
| 代理与连接池 | PGSQL Service、Proxy、PgBouncer | service route、pool/database/user |
| WAL/checkpoint/I/O | PGSQL Persist、Instance | instance timeline、counter/rate |
| 日志事件 | PGLOG Overview、Session | session/PID、SQLSTATE、timestamp |
这些名字是 Pigsty 的当前参考实现,不是永恒导航路径。若版本调整 dashboard,仍按“影响范围 → 实例 → 会话/query → 原生证据”的语义寻找。
打开任何图前,先读变量和 expression:
- 当前 cluster/service/instance/database/query 变量是什么;
- timezone 与 absolute start/end 是什么;
- unit 是 seconds、milliseconds、bytes、rows 还是 ratio;
- 原始 metric 是 counter、gauge 还是 histogram;
rate()/increase()窗口与 Grafana step 是多少;- label 是否在 recording rule 中被聚合掉;
- primary role/failover 前后 instance identity 是否变化;
No data表示 0、未抓取、权限不足还是 exporter 错误。
颜色是展示规则,不是 PostgreSQL 语义。同一个红色可能代表固定阈值、动态 baseline 或只是主题配置;同一个“QPS”可能是 query counter rate、transaction rate 或 application request rate。下结论前保存 panel query、变量、时间范围与 datasource。
一个可靠的面板收敛过程:
- 把用户事故窗口扩大到前后各一段,观察变化点;
- 用同星期/相近流量窗口做 baseline;
- 对比 primary/replica、受影响/未受影响实例;
- 找到 database/user/application/queryid,而非只看 cluster 总量;
- 同屏核对 calls/total/rows、wait/locks、CPU/I/O、pool queue;
- 保存 absolute UTC window 和 query identity;
- 用下一目的 SQL/log/plan 复核。
8.6.2 用 SQL、日志和计划复核面板判断
面板提示 Lock wait 后,原生复核应至少得到一条边:
面板提示某 query family 预算上升后,保存累计边界:
若 panel 来自 Prometheus counter,优先导出事故窗口的 expression/result,而不是把当前累计 SQL 行与历史五分钟 rate 直接比较。记录:
日志复核要能串到同一 session 或 query:
计划复核则保存可机器比较的 JSON 与环境:
再在安全环境使用代表参数执行:
如果生产语句正在 Lock/ClientWrite 等待,activity 与日志可能已经足够证明直接机制;不要为了补一张计划图冒险重跑。计划回答“executor 选择与处理了什么”,wait 回答“采样时为什么没前进”,两者互补。
面板提示“连接用满”也要回到边界:
- PostgreSQL session count 与 state;
- PgBouncer client/server active/waiting;
- application pool acquire duration;
- HAProxy backend/session;
- service route 与 primary role。
仅看到 PostgreSQL connections 接近上限,无法区分连接泄漏、长事务、pool 配太大、流量上升或 failover 重连风暴。
8.6.3 不把截图、颜色或当前点击路径当作知识
截图可以证明“当时有人看见某个画面”,却通常缺少:
- panel expression 与数据源;
- 变量值与隐藏 filters;
- absolute start/end、timezone 和 step;
- unit、legend 聚合与 null handling;
- dashboard/version/commit;
- 原始样本与可重算结果;
- query/session identity;
- 图外的对照与反证。
因此证据包的优先级应是:
可长期保留的知识应写成语义:
而不是:
前一种写法能跨 Pigsty/Grafana 版本、主题和自定义 dashboard;后一种在下一次升级就失效。需要记录当前实现时,附上:
面板本身也需要验证。常见故障包括 exporter down、scrape timeout、label 重命名、recording rule 计算错误、queryid 类型/符号处理、counter reset 未处理和 dashboard variable 选错实例。若面板与原生 SQL 矛盾,先核对口径与时间,不要自动相信“更漂亮”的一方。
本章的方法把 Pigsty 定位为可观测性工作台:
下一节把这条链放进三个外观相似、修复方向完全不同的可复现实验。
参考资料
- Pigsty:PostgreSQL Dashboards
- PostgreSQL 18:Cumulative Statistics System
- PostgreSQL 18:pg_stat_statements
上一节:设计受控实验 · 返回本章目录 · 下一节:实战:三种“慢”只修真正瓶颈 · 查看全书目录 · 查看索引中心
8.7 实战:三种“慢”只修真正瓶颈
三个 case 都制造“请求没有及时返回”,但修复方向互斥:
| case | 决定性证据 | 被排除的直接解释 | 正确方向 |
|---|---|---|---|
| estimate/plan | generic error 900x,custom 1x,无 Lock/ClientWrite | 当前锁链、慢客户端 | 参数/统计/计划与访问路径 |
| lock wait | active + Lock/transactionid,blocker=1 |
ClientWrite、正在推进的 plan | root blocker 与事务边界 |
| client slow consumer | active + Client/ClientWrite,blocker=0 |
数据库锁链 | 返回规模、客户端/网络/读取方式 |
实验不以单次 elapsed time 或节点名作为 golden。它保存机器可读 signal,再由一个不能读取答案文件的分类器只按证据关系判断。
8.7.1 估算错误、锁等待与客户端慢消费
确认 service 指向可写、可演练的 L1:
若 ch07 fixture 已通过,all 直接复用;若缺失,则调用 ch07 marker guard 做受控重建。之后顺序为:
一次实测输出:
case A:estimate/plan
estimate-case.sh 复用 ch07 tenant skew,用同一 SQL、同一 hot parameter 分别捕获:
本次机器上 generic 是 Index Scan、custom 是 Seq Scan,但分类器不以节点名判定;不同硬件、cost setting 或 PostgreSQL 版本可能选择不同 shape。稳定事实是 generic 用总体平均选择率严重低估 hot parameter,custom 能看见具体参数并改善估算。
这也不自动宣布永久修复应是 force_custom_plan。生产还要比较:
- hot/cold traffic weight;
- planning 与 execution 成本;
- prepared statement/driver/pool 行为;
- statistics、SQL/index 是否有更好解;
- 并发下延迟、blocks 与写副作用。
case B:lock wait
lock-case.sh 调用第 5 章的确定性编排:
中性 signals:
直接原因是锁等待,不是 waiter 的执行计划。长期修复应回到 blocker 所属事务:为何持锁、是否在事务内睡眠/调用外部服务、锁顺序是否一致、更新集合是否过宽。取消是止血且只允许精确命中实验身份。
case C:client slow consumer
client-write-lab.sh 用 generate_series 生成大结果,由 slow-reader.py 每次只读少量字节并等待。它不读业务表,也不创建对象。
observer 必须同时看到:
随后用 PID + backend_start epoch + database + unique application name 精确执行 pg_cancel_backend,等待 pipeline 退出并确认 worker=0。
这个 case 特意证明 planner 的 elapsed/cost 边界:server 可能很快产生数据,却因客户端不读而长时间不返回。修复应查返回规模、分页/流式协议、driver fetch、客户端线程与网络;取消任意 PID或加索引都没有解释证据。
8.7.2 随机隐藏一种根因,先独立诊断再揭晓
为避免“知道脚本名再写结论”,运行 seeded mystery:
PG36_CASE_SEED 经 SHA-256 后按三类取模。public 目录只给出 metadata、raw evidence 与中性 signals.json;ground truth 写到:
其 mode 为 0600。这防止正常步骤误读,不是同一 OS 用户之间的加密安全边界;知道源代码和 seed 的人可以作弊。盲测的纪律是先不读取 .sealed,独立填写诊断记录模板:
再运行答案盲诊断器:
diagnose.py 的三条规则是:
它的输出固定声明:
最后揭晓:
若 diagnosis 与 sealed ground truth 不一致,reveal 写 matched=false 并返回非零,不能把错误猜测包装成通过。task.sh all 内置一个故意错误的负对照,稳定验收要求该负对照失败。
想提交人工分类时,可在 public 目录创建最小 JSON:
然后指定:
先写假设再 reveal 的顺序比“猜错后改答案”更接近真实事件复盘。
8.7.3 输出证据包、假设树、修复前后对照与复位结果
task.sh all 的 evidence 结构按用途分层:
其中 raw artifact 负责可复核,中性 signals 负责教学分类,diagnosis 负责解释和排除项,review 只断言稳定关系。不要删 raw 只保留 status=ok。
baseline-v0.3-proposal.json
把本章证据追加到 DEFAULT-EVID-009:慢请求证据必须包含影响范围、
UTC 时间窗、wait/blocking edge、query/parameter identity、竞争假设和
复位结果;盲测诊断不得读答案,错误负对照必须失败。它绑定不可变
v0.1 checksum,并绑定 ch07 v0.2 proposal 的 canonical checksum;在 v0.2
尚未晋升前,它仍只是有依赖的 v0.3 candidate,不冒充已发布规约。
一份生产级证据包还应补齐:
- 用户 SLI 时间窗、样本数、吞吐、并发、错误;
- request/trace/application release identity;
- service/cluster/instance/database/user/application;
- PID + backend_start(若做实时会话动作);
- query family、参数分桶与数据敏感处理;
- Prometheus expression、Grafana variables/step/datasource;
- 日志 session、SQLSTATE、duration 与采样率;
- before/after 计划、statistics、settings、版本;
- 假设排序、反证、审批动作与回退阈值;
- 修复前后 correctness/SLO/resource 副作用;
- artifact hash、访问权限与保留期限。
本章没有持久 ch08 对象,所以没有“删库式 reset”。复位是每个 case 的成功条件:
手动复核:
若 all 为本章自动重建了 ch07 fixture,它会保留供后续计划/索引实验使用。清理 ch07 属于另一个 R2 动作,只能按第 7 章的双 token reset 执行;本章不会替读者擅自删除。
完成实验后,读者应能在看到“请求卡住”时先问:
下一章只有在证据指向访问路径时才进入索引设计,避免把索引当所有慢请求的通用药方。
上一节:从可观测面板回到原生证据 · 返回本章目录 · 下一章:巧夺天工:索引设计与效果验证 · 查看全书目录 · 查看索引中心