把 MySQL 跑在 Docker 容器里非常方便,一条命令就能启动,但如果不做任何持久化配置,容器删除的那一刻,数据库里的所有数据都会一起消失。这是因为容器使用的是联合文件系统,容器层的写入在容器销毁后不会保留。想让数据在容器重建、镜像升级之后依然存在,就必须把 MySQL 的数据目录映射到容器外部,也就是所谓的持久化配置。本文从原理讲到实操,把常见的几种方案和容易踩的坑一次说清楚。

为什么 MySQL 容器必须配置数据持久化
Docker 镜像采用分层结构,容器启动时会在镜像顶部加一个可写层,所有运行期间的写入都落在这个可写层里。当你执行 docker rm 删除容器,或者容器因异常退出被清理时,这个可写层会被一并丢弃。对 MySQL 来说,数据文件、binlog、 redo log 全部写在容器的 /var/lib/mysql 目录下,容器没了,这些文件自然也就没了。
另一个容易被忽视的场景是镜像升级。生产环境升级 MySQL 从 8.0 到 8.1,标准做法是删掉旧容器再用新镜像启动一个新容器。如果数据只存在于旧容器的可写层里,新容器根本读不到原来的数据,业务会直接瘫痪。而如果把数据目录挂载到宿主机或者独立的卷中,容器本身变成了无状态的服务,随时可以销毁重建,数据则安全地留在容器之外。
此外,可写层还有一个性能问题。Docker 的写时复制机制在容器层频繁写入时效率不如直接写宿主机文件系统,而 MySQL 恰恰是典型的写入密集型应用。把数据目录单独挂载出来,既能保住数据,也能绕开容器层的性能损耗,可以说是一举两得。
三种持久化方案对比:卷、匿名卷与绑定挂载
Docker 提供了三种把容器内目录映射出去的方式,理解它们的区别是正确做持久化配置的前提。
第一种是命名卷(named volume),由 Docker 统一管理,存放在宿主机的 /var/lib/docker/volumes/ 目录下。命名卷的生命周期独立于容器,删除容器不会删除卷,需要显式执行 docker volume rm 才会删掉。它是官方推荐的生产环境方案,兼容性好,不受宿主机目录结构和文件系统差异的影响。
第二种是匿名卷(anonymous volume),效果和命名卷类似,只是没有指定名字,Docker 会自动生成一串随机哈希作为卷名。缺点是不好管理,时间久了宿主机上会堆积大量孤儿卷,需要定期用 docker volume prune 清理。MySQL 官方镜像在 Dockerfile 里其实声明了 VOLUME /var/lib/mysql,所以即使你不写 -v 参数,Docker 也会自动创建一个匿名卷,这也是有人误以为“不挂载也有数据”的原因。
第三种是绑定挂载(bind mount),直接把宿主机上的一个目录映射进容器,例如把 /data/mysql 挂到容器的 /var/lib/mysql。这种方式的好处是数据位置一目了然,方便直接查看和备份;缺点是依赖宿主机目录的权限配置,容易遇到 SELinux 或 UID 匹配问题。下表总结了三者的差异:
| 方案 | 管理方 | 适用场景 | 主要风险 |
|---|---|---|---|
| 命名卷 | Docker | 生产环境长期运行 | 数据位置不直观 |
| 匿名卷 | Docker | 临时测试 | 孤儿卷堆积难管理 |
| 绑定挂载 | 使用者自己 | 开发调试、需直接访问文件 | 权限问题、误操作风险 |
动手配置:docker run 与 docker-compose 写法
先看最基础的命令行方式。使用命名卷时,执行下面的命令即可完成持久化配置:
docker run -d \ --name mysql8 \ -e MYSQL_ROOT_PASSWORD=your_password \ -e TZ=Asia/Shanghai \ -v mysql_data:/var/lib/mysql \ -v mysql_conf:/etc/mysql/conf.d \ -p 3306:3306 \ mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_unicode_ci
这里挂载了两个目录:/var/lib/mysql 存放数据文件,是持久化的核心;/etc/mysql/conf.d 存放自定义配置文件,把配置也持久化出来,升级镜像时不用重新写配置。卷 mysql_data 如果不存在,Docker 会自动创建。之后即使删掉容器重新执行这条命令,数据依然完好。
如果偏好绑定挂载,把 -v mysql_data:/var/lib/mysql 换成绝对路径写法即可,注意冒号前面必须是宿主机的绝对路径:
mkdir -p /data/mysql /data/conf docker run -d \ --name mysql8 \ -e MYSQL_ROOT_PASSWORD=your_password \ -v /data/mysql:/var/lib/mysql \ -v /data/conf:/etc/mysql/conf.d \ -p 3306:3306 \ mysql:8.0
生产环境更推荐用 docker-compose 统一管理,配置如下:
version: "3.8"
services:
mysql:
image: mysql:8.0
container_name: mysql8
restart: always
environment:
MYSQL_ROOT_PASSWORD: your_password
TZ: Asia/Shanghai
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./conf:/etc/mysql/conf.d
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
mysql_data:
注意 volumes 顶层段落里必须声明 mysql_data,否则启动时会报卷未定义的错误。配置完成后执行 docker compose up -d,数据就会写入命名卷中,后续执行 docker compose down 不会删除卷,只有加 -v 参数才会连数据一起清掉,这个参数一定要谨慎使用。
常见坑与备份恢复实践
第一个高频问题是绑定挂载后容器启动失败,日志里提示数据目录无法初始化。多数原因是宿主机目录属主不对。MySQL 容器内的进程以 mysql 用户(UID 999)运行,宿主机目录如果是 root 持有且权限为 755,容器就没有写入权限。解决办法是在宿主机执行 chown -R 999:999 /data/mysql,或者干脆先用命名卷避开这个麻烦。CentOS 和 RHEL 上还可能是 SELinux 拦截了访问,可以尝试在挂载路径后加 :z 让 Docker 自动修正安全标签。
第二个坑是挂载了一个非空目录。如果宿主机 /data/mysql 里已经有别的数据文件,MySQL 初始化时会判断目录非空并拒绝执行 --initialize,导致启动失败。挂载点必须是空目录,或者里面恰好是同一套 MySQL 的完整数据文件(比如从旧机器迁移过来的),后者可以直接被识别并跳过初始化。这个特性反过来也说明,整个 /var/lib/mysql 目录迁移到新容器是完全可行的。
关于备份,强烈建议不要直接打包数据目录当热备份,因为 MySQL 运行中数据文件随时在变,拷贝出来的文件可能不一致。正确的做法是用 mysqldump 或者物理备份工具。定时备份的示例如下:
# 每天凌晨两点导出全部数据库到宿主机 docker exec mysql8 sh -c \ 'exec mysqldump -uroot -p"your_password" --all-databases --single-transaction' \ > /data/backup/mysql_$(date +%F).sql # 恢复时把备份文件灌回容器 docker exec -i mysql8 sh -c \ 'exec mysql -uroot -p"your_password"' \ < /data/backup/mysql_2024-01-15.sql
--single-transaction 参数对 InnoDB 表可以保证备份期间的一致性而不锁表,是线上备份的标配。如果是大型库想缩短恢复时间,可以改用 XtraBackup 做物理备份,思路同样是借助 docker exec 在容器内执行工具。
最后强调一点:持久化不等于高可用。卷只能保证单机数据不丢,宿主机磁盘损坏或者机器下线,卷里的数据同样有风险。真正重要的业务数据,除了容器持久化之外,还应该有跨机的备份策略,比如定期把备份文件同步到对象存储或其他机器上,形成多层次的保障。把容器当进程看待、把数据当资产看待,这样的架构思路才能既享受容器化的便利,又不给数据安全留下隐患。