K8s集群断电后MySQL恢复实录:从InnoDB崩溃到数据完整迁移

事故现场

一次私有化部署的客户,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的部署方式

bash 复制代码
# 确认MySQL的部署资源类型(Deployment/StatefulSet)
kubectl get statefulset,deployment -n <namespace> | grep mysql

# 查看当前Pod的挂载情况
kubectl describe pod <mysql-pod-name> -n <namespace> | grep -A 10 "Volumes"

记下你的namespace、StatefulSet/Deployment名称、PVC名称------后面都要用。

2.2 创建恢复用的ConfigMap

创建一个 mysql-recovery-config.yaml

yaml 复制代码
apiVersion: v1
kind: ConfigMap
metadata:
  name: mysql-recovery-config
  namespace: <namespace>   # 替换为你的namespace
data:
  recovery.cnf: |
    [mysqld]
    innodb_force_recovery = 1

先从级别1开始。应用这个ConfigMap:

bash 复制代码
kubectl apply -f mysql-recovery-config.yaml

2.3 修改MySQL的StatefulSet/Deployment,挂载这个ConfigMap

bash 复制代码
kubectl edit statefulset <mysql-statefulset-name> -n <namespace>

volumes 部分添加:

yaml 复制代码
volumes:
  - name: recovery-config
    configMap:
      name: mysql-recovery-config

在容器的 volumeMounts 部分添加:

yaml 复制代码
volumeMounts:
  - name: recovery-config
    mountPath: /etc/mysql/conf.d/recovery.cnf
    subPath: recovery.cnf

为什么用subPath? 因为 /etc/mysql/conf.d/ 目录下可能已经有其他配置文件,直接挂载整个目录会把原来的配置覆盖掉。用subPath只添加这一个文件,MySQL启动时会自动加载 conf.d 目录下所有的 .cnf 文件。

保存退出后,K8s会自动重建Pod。观察Pod状态:

bash 复制代码
kubectl get pod -n <namespace> -w | grep mysql

2.4 如果级别1没起来,逐级提升

如果Pod还是CrashLoopBackOff,查看日志确认报错:

bash 复制代码
kubectl logs <mysql-pod-name> -n <namespace> --previous

然后修改ConfigMap,把级别提升到2:

bash 复制代码
kubectl edit configmap mysql-recovery-config -n <namespace>

innodb_force_recovery = 1 改成 innodb_force_recovery = 2

因为ConfigMap更新后不会自动触发Pod重启(subPath挂载的限制),需要手动重启:

bash 复制代码
kubectl delete pod <mysql-pod-name> -n <namespace>

Pod删除后StatefulSet会自动重建。继续观察日志。

依次尝试1→2→3→4→5→6,直到MySQL能正常启动。

我们当时的情况是在级别3启动成功了------redo log和undo log都有损坏,跳过事务回滚后MySQL终于愿意起来了。

bash 复制代码
# 确认MySQL已经Running
kubectl get pod -n <namespace> | grep mysql

# 进容器验证能登录
kubectl exec -it <mysql-pod-name> -n <namespace> -- mysql -u root -p

# 检查一下库表是否还在
mysql> SHOW DATABASES;
mysql> USE <your_database>;
mysql> SHOW TABLES;
mysql> SELECT COUNT(*) FROM <some_important_table>;

看到数据还在的那一刻,心终于放下了一半。

第三步:导出全量数据

MySQL能起来了,但它现在是"带伤状态",随时可能再崩。争分夺秒把数据导出来。

3.1 用mysqldump导出

bash 复制代码
# 方式一:直接在Pod内执行,导出到Pod本地再拷出来
kubectl exec -it <mysql-pod-name> -n <namespace> -- \
  mysqldump -u root -p --all-databases \
  --single-transaction \
  --routines \
  --triggers \
  --events \
  --set-gtid-purged=OFF \
  > /tmp/full_backup.sql

# 把备份文件从Pod拷到本地
kubectl cp <namespace>/<mysql-pod-name>:/tmp/full_backup.sql ./full_backup.sql
bash 复制代码
# 方式二:直接通过管道导出到本地(推荐,不占Pod磁盘空间)
kubectl exec -it <mysql-pod-name> -n <namespace> -- \
  mysqldump -u root -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可能会在某个表上卡住或报错。

这时候按数据库逐个导出,跳过有问题的表:

bash 复制代码
# 逐库导出
kubectl exec -it <mysql-pod-name> -n <namespace> -- \
  mysqldump -u root -p<password> \
  --single-transaction \
  --routines --triggers --events \
  --set-gtid-purged=OFF \
  <database_name> > ./<database_name>_backup.sql

如果某张表始终读不出来:

bash 复制代码
# 排除指定表
kubectl exec -it <mysql-pod-name> -n <namespace> -- \
  mysqldump -u root -p<password> \
  --single-transaction \
  --set-gtid-purged=OFF \
  <database_name> \
  --ignore-table=<database_name>.<broken_table> \
  > ./<database_name>_backup.sql

记录下哪些表没导出来,后面评估数据损失。

3.3 验证备份文件

导出完成后务必验证:

bash 复制代码
# 检查文件大小是否合理
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

yaml 复制代码
apiVersion: v1
kind: ConfigMap
metadata:
  name: mysql-new-config
  namespace: <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: v1
kind: PersistentVolumeClaim
metadata:
  name: mysql-new-data
  namespace: <namespace>
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 50Gi          # 根据你的数据量调整
  storageClassName: <your-storage-class>  # 替换为你的StorageClass

---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql-new
  namespace: <namespace>
spec:
  serviceName: mysql-new
  replicas: 1
  selector:
    matchLabels:
      app: mysql-new
  template:
    metadata:
      labels:
        app: mysql-new
    spec:
      containers:
        - name: mysql
          image: mysql:8.0       # 保持和原来一致的版本
          env:
            - name: MYSQL_ROOT_PASSWORD
              value: "<your-root-password>"   # 或者用Secret引用
          ports:
            - containerPort: 3306
          volumeMounts:
            - name: mysql-data
              mountPath: /var/lib/mysql
            - name: mysql-config
              mountPath: /etc/mysql/conf.d/my.cnf
              subPath: my.cnf
          resources:
            requests:
              memory: "2Gi"
              cpu: "1"
            limits:
              memory: "4Gi"
              cpu: "2"
      volumes:
        - name: mysql-config
          configMap:
            name: mysql-new-config
        - name: mysql-data
          persistentVolumeClaim:
            claimName: mysql-new-data

---
apiVersion: v1
kind: Service
metadata:
  name: mysql-new-svc
  namespace: <namespace>
spec:
  selector:
    app: mysql-new
  ports:
    - port: 3306
      targetPort: 3306
  clusterIP: None

部署新实例:

bash 复制代码
kubectl apply -f mysql-new.yaml

# 等待Pod Running
kubectl get pod -n <namespace> -w | grep mysql-new

4.3 导入数据到新实例

bash 复制代码
# 方式一:先拷进Pod再导入
kubectl cp ./full_backup.sql <namespace>/<mysql-new-pod>:/tmp/full_backup.sql

kubectl exec -it <mysql-new-pod> -n <namespace> -- \
  mysql -u root -p<password> < /tmp/full_backup.sql
bash 复制代码
# 方式二:直接通过管道导入(推荐)
kubectl exec -i <mysql-new-pod> -n <namespace> -- \
  mysql -u root -p<password> < ./full_backup.sql

如果数据量大(几十GB),导入可能需要一段时间。可以在导入前临时调大一些参数加速:

bash 复制代码
kubectl exec -it <mysql-new-pod> -n <namespace> -- mysql -u root -p<password> -e "
SET GLOBAL innodb_flush_log_at_trx_commit = 0;
SET GLOBAL sync_binlog = 0;
SET GLOBAL max_allowed_packet = 1073741824;
"

导入完成后务必改回来

bash 复制代码
kubectl exec -it <mysql-new-pod> -n <namespace> -- mysql -u root -p<password> -e "
SET GLOBAL innodb_flush_log_at_trx_commit = 1;
SET GLOBAL sync_binlog = 1;
"

4.4 验证新实例数据完整性

bash 复制代码
kubectl exec -it <mysql-new-pod> -n <namespace> -- mysql -u root -p<password>
sql 复制代码
-- 检查数据库列表
SHOW DATABASES;

-- 逐库检查表数量
SELECT TABLE_SCHEMA, COUNT(*) as table_count 
FROM information_schema.TABLES 
WHERE TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys') 
GROUP BY TABLE_SCHEMA;

-- 检查关键业务表的行数
USE <your_database>;
SELECT COUNT(*) FROM <important_table_1>;
SELECT COUNT(*) FROM <important_table_2>;

-- 检查用户和权限
SELECT user, host FROM mysql.user;

把新旧实例的表数量、行数对比一下,确认数据一致。

第五步:切换流量到新实例

数据验证没问题后,最后一步是把应用的数据库连接切到新实例。

5.1 修改Service指向

最简单的方式是把原来MySQL Service的selector改成指向新Pod:

bash 复制代码
kubectl edit service <mysql-service-name> -n <namespace>

selector 改成 app: mysql-new

或者更干净的方式------删除旧的StatefulSet和Service,把新的重命名成旧的名字:

bash 复制代码
# 先缩容旧实例(不删PVC)
kubectl scale statefulset <old-mysql> -n <namespace> --replicas=0

# 给新Service/StatefulSet改名(需要先删再建,K8s不支持直接rename)
# 修改mysql-new.yaml中的名称为原来的名称
# 然后重新apply

5.2 清理

确认新实例运行稳定后(建议观察至少24小时):

bash 复制代码
# 删除旧的PVC(确认不需要了再删!)
kubectl delete pvc <old-mysql-pvc> -n <namespace>

# 删除恢复用的ConfigMap
kubectl delete configmap mysql-recovery-config -n <namespace>

完整流程速查

整个恢复过程浓缩成一张命令清单:

bash 复制代码
# ===== 阶段一:强制恢复启动 =====

# 1. 创建recovery ConfigMap
cat <<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. 全量导出
kubectl exec -it <mysql-pod> -n <namespace> -- \
  mysqldump -u root -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 -f mysql-new.yaml

# 8. 导入数据
kubectl exec -i <mysql-new-pod> -n <namespace> -- \
  mysql -u root -p<password> < ./full_backup.sql

# 9. 验证数据
kubectl exec -it <mysql-new-pod> -n <namespace> -- \
  mysql -u root -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_commitsync_binlog,能让导入速度快3-5倍。但记得导入完改回来,这两个参数关掉意味着掉电会丢数据------而我们刚经历过一次掉电。

5. 旧PVC不要着急删

数据恢复最怕的是"二次事故"。旧PVC保留至少一周,确认新实例完全稳定、业务数据完全正确后再清理。磁盘空间不够的话,可以先把旧PVC的数据tar打包存到对象存储。

写在后面

这次事故之后我们做了几个改进:

  • MySQL备份策略:从"每天全量备份"改成"每6小时增量+每天全量",备份存到独立的对象存储
  • UPS监控:加了UPS电量告警,低于30%时自动触发K8s节点drain
  • 数据库有状态服务的部署建议:生产环境的MySQL尽量不要跑在K8s里。如果一定要跑,至少保证PV用的是带掉电保护的SSD,并且做好主从复制

数据库这东西,平时觉得它就在那里、稳如磐石。等它真出问题了,你才意识到备份和高可用不是成本,是保险。

相关推荐
掉头发的王富贵10 分钟前
【StarRocks】极限十分钟入门StarRocks
数据库·sql·mysql
SamDeepThinking5 小时前
一条UPDATE语句在MySQL 8.0中到底加了几把锁?
后端·mysql·程序员
Patrick_Wilson18 小时前
从「改个端口」到 502:Next.js on k8s 的容器端口、Service 映射与 env 覆盖
docker·kubernetes·next.js
探索云原生1 天前
K8s 1.36 这个 GA 特性,把 initContainer 拉模型的 hack 干掉了
ai·云原生·kubernetes
李白客2 天前
KES新版MySQL兼容能力再升级意味着什么?
mysql·国产数据库
Java之美2 天前
一次k8s升级引发的DevicePlugin注册失败
云原生·kubernetes
Jim6004 天前
【吃透 MySQL InnoDB连载】第 1 章・解密线上数据库高频故障
mysql
GreatSQL4 天前
gt-checksum v4.0.0 新功能解读系列文章(4):SSL 加密连接——数据校验传输安全再升级
mysql