数据持久化与卷管理

数据持久化与卷管理

写在前面

上一篇讲网络的时候,我们已经把 Redis 容器化了,但容器化不是"docker run 一个就跑"那么简单——尤其是有状态服务(数据库、消息队列、缓存)。本篇会讲清楚数据持久化的各种方式,以及一个真实的 MySQL 容器化迁移案例。

重点:容器是临时的,数据是永恒的。理解这一点,后面 K8s 的 StatefulSet、PV/PVC 都是自然延伸。

一、三种挂载方式

1.1 Bind Mount(绑定挂载)

直接把宿主机的目录或文件挂进容器。

# 把宿主机的 /data/mysql 挂到容器的 /var/lib/mysql
docker run -d --name db \
  -v /data/mysql:/var/lib/mysql \
  mysql:8.0

特点:

适用场景:

1.2 Volume(命名卷)

Docker 自己管理的卷,默认存在 /var/lib/docker/volumes/。

# 创建命名卷
docker volume create mysql-data

# 启动容器挂载
docker run -d --name db \
  -v mysql-data:/var/lib/mysql \
  mysql:8.0

# 查看卷信息
docker volume inspect mysql-data
# [
#   {
#     "Driver": "local",
#     "Mountpoint": "/var/lib/docker/volumes/mysql-data/_data",
#     ...
#   }
# ]

特点:

适用场景:

1.3 tmpfs(内存盘)

只存在内存里,容器停止就消失。

services:
  api:
    volumes:
      - type: tmpfs
        target: /tmp
        tmpfs:
          size: 100m

适用场景:

二、生产中如何选择

场景 推荐 原因
生产数据库 Volume 跨机器、易备份
生产日志 Volume 或 bind bind 方便采集,volume 跨机器
开发热更新 bind 改代码立刻生效
临时缓存 tmpfs 不污染磁盘
配置文件 bind(:ro) 配置注入,只读
容器间共享数据 Volume 命名卷可挂多个容器

生产黄金法则:能用 Volume 就用 Volume,只有开发环境才考虑 bind mount。

三、Volume 进阶

3.1 Volume Driver

默认 driver 是 local(本质就是 bind mount 到 /var/lib/docker/volumes/)。可以换成其他 driver 把数据存到远程:

# 创建 NFS volume
docker volume create \
  --driver local \
  --opt type=nfs \
  --opt o=addr=10.0.0.1,rw \
  --opt device=:/path/to/dir \
  nfs-data

# 创建 S3-backed volume(用 goofys 或 s3fs)
docker volume create \
  --driver local \
  --opt type=fuse.s3fs \
  --opt o=url=https://s3.amazonaws.com/bucket \
  s3-data

3.2 匿名卷 vs 命名卷

# 匿名卷 - 难管理,不推荐
docker run -d -v /var/lib/mysql mysql:8.0

# 命名卷 - 推荐
docker run -d -v mysql-data:/var/lib/mysql mysql:8.0

匿名卷的 ID 是随机生成的,删除容器时不会自动删除,久而久之会有一堆孤儿卷。

3.3 卷的备份与恢复

备份

# 方法 1:用 tar 打包整个卷
docker run --rm \
  -v mysql-data:/source:ro \
  -v $(pwd):/backup \
  alpine tar czf /backup/mysql-$(date +%F).tar.gz -C /source .

# 方法 2:直接 mysql dump
docker exec db sh -c 'mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --all-databases' > backup.sql

恢复

# 解压 tar 到卷
docker run --rm \
  -v mysql-data:/target \
  -v $(pwd):/backup \
  alpine tar xzf /backup/mysql-2024-03-15.tar.gz -C /target

# 从 SQL 恢复
cat backup.sql | docker exec -i db mysql -u root -p"$MYSQL_ROOT_PASSWORD"

3.4 卷的生命周期管理

# 查看所有卷
docker volume ls

# 查看卷详情
docker volume inspect mysql-data

# 清理未使用的卷(谨慎!)
docker volume prune

# 删除指定卷
docker volume rm mysql-data

⚠️ docker volume prune 会删除所有未被任何容器使用的卷。生产环境用前一定要确认。

四、实战:MySQL 容器化迁移

公司订单库的迁移过程,这是一个 200GB 数据、QPS 5000 的 MySQL 8.0。

4.1 改造前的状态

4.2 目标架构

                    ┌──────────────┐
                    │  ProxySQL    │  ← 读写分离
                    └──────┬───────┘
                           │
            ┌──────────────┼──────────────┐
            ▼              ▼              ▼
       ┌────────┐    ┌────────┐    ┌────────┐
       │  M1    │    │  M2    │    │  M3    │
       │ Master │◀──▶│ Repl1  │    │ Repl2  │
       └────────┘    └────────┘    └────────┘
       写           读           读

最终要的是:1 主 2 从 + ProxySQL 读写分离,全部容器化。

但这是一个长达两个月的项目,本篇只讲单实例容器化部分,主从那部分后面再说。

4.3 第一步:数据迁移

用 mysqldump 导出,在新机器上导入:

# 旧库导出
mysqldump -u root -p \
  --single-transaction \
  --routines \
  --triggers \
  --events \
  --master-data=2 \
  --all-databases \
  | gzip > backup-$(date +%F).sql.gz

# 传输到新机器
scp backup-*.sql.gz new-host:/tmp/

# 新机器导入(目标库还没起容器,先临时跑一个)
docker run --rm -d --name tmp-mysql \
  -v /tmp/mysql-init:/docker-entrypoint-initdb.d:ro \
  -e MYSQL_ROOT_PASSWORD=migrate123 \
  mysql:8.0

# 等待初始化完成
sleep 30

# 导入数据
gunzip < /tmp/backup-*.sql.gz | docker exec -i tmp-mysql mysql -u root -pmigrate123

⚠️ 200GB 数据,导了一晚上(7 小时),导入又花了 4 小时。生产数据迁移尽量用物理备份(xtrabackup)而不是逻辑备份(mysqldump)。

4.4 第二步:容器化部署

配置文件

# conf.d/my.cnf
[mysqld]
server-id = 1
log_bin = mysql-bin
binlog_format = ROW
gtid_mode = ON
enforce_gtid_consistency = ON

innodb_buffer_pool_size = 12G
innodb_log_file_size = 2G
innodb_flush_log_at_trx_commit = 1

max_connections = 1000
slow_query_log = 1
long_query_time = 2

启动命令

services:
  mysql:
    image: mysql:8.0
    restart: unless-stopped
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --default-authentication-plugin=caching_sha2_password
    environment:
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password
      MYSQL_DATABASE: order_db
      MYSQL_USER: api_user
      MYSQL_PASSWORD_FILE: /run/secrets/mysql_password
    secrets:
      - mysql_root_password
      - mysql_password
    volumes:
      # 数据卷
      - mysql-data:/var/lib/mysql
      # 配置
      - ./conf.d/my.cnf:/etc/mysql/conf.d/my.cnf:ro
      # 初始化脚本(只首次启动生效)
      - ./init:/docker-entrypoint-initdb.d:ro
    networks:
      - backend
    ports:
      - "127.0.0.1:3306:3306"  # 只暴露到宿主机,外部访问通过 ProxySQL
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 120s  # 数据量大,启动慢
    deploy:
      resources:
        limits:
          memory: 24G  # 留足 buffer pool
    ulimits:
      nofile:
        soft: 65535
        hard: 65535

volumes:
  mysql-data:
    name: order-mysql-data  # 显式命名,方便跨 compose 共享

关键决策解释

Q:为什么用 127.0.0.1:3306:3306 而不是 3306:3306?

A:只允许宿主机访问,不暴露到外网。外部要走 ProxySQL,统一入口,统一鉴权。

Q:为什么 MYSQL_ROOT_PASSWORD_FILE 而不是 MYSQL_ROOT_PASSWORD?

A:不把密码写在环境变量里。环境变量会出现在 docker inspect 输出、日志、镜像历史里,泄露风险大。secrets 挂到容器内的文件,不在进程列表里。

Q:为什么 start_period: 120s?

A:200GB 数据重启 MySQL 需要 2-3 分钟加载 buffer pool。默认 healthcheck 失败就重启容器,会陷入"启动 → 健康检查失败 → 重启"的死循环。

Q:为什么 ulimits nofile 65535?

A:MySQL 每个连接要开一个文件描述符,默认 1024 远不够。并发高的时候 Too many open files 是常见错误。

4.5 第三步:数据校验

迁移完成后必须做数据校验,不能等业务出错才发现:

# 记录迁移前的行数
mysqldump -u root -p --no-data --all-databases | wc -l

# 在新库对比表数和行数
docker exec mysql mysql -u root -p"$PWD" -e "
  SELECT table_name, table_rows
  FROM information_schema.tables
  WHERE table_schema = 'order_db'
  ORDER BY table_name;
"

我们写了一个校验脚本,逐表对比行数 + checksum,200GB 数据校验了 40 分钟,确认零差异后才切流量。

4.6 第四步:灰度切流量

不能一刀切,要做灰度:

  1. 双写阶段(2 周):API 同时写新旧两库,后台对比,确认一致
  2. 读切换(1 周):10% 流量从 ProxySQL 走到新库
  3. 写切换:把新库设为 Master,旧库降级为 Repl
  4. 旧库观察(2 周):确认无问题后下线

整个过程用了两个月。

4.7 这次迁移踩的坑

坑 1:Innodb buffer pool 重启清空

容器重启后,buffer pool 是冷的,会有几分钟的"性能熔岩层"——查询 RT 飙到平时的 5 倍。

解决方案:开启 buffer pool 持久化:

innodb_buffer_pool_dump_at_shutdown = ON
innodb_buffer_pool_load_at_startup = ON

Docker 配合 docker stop(而不是 docker kill)可以正常触发 dump。

坑 2:磁盘 IO 高峰影响其他容器

如果 MySQL 和其他 IO 密集服务(日志收集、备份)跑在同一台宿主机,IO 会互相影响。

解决方案:MySQL 跑专属宿主机,或者用 blkio 限制其他容器的 IO:

deploy:
  resources:
    limits:
      cpus: '2'
      memory: 4G
    blkio_config:
      weight: 100  # 优先级低,DB 的 weight 设 1000

坑 3:备份窗口影响业务

mysqldump 是逻辑备份,会锁表或者产生大量 binlog。我们后来改用 Percona XtraBackup,热备份不影响业务。

容器化 XtraBackup:

services:
  mysql-backup:
    image: perconalab/percona-xtrabackup:8.0
    volumes:
      - mysql-data:/var/lib/mysql:ro
      - ./backups:/backups
    entrypoint: "xtrabackup"
    command:
      - --backup
      - --datadir=/var/lib/mysql
      - --target-dir=/backups/$(date +%F)
    networks:
      - backend

cron 定时跑这个容器,每次生成一个时间戳目录。

五、Redis 持久化

Redis 的持久化机制跟 MySQL 不一样,要分开说。

5.1 两种持久化方式

# RDB - 定时快照
save 900 1        # 900s 内至少 1 个 key 变化
save 300 10       # 300s 内至少 10 个 key 变化
save 60 10000     # 60s 内至少 10000 个 key 变化

# AOF - 追加日志
appendonly yes
appendfsync everysec  # 每秒刷盘,折中方案

5.2 容器化方案

services:
  redis:
    image: redis:7-alpine
    command: >
      redis-server
      --maxmemory 4gb
      --maxmemory-policy allkeys-lru
      --appendonly yes
      --appendfsync everysec
      --save ""      
    volumes:
      - redis-data:/data
      - ./redis.conf:/usr/local/etc/redis/redis.conf:ro
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5

我们用的是 AOF everysec,丢失窗口最多 1 秒,性能也够用。生产中 AOF 是主流。

5.3 Redis 持久化的坑

坑 1:AOF 文件膨胀

AOF 会持续追加,文件越来越大。Redis 提供自动重写机制,但要触发它:

auto-aof-rewrite-percentage 100  # AOF 比上次重写大 100% 触发
auto-aof-rewrite-min-size 64mb   # 至少 64MB 才触发

容器化时还得注意 磁盘空间——AOF 重写会临时 fork 子进程,可能瞬间产生一倍内存的临时文件。

坑 2:数据目录 mount 错

# 错误 - /data 是 redis 数据目录
docker run -v /host/redis:/data redis
# /host/redis 里会塞 appendonly.aof 和 dump.rdb

# 正确 - /data 是默认数据目录
docker run -v redis-data:/data redis
# redis-data 是命名卷

六、本篇小结

主题 关键点
三种挂载方式 bind / volume / tmpfs,生产用 volume
命名卷 vs 匿名卷 必须命名,匿名卷难管理
备份恢复 tar 打包整个卷,或数据库自带 dump
MySQL 迁移 物理备份 > 逻辑备份,必须校验数据
Redis 持久化 AOF everysec 是主流,RDB 作为补充
灰度切换 不能一刀切,先双写再读切换再写切换

下一篇讲 私有镜像仓库搭建,实战 Harbor 部署 + 镜像签名 + 垃圾回收。

参考资料