事故现场
一次私有化部署的客户,K8s集群所在的物理机房经历了一次意外断电,UPS没扛住,整个集群硬关机。
大部分无状态服务重启后自动恢复了——这也是K8s的优势所在。但MySQL没那么好说话。Pod起来了,容器起来了,mysqld进程直接CrashLoopBackOff:
[ERROR] InnoDB: Page [page id: space=2, page number=305] log sequence number 294572830 is in the future! [ERROR] InnoDB: Your database may be corrupt or you may have copied the InnoDB tablespace but not the InnoDB redo log files. [ERROR] InnoDB: Plugin initialization aborted with error Generic error [ERROR] Plugin 'InnoDB' init function returned error. [ERROR] Plugin 'InnoDB' registration as a STORAGE ENGINE failed. [ERROR] Failed to initialize builtin plugins. [ERROR] Aborting翻译成人话:InnoDB的redo log和数据页对不上了。断电那一刻,有些脏页刚写了一半,redo log也没来得及flush。MySQL很诚实——“我对不上账,不干了”。
这种情况在物理机上不算罕见,但在K8s里恢复起来多了几层复杂度——你不能直接改配置文件,因为Pod重启就回到镜像状态;你不能随便进容器操作,因为CrashLoopBackOff意味着容器活不过几秒。
下面是完整的恢复过程。我们踩了几个坑,最后数据一条没丢地救回来了。
恢复思路
核心策略分三步:
第一步:用 innodb_force_recovery 强制启动MySQL(只读模式) 第二步:mysqldump 导出全量数据 第三步:重建一个干净的MySQL实例,导入数据为什么不直接修复原库?因为强制恢复模式下的MySQL是"带伤运行",InnoDB的内部状态可能已经不一致。即使能起来、能读数据,继续写入是有风险的。最稳妥的方式是:趁它还能读,赶紧把数据倒出来,然后在一个干净的实例上重建。
第一步:理解 innodb_force_recovery
在动手之前,先搞清楚这个参数。innodb_force_recovery是MySQL的"急救模式开关",取值从1到6,数字越大越暴力,跳过的检查越多,数据丢失的风险也越大。
原则:从小到大试,能用最小值启动就不要用更大的。
| 级别 | 含义 | 跳过了什么 | 风险 |
|---|---|---|---|
1(SRV_FORCE_IGNORE_CORRUPT) | 忽略损坏页 | 遇到损坏的数据页时不崩溃,而是跳过 | 低。损坏页上的数据可能读不到,但其他数据完好 |
2(SRV_FORCE_NO_BACKGROUND) | 阻止后台线程 | 不启动master thread和purge thread | 低。相当于冻结了InnoDB的后台清理操作 |
3(SRV_FORCE_NO_TRX_UNDO) | 跳过事务回滚 | 不做crash recovery中的undo回滚 | 中。断电时未提交的事务不会被回滚,可能看到"半成品"数据 |
4(SRV_FORCE_NO_IBUF_MERGE) | 跳过insert buffer合并 | 不合并二级索引的change buffer | 中。二级索引可能和主键数据不一致 |
5(SRV_FORCE_NO_UNDO_LOG_SCAN) | 跳过undo log扫描 | 不查看undo log,把未完成的事务视为已提交 | 高。可能把脏数据当成正常数据 |
6(SRV_FORCE_NO_LOG_REDO) | 跳过redo log前滚 | 不做redo log的前滚恢复 | 最高。基本上是"不管日志说什么,直接用磁盘上现有的数据页" |
每一级都包含前面所有级别的行为。也就是说设置为3,意味着同时生效1、2、3。
重要:级别>=4时,MySQL会拒绝所有写入操作(INSERT/UPDATE/DELETE),只允许SELECT和mysqldump。
实际上从级别1开始,MySQL就已经是"受限模式"了。官方文档明确说了:innodb_force_recovery > 0 时不应该用于正常生产,只用于数据抢救。
第二步:创建ConfigMap覆盖MySQL配置
K8s里MySQL的配置文件在镜像里或者已有ConfigMap里。我们需要创建(或修改)一个ConfigMap,注入innodb_force_recovery参数。
2.1 先看当前MySQL的部署方式
# 确认MySQL的部署资源类型(Deployment/StatefulSet)kubectl get statefulset,deployment-n<namespace>|grepmysql# 查看当前Pod的挂载情况kubectl describe pod<mysql-pod-name>-n<namespace>|grep-A10"Volumes"记下你的namespace、StatefulSet/Deployment名称、PVC名称——后面都要用。
2.2 创建恢复用的ConfigMap
创建一个mysql-recovery-config.yaml:
apiVersion:v1kind:ConfigMapmetadata:name:mysql-recovery-confignamespace:<namespace># 替换为你的namespacedata:recovery.cnf:|[mysqld] innodb_force_recovery = 1先从级别1开始。应用这个ConfigMap:
kubectl apply-fmysql-recovery-config.yaml2.3 修改MySQL的StatefulSet/Deployment,挂载这个ConfigMap
kubectl edit statefulset<mysql-statefulset-name>-n<namespace>在volumes部分添加:
volumes:-name:recovery-configconfigMap:name:mysql-recovery-config在容器的volumeMounts部分添加:
volumeMounts:-name:recovery-configmountPath:/etc/mysql/conf.d/recovery.cnfsubPath:recovery.cnf为什么用subPath?因为
/etc/mysql/conf.d/目录下可能已经有其他配置文件,直接挂载整个目录会把原来的配置覆盖掉。用subPath只添加这一个文件,MySQL启动时会自动加载conf.d目录下所有的.cnf文件。
保存退出后,K8s会自动重建Pod。观察Pod状态:
kubectl get pod-n<namespace>-w|grepmysql2.4 如果级别1没起来,逐级提升
如果Pod还是CrashLoopBackOff,查看日志确认报错:
kubectl logs<mysql-pod-name>-n<namespace>--previous然后修改ConfigMap,把级别提升到2:
kubectl edit configmap mysql-recovery-config-n<namespace>把innodb_force_recovery = 1改成innodb_force_recovery = 2。
因为ConfigMap更新后不会自动触发Pod重启(subPath挂载的限制),需要手动重启:
kubectl delete pod<mysql-pod-name>-n<namespace>Pod删除后StatefulSet会自动重建。继续观察日志。
依次尝试1→2→3→4→5→6,直到MySQL能正常启动。
我们当时的情况是在级别3启动成功了——redo log和undo log都有损坏,跳过事务回滚后MySQL终于愿意起来了。
# 确认MySQL已经Runningkubectl get pod-n<namespace>|grepmysql# 进容器验证能登录kubectlexec-it<mysql-pod-name>-n<namespace>-- mysql-uroot-p# 检查一下库表是否还在mysql>SHOW DATABASES;mysql>USE<your_database>;mysql>SHOW TABLES;mysql>SELECT COUNT(*)FROM<some_important_table>;看到数据还在的那一刻,心终于放下了一半。
第三步:导出全量数据
MySQL能起来了,但它现在是"带伤状态",随时可能再崩。争分夺秒把数据导出来。
3.1 用mysqldump导出
# 方式一:直接在Pod内执行,导出到Pod本地再拷出来kubectlexec-it<mysql-pod-name>-n<namespace>--\mysqldump-uroot-p--all-databases\--single-transaction\--routines\--triggers\--events\--set-gtid-purged=OFF\>/tmp/full_backup.sql# 把备份文件从Pod拷到本地kubectlcp<namespace>/<mysql-pod-name>:/tmp/full_backup.sql ./full_backup.sql# 方式二:直接通过管道导出到本地(推荐,不占Pod磁盘空间)kubectlexec-it<mysql-pod-name>-n<namespace>--\mysqldump-uroot -p<password>--all-databases\--single-transaction\--routines\--triggers\--events\--set-gtid-purged=OFF\>./full_backup.sql几个参数说明:
--single-transaction:对InnoDB表使用一致性快照,不锁表。在force_recovery模式下尤其重要,因为此时FLUSH TABLES WITH READ LOCK可能会失败--routines:导出存储过程和函数--triggers:导出触发器--events:导出定时事件--set-gtid-purged=OFF:如果你用了GTID复制的话,导入时避免GTID冲突
3.2 如果mysqldump报错
force_recovery模式下,某些表可能因为数据页损坏而无法读取。mysqldump可能会在某个表上卡住或报错。
这时候按数据库逐个导出,跳过有问题的表:
# 逐库导出kubectlexec-it<mysql-pod-name>-n<namespace>--\mysqldump-uroot -p<password>\--single-transaction\--routines--triggers--events\--set-gtid-purged=OFF\<database_name>>./<database_name>_backup.sql如果某张表始终读不出来:
# 排除指定表kubectlexec-it<mysql-pod-name>-n<namespace>--\mysqldump-uroot -p<password>\--single-transaction\--set-gtid-purged=OFF\<database_name>\--ignore-table=<database_name>.<broken_table>\>./<database_name>_backup.sql记录下哪些表没导出来,后面评估数据损失。
3.3 验证备份文件
导出完成后务必验证:
# 检查文件大小是否合理ls-lh./full_backup.sql# 检查文件结尾是否正常(正常的mysqldump文件最后几行)tail-5./full_backup.sql# 应该看到类似这样的内容:# -- Dump completed on 2026-05-06 10:30:00如果文件尾部没有Dump completed标记,说明导出过程中断了,需要重新导出。
第四步:重建一个干净的MySQL实例
数据导出来了,现在要建一个全新的MySQL来接管。
4.1 删除旧的PVC(或者先保留,用新的)
先别急着删旧PVC!万一新实例导入有问题,旧数据还在。我们先用一个新的PVC创建新实例。
4.2 准备新的MySQL部署
创建一个mysql-new.yaml:
apiVersion:v1kind:ConfigMapmetadata:name:mysql-new-confignamespace:<namespace>data:my.cnf:|[mysqld] default-storage-engine=InnoDB character-set-server=utf8mb4 collation-server=utf8mb4_unicode_ci max_connections=500 innodb_buffer_pool_size=1G innodb_log_file_size=256M innodb_flush_log_at_trx_commit=1 sync_binlog=1 # 根据你原来的配置调整---apiVersion:v1kind:PersistentVolumeClaimmetadata:name:mysql-new-datanamespace:<namespace>spec:accessModes:-ReadWriteOnceresources:requests:storage:50Gi# 根据你的数据量调整storageClassName:<your-storage-class># 替换为你的StorageClass---apiVersion:apps/v1kind:StatefulSetmetadata:name:mysql-newnamespace:<namespace>spec:serviceName:mysql-newreplicas:1selector:matchLabels:app:mysql-newtemplate:metadata:labels:app:mysql-newspec:containers:-name:mysqlimage:mysql:8.0# 保持和原来一致的版本env:-name:MYSQL_ROOT_PASSWORDvalue:"<your-root-password>"# 或者用Secret引用ports:-containerPort:3306volumeMounts:-name:mysql-datamountPath:/var/lib/mysql-name:mysql-configmountPath:/etc/mysql/conf.d/my.cnfsubPath:my.cnfresources:requests:memory:"2Gi"cpu:"1"limits:memory:"4Gi"cpu:"2"volumes:-name:mysql-configconfigMap:name:mysql-new-config-name:mysql-datapersistentVolumeClaim:claimName:mysql-new-data---apiVersion:v1kind:Servicemetadata:name:mysql-new-svcnamespace:<namespace>spec:selector:app:mysql-newports:-port:3306targetPort:3306clusterIP:None部署新实例:
kubectl apply-fmysql-new.yaml# 等待Pod Runningkubectl get pod-n<namespace>-w|grepmysql-new4.3 导入数据到新实例
# 方式一:先拷进Pod再导入kubectlcp./full_backup.sql<namespace>/<mysql-new-pod>:/tmp/full_backup.sql kubectlexec-it<mysql-new-pod>-n<namespace>--\mysql-uroot -p<password></tmp/full_backup.sql# 方式二:直接通过管道导入(推荐)kubectlexec-i<mysql-new-pod>-n<namespace>--\mysql-uroot -p<password><./full_backup.sql如果数据量大(几十GB),导入可能需要一段时间。可以在导入前临时调大一些参数加速:
kubectlexec-it<mysql-new-pod>-n<namespace>-- mysql-uroot -p<password>-e" SET GLOBAL innodb_flush_log_at_trx_commit = 0; SET GLOBAL sync_binlog = 0; SET GLOBAL max_allowed_packet = 1073741824; "导入完成后务必改回来:
kubectlexec-it<mysql-new-pod>-n<namespace>-- mysql-uroot -p<password>-e" SET GLOBAL innodb_flush_log_at_trx_commit = 1; SET GLOBAL sync_binlog = 1; "4.4 验证新实例数据完整性
kubectlexec-it<mysql-new-pod>-n<namespace>-- mysql-uroot -p<password>-- 检查数据库列表SHOWDATABASES;-- 逐库检查表数量SELECTTABLE_SCHEMA,COUNT(*)astable_countFROMinformation_schema.TABLESWHERETABLE_SCHEMANOTIN('information_schema','mysql','performance_schema','sys')GROUPBYTABLE_SCHEMA;-- 检查关键业务表的行数USE<your_database>;SELECTCOUNT(*)FROM<important_table_1>;SELECTCOUNT(*)FROM<important_table_2>;-- 检查用户和权限SELECTuser,hostFROMmysql.user;把新旧实例的表数量、行数对比一下,确认数据一致。
第五步:切换流量到新实例
数据验证没问题后,最后一步是把应用的数据库连接切到新实例。
5.1 修改Service指向
最简单的方式是把原来MySQL Service的selector改成指向新Pod:
kubectl editservice<mysql-service-name>-n<namespace>把selector改成app: mysql-new。
或者更干净的方式——删除旧的StatefulSet和Service,把新的重命名成旧的名字:
# 先缩容旧实例(不删PVC)kubectl scale statefulset<old-mysql>-n<namespace>--replicas=0# 给新Service/StatefulSet改名(需要先删再建,K8s不支持直接rename)# 修改mysql-new.yaml中的名称为原来的名称# 然后重新apply5.2 清理
确认新实例运行稳定后(建议观察至少24小时):
# 删除旧的PVC(确认不需要了再删!)kubectl delete pvc<old-mysql-pvc>-n<namespace># 删除恢复用的ConfigMapkubectl delete configmap mysql-recovery-config-n<namespace>完整流程速查
整个恢复过程浓缩成一张命令清单:
# ===== 阶段一:强制恢复启动 =====# 1. 创建recovery ConfigMapcat<<EOF|kubectl apply-f-apiVersion: v1 kind: ConfigMap metadata: name: mysql-recovery-config namespace: <namespace> data: recovery.cnf: | [mysqld] innodb_force_recovery = 1 EOF# 2. 挂载到MySQL Pod(编辑StatefulSet添加volume和volumeMount)kubectl edit statefulset<mysql-sts>-n<namespace># 3. 如果起不来,逐级提升(1→2→3→4→5→6)kubectl edit configmap mysql-recovery-config-n<namespace>kubectl delete pod<mysql-pod>-n<namespace># 4. 查看日志确认是否启动kubectl logs<mysql-pod>-n<namespace>-f# ===== 阶段二:导出数据 =====# 5. 全量导出kubectlexec-it<mysql-pod>-n<namespace>--\mysqldump-uroot -p<password>--all-databases\--single-transaction--routines--triggers--events\--set-gtid-purged=OFF>./full_backup.sql# 6. 验证备份tail-5./full_backup.sql# ===== 阶段三:重建导入 =====# 7. 部署新MySQL实例kubectl apply-fmysql-new.yaml# 8. 导入数据kubectlexec-i<mysql-new-pod>-n<namespace>--\mysql-uroot -p<password><./full_backup.sql# 9. 验证数据kubectlexec-it<mysql-new-pod>-n<namespace>--\mysql-uroot -p<password>-e"SHOW DATABASES;"# 10. 切换流量 & 清理旧资源kubectl scale statefulset<old-mysql>-n<namespace>--replicas=0几个踩过的坑
1. ConfigMap更新后Pod不会自动重载
用subPath挂载的ConfigMap,修改后不会自动更新到Pod里。必须手动删Pod触发重建。这跟直接挂载目录的行为不同,容易踩坑。
2. 不要在force_recovery模式下执行任何写操作
即使级别1-3允许写入,也不要做。这时候InnoDB的状态是不可信的,写入可能导致二次损坏。只做SELECT和mysqldump。
3. mysqldump的–single-transaction不是万能的
它依赖InnoDB的MVCC。如果InnoDB本身都坏了,一致性快照的可靠性要打个问号。但在force_recovery模式下,这已经是最好的选择了。
4. 导入大数据库时先关掉binlog和双写
前面提到的临时关闭innodb_flush_log_at_trx_commit和sync_binlog,能让导入速度快3-5倍。但记得导入完改回来,这两个参数关掉意味着掉电会丢数据——而我们刚经历过一次掉电。
5. 旧PVC不要着急删
数据恢复最怕的是"二次事故"。旧PVC保留至少一周,确认新实例完全稳定、业务数据完全正确后再清理。磁盘空间不够的话,可以先把旧PVC的数据tar打包存到对象存储。
写在后面
这次事故之后我们做了几个改进:
- MySQL备份策略:从"每天全量备份"改成"每6小时增量+每天全量",备份存到独立的对象存储
- UPS监控:加了UPS电量告警,低于30%时自动触发K8s节点drain
- 数据库有状态服务的部署建议:生产环境的MySQL尽量不要跑在K8s里。如果一定要跑,至少保证PV用的是带掉电保护的SSD,并且做好主从复制
数据库这东西,平时觉得它就在那里、稳如磐石。等它真出问题了,你才意识到备份和高可用不是成本,是保险。