数据持久化与卷管理
数据持久化与卷管理
写在前面
上一篇讲网络的时候,我们已经把 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
特点:
- 路径由宿主机决定,Docker 不管
- 高性能(几乎零开销)
- 调试方便,直接进宿主目录看
- 不适合跨机器迁移(宿主机路径写死)
适用场景:
- 开发环境(热更新)
- 需要直接操作宿主机文件的场景
- 调试时挂代码进容器
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",
# ...
# }
# ]
特点:
- Docker 管理,跨机器迁移方便
- 支持 volume driver(NFS、S3 等)
- 比 bind mount 略慢(经过一层抽象)
- 生产推荐
适用场景:
- 生产数据库
- 任何需要持久化的数据
- 跨主机部署
1.3 tmpfs(内存盘)
只存在内存里,容器停止就消失。
services:
api:
volumes:
- type: tmpfs
target: /tmp
tmpfs:
size: 100m
适用场景:
- 敏感信息(密码、token)处理
- 临时缓存(不希望污染磁盘)
- 高速临时存储
二、生产中如何选择
| 场景 | 推荐 | 原因 |
|---|---|---|
| 生产数据库 | 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 改造前的状态
- 物理机,CentOS 7,跑 MySQL 8.0
- 1TB 机械硬盘,IO 已经吃紧
- 没有主从,凌晨有定时任务做主从延迟监控(实际上是单机)
- 没有 binlog 自动备份,每周一次冷备份
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 第四步:灰度切流量
不能一刀切,要做灰度:
- 双写阶段(2 周):API 同时写新旧两库,后台对比,确认一致
- 读切换(1 周):10% 流量从 ProxySQL 走到新库
- 写切换:把新库设为 Master,旧库降级为 Repl
- 旧库观察(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 部署 + 镜像签名 + 垃圾回收。