数据库容器化实战:Postgres、MySQL 与 Redis 的持久化与备份

从数据目录与 VOLUME 语义出发,讲解 Postgres、MySQL、Redis 的容器化持久化方案:PGDATA 权限与初始化脚本、mysql_native_password 认证坑、Redis AOF 与 RDB 双持久化,以及命名卷选型、pg_dump/pg_basebackup/mysqldump 备份恢复、WAL 与 fsync 语义、共享内存与存储驱动性能陷阱。

前置阅读:建议先阅读 Docker Volume 与网络:数据持久化、容器通信与生产存储策略 与 容器资源限制与 cgroup 深入:CPU/内存/IO 配额与 OOM。本篇聚焦 数据库类有状态服务的容器化持久化、备份恢复与生产调优。

把 Postgres 塞进容器只需要一条 docker run,但要让它 在重启、崩溃、磁盘写满、镜像升级之后依然不丢一个字节,需要理解一整套存储语义。数据库是容器化道路上最难啃的骨头:应用容器随时可以杀掉重建,数据库容器里躺着的是不可再生的数据。本文用 Postgres、MySQL、Redis 三个最典型的镜像,把数据目录、初始化脚本、持久化配置、备份恢复、性能陷阱和升级路径一次讲透。

1. 数据库容器化的核心约束

1.1 数据目录与 VOLUME 语义

官方镜像几乎都在 Dockerfile 里声明了 VOLUME。以 postgres 为例,其数据目录是 /var/lib/postgresql/data;mysql 是 /var/lib/mysql;redis 是 /data。一旦声明 VOLUME,Docker 在容器创建时会做两件事:

  1. 若你在 docker run 时没有显式挂载该路径,Docker 会 自动创建一个匿名卷(64 位随机 ID 命名),把镜像里该目录的初始内容复制进去。
  2. 若你显式挂载了命名卷或 bind mount,则 镜像中的初始内容会被复制到该卷(仅当卷为空时)。

这个「自动匿名卷」是最大的隐形陷阱:很多人 docker run -d postgres 之后直接 docker rm 容器,以为数据没了,其实数据还躺在 /var/lib/docker/volumes/<随机ID>/ 里,磁盘被慢慢吃满;反之,如果你以为数据在容器里,删了容器才发现匿名卷还在但找不到——docker volume ls 里一堆 64 位哈希,无从对应。

# 反例:隐式匿名卷,无法追踪、无法复用
docker run -d --name pg postgres:16
docker rm -f pg            # 数据仍在匿名卷里,成了孤儿卷

# 正例:显式命名卷,生命周期可控
docker run -d --name pg \
  -e POSTGRES_PASSWORD=secret \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16

一句话:任何持久化路径都必须显式挂载命名卷或 bind mount,绝不要依赖 VOLUME 声明带来的匿名卷。

1.2 存储驱动 CoW 对数据库 IO 的影响

overlay2 的写时复制(Copy-on-Write)机制对「大文件顺序写、就地更新」的数据库极其不友好:首次写入一个块要先把整个文件从 lowerdir 复制到 upperdir,且每次容器层改动都会产生新层。数据库的随机小写入在这种语义下会被放大成大量复制。

存储位置底层机制数据库 IO 表现适用场景
容器可写层(overlay2)写时复制,按文件复制首次写放大严重,随机写差严禁存放数据库数据
命名卷(默认 local 驱动)直接落在宿主 /var/lib/docker/volumes接近裸盘,支持直接 IO生产默认选择
bind mount 到数据盘直接挂宿主目录与裸盘一致,可指定 XFS/ext4需要精确控制挂载参数时
tmpfs内存文件系统极快但不持久仅用于临时排序空间

结论很明确:数据库数据目录必须落在卷或 bind mount 上,绝不允许写在容器可写层。此外,宿主文件系统建议使用 XFS 或 ext4,并确保挂载时没有 noatime 之外的额外限制;对于 pg_basebackup 这类依赖 fsync 的场景,底层存储必须真正支持 fsync 落盘。

2. Postgres 容器化实战

2.1 数据目录、PGDATA 与权限

Postgres 镜像默认以 postgres 用户(UID 999)运行,数据目录默认 /var/lib/postgresql/data。有两个高频坑:

  • 挂载点吞掉 PGDATA:如果你把卷挂到 /var/lib/postgresql/data,而 PGDATA 又指向该目录下的子目录,某些编排工具(尤其是 Kubernetes 挂载空目录时)会让初始化失败。稳妥做法是显式设置 PGDATA 为挂载点下的子目录,或直接用 PGDATA=/var/lib/postgresql/data/pgdata。
  • 权限错乱:bind mount 到宿主目录时,宿主目录属主往往不是 999,导致 initdb 报 could not change permissions of directory。此时需要 chown -R 999:999,或用 --user 指定,但 --user 又要保证该用户对 PGDATA 有写权限。
# 推荐:命名卷 + 显式 PGDATA 子目录
docker run -d --name pg \
  -e POSTGRES_PASSWORD=secret \
  -e PGDATA=/var/lib/postgresql/data/pgdata \
  -e POSTGRES_INITDB_ARGS="--encoding=UTF8 --locale=C.UTF-8" \
  -v pgdata:/var/lib/postgresql/data \
  -p 5432:5432 \
  postgres:16

POSTGRES_INITDB_ARGS 只在 数据目录为空、首次初始化 时生效;后续重启会被忽略,改它不会影响已存在的集群。POSTGRES_INITDB_ARGS 常用来固定 --encoding 与 --locale,避免宿主环境变量 LANG 漂移导致 collation 不一致。

2.2 初始化脚本 /docker-entrypoint-initdb.d

镜像入口脚本会在首次初始化完成后,按文件名字典序执行 /docker-entrypoint-initdb.d/ 下的 .sh、.sql、.sql.gz 文件。这是建库、建用户、装扩展的标准位置:

-- initdb/01-schema.sql
CREATE DATABASE appdb;
CREATE USER app WITH PASSWORD 'app_pass';
GRANT ALL PRIVILEGES ON DATABASE appdb TO app;
\connect appdb
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE TABLE t_orders (id bigserial PRIMARY KEY, amount numeric(12,2));
# docker-compose.yml 片段
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./initdb:/docker-entrypoint-initdb.d:ro

关键点:这些脚本只在数据目录为空时执行一次。卷里已有数据时它们被完全跳过——这是「我改了初始化脚本为什么没生效」的根因。要重跑只能删卷重建,或手工 docker exec 执行。

2.3 共享内存与 shm-size

Postgres 使用 shared_buffers 之外的共享内存做并行查询、大排序和 hash join。Docker 默认给 /dev/shm 仅 64MB,一旦查询超过该阈值会报 could not resize shared memory segment 或 No space left on device。解决办法是提高 --shm-size:

docker run -d --name pg \
  --shm-size=1g \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16 -c shared_buffers=512MB -c max_parallel_workers=4

在 Compose 中对应 shm_size: 1gb。经验值:shm_size 至少与 shared_buffers 同量级,高并发并行查询场景建议 1~2GB。

3. MySQL 容器化实战

3.1 环境变量与初始化

MySQL 官方镜像用一组环境变量完成初始化,语义与 Postgres 类似,但也只在数据目录为空时生效:

变量作用备注
MYSQL_ROOT_PASSWORD设置 root 密码必填(除非用随机密码或 socket 认证)
MYSQL_DATABASE自动创建数据库首次初始化执行
MYSQL_USER / MYSQL_PASSWORD创建普通用户并授权该库两变量必须成对出现
MYSQL_ALLOW_EMPTY_PASSWORD允许空 root 密码仅限本地测试,生产禁用
MYSQL_RANDOM_ROOT_PASSWORD随机生成 root 密码并打印到日志需要从 docker logs 捞取
docker run -d --name mysql \
  -e MYSQL_ROOT_PASSWORD=root_pass \
  -e MYSQL_DATABASE=appdb \
  -e MYSQL_USER=app -e MYSQL_PASSWORD=app_pass \
  -v mysqldata:/var/lib/mysql \
  -p 3306:3306 \
  mysql:8.0 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

3.2 mysql_native_password 认证坑

MySQL 8.0 默认认证插件改为 caching_sha2_password。老客户端(旧版 JDBC、PHP 5.x、部分中间件)不支持该插件,连接时报 Authentication plugin 'caching_sha2_password' cannot be loaded。两种处理方式:

-- 方式一:把用户改为旧插件(兼容性优先)
ALTER USER 'app'@'%' IDENTIFIED WITH mysql_native_password BY 'app_pass';
FLUSH PRIVILEGES;
# 方式二:服务端默认插件改为 native password
docker run -d --name mysql mysql:8.0 \
  --default-authentication-plugin=mysql_native_password

注意:MySQL 8.4 起 mysql_native_password 被默认禁用,需要 --mysql-native-password=ON 显式开启。新项目应优先升级客户端而非回退插件。

3.3 字符集与时区

字符集必须在初始化时定好,事后改库级字符集无法修复已有数据的排序规则。建议一律 utf8mb4 + utf8mb4_unicode_ci(或 utf8mb4_0900_ai_ci)。时区则通过 TZ 环境变量与 --default-time-zone 双管齐下,否则容器默认 UTC,业务侧写入的时间会与预期差 8 小时:

services:
  db:
    image: mysql:8.0
    environment:
      TZ: Asia/Shanghai
      LANG: C.UTF-8
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      - --default-time-zone=+08:00

LANG 影响排序与大小写比较的本地化行为,TZ 影响 NOW() 与日志时间戳,两者要同时设置保持一致。

4. Redis 持久化:AOF 与 RDB

4.1 appendonly 与 save 的取舍

Redis 容器默认的数据目录是 /data。它有两套持久化:RDB(定时快照)与 AOF(追加日志)。默认镜像的启动参数是纯 RDB,重启间隙会丢数据;生产建议开启 AOF 并配 everysec 刷盘。

docker run -d --name redis \
  -v redisdata:/data \
  -p 6379:6379 \
  redis:7 redis-server \
    --appendonly yes \
    --appendfsync everysec \
    --save 900 1 --save 300 10 \
    --dir /data
模式落盘时机数据安全恢复速度文件体积
RDB save 900 1900 秒内至少 1 次写最多丢 15 分钟快小
AOF appendfsync everysec每秒 fsync最多丢 1 秒慢(重放日志)大
AOF appendfsync always每次写都 fsync基本不丢最慢大
AOF + RDB 混合两者并存最多丢 1 秒快(混合格式)中

生产推荐 appendonly yes + appendfsync everysec,并开启 aof-use-rdb-preamble yes(Redis 4.0 起默认)让 AOF 重写时使用 RDB 前言,兼顾恢复速度。

4.2 快照备份与恢复

Redis 备份不必停机。redis-cli --rdb 会触发一次 BGSAVE 并把 RDB 流式传输到本地,全程不阻塞主线程:

# 从运行中的容器导出 RDB 到宿主
docker exec redis redis-cli --rdb /tmp/dump.rdb
docker cp redis:/tmp/dump.rdb ./backup/redis-$(date +%F).rdb

# 或者直接从宿主管道拿到 stdout(无需容器内落盘)
docker exec redis redis-cli --rdb - > ./backup/redis.rdb

恢复时把 RDB 放回 /data/dump.rdb 并重启容器即可;AOF 模式下 Redis 会优先加载 AOF,需注意两者的一致性。

5. 数据卷选型:命名卷 vs bind mount

维度命名卷bind mount
管理方式Docker 统一管理,docker volume 系列命令宿主目录,手工管理
可移植性高,跨宿主需备份迁移低,强依赖宿主路径
权限控制由 Docker 处理,UID 映射简单需手工 chown,易踩权限坑
备份docker run --rm -v vol:/data alpine tar直接对宿主目录操作
性能local 驱动接近裸盘取决于宿主文件系统与挂载参数
适用场景生产默认、Compose 编排需要 NFS/云盘/特殊挂载参数

一句话:默认选命名卷,只有当你需要挂载 NFS、云盘或精确控制文件系统参数时才用 bind mount。

6. 备份与恢复实战

6.1 Postgres 逻辑与物理备份

# 逻辑备份:跨版本可用,体积小,恢复慢
docker exec -t pg pg_dump -U postgres -Fc appdb > appdb.dump

# 恢复
docker exec -i pg pg_restore -U postgres -d appdb --clean --if-exists < appdb.dump

# 物理备份:整集群二进制一致快照,恢复快,需同大版本
docker exec pg pg_basebackup -U replicator -D /tmp/base -Ft -z -P
docker cp pg:/tmp/base/base.tar.gz ./backup/

-Fc 自定义格式支持并行恢复与选择性还原;pg_basebackup 需要复制权限的连接账号,适合搭建从库或做整实例灾备。

6.2 MySQL 逻辑备份

# 单库导出(含建表与数据),注意容器内管道不要用 -t
docker exec mysql sh -c \
  'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers appdb' \
  > appdb.sql

# 恢复
docker exec -i mysql sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" appdb' < appdb.sql

--single-transaction 利用 InnoDB 的 MVCC 快照实现一致性备份且不锁表,是 InnoDB 场景的标配。MyISAM 表则必须加 --lock-tables。

6.3 定时备份到对象存储

用一个 sidecar 容器跑 cron,把备份直接推到对象存储,避免宿主机成为单点:

#!/bin/sh
# backup.sh —— 由 cron 容器每天调用
set -e
STAMP=$(date +%Y%m%d-%H%M)
docker exec -t pg pg_dump -U postgres -Fc appdb \
  | gzip > /backup/appdb-$STAMP.dump.gz
aws s3 cp /backup/appdb-$STAMP.dump.gz s3://my-db-backups/appdb/
find /backup -name 'appdb-*.dump.gz' -mtime +7 -delete

配合 docker-compose 中的 restart: unless-stopped 与健康检查,形成「备份、上传、清理」闭环。备份策略建议 3-2-1:三份副本、两种介质、一份异地。

7. 性能陷阱与调优

7.1 fsync、WAL 与 redo log

数据库的持久性最终由 fsync 保证。容器本身不改变 fsync 语义,但底层存储会:如果卷落在不支持真正 fsync 的网络文件系统或某些虚拟化磁盘上,数据库会「以为」落盘了。因此 不要为了性能把 synchronous_commit 或 innodb_flush_log_at_trx_commit 调到 0,除非明确接受崩溃后丢最近若干秒的数据。

# Postgres:适度降低 WAL 写入频率,但不牺牲事务持久性
docker run -d postgres:16 \
  -c wal_compression=on -c checkpoint_timeout=15min \
  -c max_wal_size=4GB -c synchronous_commit=on
# MySQL:默认 1 表示每次事务提交都刷 redo log(最安全)
# 2 表示每秒刷一次,崩溃可能丢 1 秒;0 性能最好但最不安全
innodb_flush_log_at_trx_commit = 1

7.2 连接池与 max_connections

每个应用进程直连数据库都会占用一个后端进程/线程,Postgres 尤其昂贵——每个连接是一个操作系统进程。容器化环境下应用实例数容易失控,max_connections 很快被打满。正确姿势是在应用与数据库之间放连接池(PgBouncer、ProxySQL),把 max_connections 控制在合理范围:

# Postgres 侧:给连接数设上限,把并发交给 PgBouncer
docker run -d postgres:16 -c max_connections=200 -c shared_buffers=1GB
; pgbouncer.ini 关键项:transaction 模式最省连接
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20

transaction 模式下连接只在事务期间绑定,能显著提升连接复用率,但要注意它不支持会话级特性(如 SET、prepared statement 跨事务复用)。

8. 大版本升级与健康检查编排

8.1 升级路径:dump-restore 与 pg_upgrade

容器化数据库升级的核心原则:数据目录不能跨大版本直接复用。 Postgres 数据目录有版本号,16 的数据目录无法被 17 直接打开;MySQL 同样如此。

方式停机时间适用场景风险
dump-restore(逻辑)长,取决于数据量跨大版本、跨平台低,可校验
pg_upgrade(物理)短同机同架构大版本升级中,需 --link 谨慎使用
主从切换几乎为零生产在线升级需搭从库与切换流程
# 逻辑升级:旧容器导出,新容器导入
docker exec -t pg16 pg_dumpall -U postgres > all.sql
docker run -d --name pg17 -v pgdata17:/var/lib/postgresql/data postgres:17
docker exec -i pg17 psql -U postgres < all.sql

在线升级的稳妥做法是先用新版本容器搭一个从库,等复制追上后切换主从,再回滚旧版本容器。

8.2 健康检查与依赖启动顺序

应用容器常常在数据库还没就绪时就启动,导致连接失败。正确做法是给数据库定义健康检查,并让应用依赖它:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 30s
    volumes:
      - pgdata:/var/lib/postgresql/data
  app:
    image: myapp:latest
    depends_on:
      db:
        condition: service_healthy

depends_on 配合 condition: service_healthy 才能真正等到数据库可接受连接。注意健康检查本身要轻量,pg_isready 不会建立真实会话,是最合适的选择;用 psql -c 'select 1' 会产生额外连接,高频率下反而成为负担。

9. 总结

主题关键结论一句话记忆
数据目录与 VOLUME必须显式挂载,避免匿名卷孤儿不显式挂载就等于没持久化
初始化脚本仅在数据目录为空时执行一次空目录才跑 initdb.d
权限与 PGDATA属主要匹配镜像 UID,子目录避开挂载点吞并999 属主与子目录 PGDATA
MySQL 认证8.0 默认 caching_sha2,旧客户端需 native老客户端先查认证插件
Redis 持久化AOF everysec 兼顾安全与性能要少丢数据就开 AOF
备份恢复逻辑备份跨版本、物理备份快dump 通用,basebackup 快
性能陷阱卷落裸盘、别关 fsync、连接交给池卷要裸盘,连接要池
升级与编排大版本不可复用数据目录,健康检查定序升级走 dump,启动等 healthy

数据库容器化不是把 docker run 换成 docker-compose 就结束,而是一整套围绕 数据生命周期 的工程实践。第一层是存储语义:数据目录必须落在卷或 bind mount 上,避开 overlay2 的写时复制,理解 VOLUME 声明带来的匿名卷陷阱。第二层是初始化与权限:Postgres 的 PGDATA 与 UID 999、MySQL 的认证插件与字符集、Redis 的 AOF 与 RDB,都必须在首次初始化时一次定好,事后补救成本极高。第三层是备份与恢复:逻辑备份通用、物理备份快,但都要真正演练过恢复才算数,并遵循 3-2-1 原则把副本推到对象存储。第四层是性能与演进:fsync 语义不能妥协,连接数交给连接池,大版本升级永远走 dump-restore 或主从切换,而不是直接复用数据目录。把这几层做扎实,容器里的数据库才真正具备生产可信度。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「docker」更多文章

  1. 容器 CPU 调度与 NUMA:绑核、实时性与 QoS 保障
  2. Docker Daemon 运维:systemd 集成、配置调优与日志治理
  3. OCI 镜像与工件规范:manifest、index 与 artifact 生态