一次 PostgreSQL 生产恢复实录:从序列错位到索引损坏
一次 PostgreSQL 生产恢复实录:从序列错位到索引损坏
一次夜间维护中,我们处理了某业务系统的数据库故障。最初,后台服务因自增序列落后于表内最大 ID 而启动失败。修正序列后,服务仍未恢复:新版本迁移被防回卷 VACUUM 阻塞,而数据库在处理索引时又反复发生 PANIC。
最终,我们在保全现场并验证独立恢复副本后,定位并重建了一个损坏的 B-tree 索引,让原新版完成迁移,再通过 41 次采样、1,280 秒的生产观察,确认应用健康检查通过,业务数据继续入库,成功处理回执继续产生。
这次经历值得分享的地方,是每一步如何判断“已经知道什么”,以及这些证据足不足以支持下一步操作。本文依据当时的恢复记录复盘,业务表、字段和索引使用通用示例名称。
1. 一个看起来很直接的启动错误
现场应用是一套容器化部署的 .NET 后台服务,数据库使用 PostgreSQL 12.9。新版服务在启动时会执行数据库兼容性检查和 EF Core 迁移。
启动日志给出的错误很明确,核心含义是:
自增序列的位置落后于业务表当前的最大 ID。
业务事件表的主键使用 bigint identity,关联独立序列。现场读取结果的量级如下:
| 项目 | 修复前的现场值 |
|---|---|
| 表内最大 ID | 接近百亿 |
| 序列当前位置 | 约 1.3 亿 |
| 最大 ID 对应记录 | 早于本次维护的历史记录 |
| 序列配置 | 步长 1、缓存 1、不循环 |
这里需要先厘清 PostgreSQL 的行为:向 identity 列显式写入一个较大的 ID,并不意味着关联序列一定会自动追上它。导入、恢复或兼容写入都可能造成类似状态,但本次没有证据确认这条大 ID 记录的具体来源。
现场业务仍在上报。我们核对了序列配置、最大 ID 和并发写入情况,将序列前移至已确认的表内最大 ID,并设置 is_called=true,使下一次自动分配从该值加一开始。随后再次采样,表内最大 ID 与序列已同步增长,说明原序列错误消失,自动 ID 分配继续前进。
这次处理有很明确的适用条件:序列与最大 ID 存在巨大的已确认间隔,序列配置已核实,而且修复只向前移动。ACCESS SHARE 锁并不阻断 INSERT,因此不能把现场方法简化为“查一下最大值,再在线 setval”的通用并发修复方案。
2. 修正序列之后,为什么还要继续等?
原错误消失后,应用开始执行 6 项 EF Core 迁移。前两项完成,后续创建用于筛选待处理事件的部分索引时等待关系锁。
阻塞者是业务事件表的防回卷 autovacuum。这个表当时约有两千多万个 8 KiB heap blocks,仅 heap 就约 165 GiB。对这种体量的表,维护耗时长并不意外。初期看到 VACUUM 在扫描,继续观察有合理依据。
问题出在更长的日志时间线上。数据库反复出现:
PANIC: corrupted line pointer: offset = 0, size = 0
LOG: all server processes terminated; reinitializing
LOG: database system was interrupted
这些错误在本次序列修复之前就已存在,之后仍反复出现,间隔约 10 至 14 分钟。数据库进入崩溃恢复,客户端连接被中断,应用因此收到 Npgsql EndOfStreamException。现场还观察到 VACUUM 出错前处于 vacuuming indexes 阶段。
此时,等待策略的前提已经变化。进度条在动,却不能证明任务会完成:VACUUM 可能在反复扫描、出错、恢复,再从头开始。
还有一个容易误判的地方:PostgreSQL 内部发生 PANIC 和重初始化,Docker 容器的 RestartCount 仍可能为 0。 只看容器是否存活,无法判断数据库内部是否稳定,必须结合数据库日志和实际查询。
我们也曾在迁移未结束时重启应用,导致旧迁移连接短暂继续等待并持有 advisory lock。这进一步说明:大表迁移期间,应用重启不一定能清掉数据库侧的等待关系;先查会话与锁,才能知道重启会解决什么。
3. 没有确认可用的备份,先建立恢复起点
当时没有确认到可恢复的 PostgreSQL 外部备份。服务器上虽然存在备份脚本和计划任务,但它们属于 MySQL。PostgreSQL 的 archive_mode=off,也没有在线复制连接。不能因为机器上有一个叫“备份”的目录,就假定这套数据库已经有恢复保障。
我们先确认存储布局:PGDATA、WAL 和默认 tablespace 都位于同一逻辑卷上,没有外部 tablespace。随后创建 128 GiB COW 空间的 LVM 保全快照,保留当时的数据库和 WAL 状态。
快照能保存现场,但它与主卷共享底层存储,也包含已经存在的损坏。它既不能提供跨设备保护,也不会自动变成一个健康数据库。因此,恢复准备分成了三层:
| 对象 | 本次用途 | 证明范围 |
|---|---|---|
| 128 GiB 保全快照 | 保存维护前现场 | 保留已有状态,包括损坏 |
| 可丢弃的诊断快照 | 隔离启动并检查索引 | 证明该副本可启动、指定检查有结果 |
| 另一块设备上的独立恢复副本 | 独立保留 PGDATA 和 WAL | 验证恢复启动与正常关闭,不代表全部页面健康 |
独立副本从同一固定时间点的快照复制,复制期间不在源副本上运行 PostgreSQL。它最终包含数千个文件,约 417 GiB。元数据比对无差异、有界同步成功之后,再用断网隔离容器完成崩溃恢复和正常关闭。
前后控制文件的 system identifier 保持一致,状态最终变为 shut down。这才允许我们把它登记为已验证可恢复的独立副本,而不是只登记“文件复制完成”。
复制过程并不顺利。现场 rsync 3.1.2 不支持 --fsync;一次状态查询的超时设置太短,导致复制提前退出;续传时全量校验已有内容又非常耗时。最后改为先校验已有的数百个文件,再复制剩余文件,两阶段使用同一个固定源和同一个时间预算。
这里有两个实用经验:恢复工具也需要验证现场版本;续传必须保证目标最终对应一个一致的源时间点。直接把多个时点的数据库文件拼在一起,文件都在,也未必能恢复。
4. 把“怀疑索引损坏”变成可验证的判断
根据 PANIC 信息、VACUUM 阶段及 PostgreSQL 12.9 相关源码,我们把既有 B-tree 索引作为重点检查对象。不过,日志里的 line pointer 错误本身不足以直接指认某一个索引,更不能直接推出整个业务表已经损坏。
检查在隔离副本中进行,使用现场同一 PostgreSQL 镜像,断网、关闭 TCP 监听,并限制 CPU 和内存。amcheck 只安装在副本中。
第一次检查使用 65 秒的 statement timeout:
| 索引 | 第一次检查 | 后续完整检查 |
|---|---|---|
| 主键索引 | 超时 | COMPLETE |
| 状态字段索引 | 超时 | COMPLETE |
| 时间与状态组合索引 | 结构错误 | ERROR_STRUCTURE,SQLSTATE XX002 |
目标索引重复报告:
ERROR: item order invariant violated for index
异常页、item 和 LSN 与先前诊断一致,索引结构异常由此得到确认。另外两个索引在更长的有界检查中完成,补齐了最初超时留下的未知项。
超时只能登记为未验证。 同样,indisvalid=true 表示目录状态允许查询使用索引,并不保证每一个物理页面都没有损坏。
本次使用的是 bt_index_check(..., false)。它提供的是指定 B-tree 的结构检查结果,没有开启 heapallindexed,不能证明 heap 中每一条应有的记录都被索引覆盖,也不能证明全部 heap、TOAST 或其他对象健康。
5. 最终为什么选择普通 REINDEX?
最初考虑并尝试了 REINDEX INDEX CONCURRENTLY,希望降低对业务写入的影响。但并发重建同样会与同表 VACUUM 的关系锁冲突,未完成的尝试还留下了一个 _ccnew 对象。
现场应用此时已经停止。重新评估维护条件后,最终使用普通 REINDEX INDEX 完成目标重建。两种方式的主要取舍是:
| 方式 | PostgreSQL 12 的关键行为 | 本次需要承担的影响 |
|---|---|---|
普通 REINDEX INDEX |
对表取得 ShareLock,对目标索引取得 AccessExclusiveLock;主要一次 heap 扫描 |
表写入被阻塞;读取该索引也可能等待 |
REINDEX INDEX CONCURRENTLY |
使用 ShareUpdateExclusiveLock,执行构建与验证扫描,并等待相关事务 |
锁等待与扫描更多,失败可能留下 _ccnew / _ccold |
“并发”降低的是部分正常业务阻塞,不等于没有锁,也不保证恢复耗时更短。现场既然已进入明确的维护状态,就应该按当时的锁竞争、业务影响和可验证性重新选择。
对于遗留对象,清理也必须有边界。本次只处理了与失败尝试证据绑定的 _ccnew,其 OID、filenode、名称、父表和相关属性均已核对,状态为 valid=false、ready=false。没有按后缀批量删除未知对象。
最终采用普通重建。以下使用通用索引名称展示维护语句,实际执行前须完成备份、对象与锁核验:
REINDEX INDEX public."IX_event_data_uploaded_at_status";
结果保持了逻辑索引名称、时间与状态字段的组合定义及原 OID,物理 filenode 已更换。普通重建保留 OID,因此不能沿用并发方案中“成功后 OID 应更换”的判断条件。
重建完成后,我们又从修复后的生产状态创建新的隔离副本,完整检查目标索引。结果为 TargetAmcheck=COMPLETE,隔离数据库正常关闭,生产数据库身份复核通过。数据库返回一条“REINDEX 成功”,与修复后独立检查通过,是两份不同的证据。
6. 防回卷 VACUUM:可以调度,不能随意绕过
现场表的 XID 年龄约为 2.01 亿,已有防回卷 VACUUM。它承担的是避免事务 ID 回卷的数据库维护责任,不能把它简单当作一个挡路的后台任务。
本次维护没有关闭生产 autovacuum,也没有提高生产 freeze 阈值。整个处理过程中,两次取消都针对精确核验过的业务事件表防回卷 worker,分别服务于索引修复和新版迁移的锁调度。
每次都先确认等待会话与阻塞者的 PID、backend_start、数据库、准确 SQL、关系锁和 pg_blocking_pids()。只有唯一阻塞者确实是已识别 worker,才取消这一轮,并继续核对维护是否取得所需锁。单次取消不会免除后续 VACUUM 责任,XID 年龄和维护进度仍要继续观察。
另一个脚本细节也值得记录:应用现场建索引 SQL 没有尾部分号,最初严格匹配的文本条件与它不一致。我们修正匹配逻辑,保留其余身份和锁门禁,而没有为了让脚本继续运行而放宽到按名称批量取消会话。
隔离诊断容器有独立的维护配置,它只用于可丢弃副本的检查。文章中“未修改 freeze/autovacuum 参数”指生产配置,不能混淆这两个环境。
7. 恢复验收,要看到真实业务前进
修复后,我们继续使用原新版,让应用自己完成剩余迁移,没有手工伪造 __EFMigrationsHistory,也没有禁用启动校验。旧损坏索引最终采用普通重建,新版的待处理事件索引仍由应用自己的 CREATE INDEX CONCURRENTLY 创建,两者不能混为同一项操作。
验收包含三个层次:先核对 6 项迁移、11 个支撑索引和新的待处理事件索引;再核对 Docker healthy 与 /healthz;最后核对新数据入库,以及使用新版处理语义的成功回执,其关联业务记录处于已完成状态。
最后一层尤其关键。现场 /healthz 的 self 检查主要说明应用进程在运行,不能单独证明消息已经完成业务处理。成功回执与对应业务记录,才补上了这一段证明。
此前 PANIC 大约每 10 至 14 分钟出现一次,因此恢复后观察下限设置为 20 分钟,计划采样 41 次,每次间隔至少 30 秒,并加上整体时间上限和取消路径。
第一次观察在读取日志时触发 12 秒超时门禁,只完成 1 次采样,不能算成功。随后把增量日志读取限制为最近 2,000 行,重新预检并从头执行完整观察。最终结果为:
| 验收项目 | 现场结果 |
|---|---|
| 健康与数据库采样 | 41 / 41 |
| 稳定窗口 | 1,280 秒,约 21 分 20 秒 |
| 迁移与索引 | 6 项迁移、11 个支撑索引、待处理事件索引及目标索引均通过门禁 |
| 入库 ID 与序列 | 新入库 ID 持续增长,序列未落后 |
| 成功处理回执 | 新成功回执继续产生,关联业务记录处于已完成状态 |
| 容器状态 | 应用 healthy,应用及数据库容器身份、启动时间、重启计数稳定 |
| 已采集 PostgreSQL 日志 | 未匹配到新的 PANIC 或重初始化标记 |
这组结果支持“服务在观察窗口内恢复并稳定处理业务”。它不能扩展成“全库已经完全健康”。尤其是日志设置了 2,000 行尾部上限,如果一个采样间隔内产生更多日志,就可能遗漏早期内容;后续应使用连续日志采集或带文件偏移的读取,并记录截断状态。
观察结束时,防回卷 VACUUM 已从索引阶段推进到 heap 阶段,但尚无其全部完成的证据。完整 heap/TOAST 校验、长期稳定性及底层存储健康,也仍需后续工作。
8. 把恢复过程写成可审查的证据链
这次处理没有把判断留在口头上。每个阶段都保留身份、输出、退出状态和清理记录,后一个阶段读取前一阶段的成功摘要。主要判断关系如下:
| 现场证据 | 支持的判断 | 据此采取的动作 |
|---|---|---|
| 表内最大 ID 明显超过序列,序列配置已核对 | 自增兼容条件不满足 | 现场前移序列并验证新入库 |
| 历史 PANIC 重复出现,VACUUM 在索引阶段出错 | 单纯等待不足以恢复 | 保存日志、保全数据、隔离诊断 |
| 目标索引重复结构错误,另外两项检查完成 | 已定位一个损坏索引 | 对单个目标实施有界重建 |
| 新副本目标检查完成,身份与正常关闭通过 | 修复后指定结构检查通过 | 受控启动新版迁移 |
| 迁移、健康检查、业务回执及 20 分钟观察通过 | 观察窗口内服务恢复 | 登记结果,保留备份与后续限制 |
阶段顺序也能帮助后续复盘。以下只列关键节点,避免把每一次采样写成一篇流水账:
| 阶段 | 关键节点 |
|---|---|
| 初步排障 | 序列前移,原启动错误消失 |
| 扩大诊断 | 核对历史日志,发现反复索引页相关 PANIC |
| 隔离定位 | 副本确认目标索引结构异常 |
| 恢复准备 | 独立副本复制、恢复启动和正常关闭验证完成 |
| 完整检查 | 三个既有索引的检查结果收齐 |
| 定向修复 | 转入普通 REINDEX 路径,完成目标重建 |
| 修复复核 | 新的隔离副本完成目标结构检查 |
| 应用恢复 | 原新版受控启动,完成剩余迁移 |
| 业务验收 | 完成 41 次恢复观察并登记结果 |
实际脚本还遇到了两类环境差异。CentOS 上的 ps -o pid=,pgid= 输出不符合预期,需要改成重复 -o;Docker inspect 的 Mounts 数组顺序会变化,直接对原始 JSON 算哈希会误报配置变化。
后者最终使用结构化解析,只规范化挂载列表的顺序,保留其他配置数组的顺序和真实字段差异。这类问题看起来与数据库无关,却直接影响“是否仍在操作同一个现场”的判断可靠性。
所有长任务都设置迭代数、墙钟上限和容量停止条件;取消时核对 PID、启动时间、命令行与父进程链,只回收本次创建的对象。工具超时和身份门禁失败都保留证据,不自动记为成功,也不无限重试。
9. 下次可以提前做好的事
这次恢复最终完成,但几个准备项原本可以在故障前解决。
首先,备份验收要明确到数据库实例、数据目录、WAL、保留周期和恢复演练。备份脚本存在、任务执行成功、文件复制完整、数据库可恢复、业务数据完整,是不同的验收项。本次独立副本位于同一服务器的另一块设备上,后续仍需要异机备份,覆盖整机故障的恢复场景。
其次,数据库告警应同时观察内部 PANIC、崩溃恢复、客户端异常和 XID 年龄。Docker 容器存活与 RestartCount=0,不足以支撑数据库健康结论。
对于大表建索引,应在发布前评估锁竞争、扫描量、WAL、排序空间和维护窗口。165 GiB 是本次 heap 的量级,并不代表完整数据库大小,也不代表操作只会产生同等大小的 I/O。启动迁移耗时可能直接转化为服务恢复等待时间。
序列检查也应进入数据导入和恢复流程,尤其要关注显式 ID 写入。发现错位时记录来源与并发条件,避免每次靠启动失败才暴露问题。
索引损坏的来源也需要继续调查。本次有限的内核日志检查没有发现相关 I/O 错误,现场未执行完整磁盘诊断,不能据此证明硬件健康。重建解决了已经确认的索引结构问题,尚未解释损坏最初如何发生,也没有证明它是 PANIC 的唯一来源。
最后,恢复观察应根据已知故障周期设计,并同时覆盖真实业务。此次 20 分钟窗口来自历史 PANIC 的复发节奏;对于更长的周期,还需要相应延长。修复命令完成后,工作要继续推进到迁移完成、业务前进、证据保存及临时资源清理,才算把现场交代清楚。
参考资料
本次只对已确认的问题实施定向处理;其他环境即使出现同样报错,也需要重新核对数据库、序列、索引和维护状态。本次维护没有通过删除业务历史数据或以旧副本覆盖主库的方式恢复服务;业务记录的全量完整性不在本次验证结论内。
恢复脚本应绑定当前环境的对象身份与前置条件。迁移到其他环境时,应重新核对维护条件和证明范围。

浙公网安备 33010602011771号