本地开发绕不开这两件事:一个能连的 MySQL,一个能连的 Redis。我两种方案都用过——最早在笔记本上 apt install 直接装,后来为了给同事复现环境改成 Docker Compose,中间也回退过一次。现在的结论很明确:日常开发走 Docker Compose,直装只在两种场景下值得(下面会写)。这篇先给一份我自己在用的 docker-compose.yml,然后逐条解释里面那些看起来啰嗦的参数到底是防什么坑,最后把踩过的坑排一遍。
一份可以直接用的 docker-compose.yml
# docker-compose.yml
services:
mysql:
image: mysql:8.0.36 # 别用 mysql:latest,小版本升级会改默认行为
container_name: dev-mysql
restart: unless-stopped
ports:
- "3306:3306" # 被占用时改成 "13306:3306"
environment:
MYSQL_ROOT_PASSWORD: root123456 # 只在数据卷为空的第一次生效
MYSQL_DATABASE: app_dev # 顺手建好库,省一次手动 CREATE DATABASE
MYSQL_USER: app
MYSQL_PASSWORD: app123456
TZ: Asia/Shanghai
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_0900_ai_ci
- --default-time-zone=+08:00 # 注意:不加引号
- --default-authentication-plugin=mysql_native_password
- --max_connections=500
- --innodb-buffer-pool-size=512M
volumes:
- mysql-data:/var/lib/mysql
- ./initdb:/docker-entrypoint-initdb.d:ro # 首次启动执行的 .sql / .sh
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p\"$$MYSQL_ROOT_PASSWORD\" --silent"]
interval: 10s
timeout: 5s
retries: 10
start_period: 40s # 首次初始化要建系统表,给足时间
networks: [devnet]
redis:
image: redis:7.2-alpine
container_name: dev-redis
restart: unless-stopped
ports:
- "6379:6379"
command:
- redis-server
- --appendonly
- "yes" # 开 AOF,重启不丢最近写入
- --requirepass
- redis123456
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
interval: 10s
timeout: 3s
retries: 5
environment:
REDIS_PASSWORD: redis123456
networks: [devnet]
volumes:
mysql-data:
redis-data:
networks:
devnet:
driver: bridge
用起来就三行:
docker compose up -d
docker compose ps # 关注 STATUS 里是不是 (healthy)
docker compose logs -f mysql # 第一次启动看初始化日志,报错只看这里
docker compose down # 停掉但保留数据卷
docker compose down -v # 连数据一起删(慎用,见下文)
command 里那几行参数分别防什么
这几行不是抄来的仪式,每一行我都对应过一次具体的故障,值得逐条说清楚。
--character-set-server=utf8mb4 是防 emoji 和生僻字入库报错。 MySQL 的 utf8 是个历史遗留的别名,实际只支持最多 3 字节,而 emoji、部分中文扩展字符需要 4 字节。不设的话,表现是插入带 emoji 的昵称时报 Incorrect string value: '\xF0\x9F...' for column。光设服务器字符集还不够——如果表是在别处建的、用的是 utf8mb3,那服务器默认值救不了它,得单独 ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4。所以我现在建表时也显式写:
CREATE TABLE `user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`nickname` VARCHAR(64) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
顺带一个索引相关的细节:utf8mb4 下 VARCHAR(255) 的索引长度是 255×4 字节,在 ROW_FORMAT=COMPACT 的老表上可能超索引长度限制,报 Specified key was too long。我现在索引列尽量不超过 191 字符,长字段要索引就存哈希值或者用前缀索引。
--default-authentication-plugin=mysql_native_password 是防老客户端连不上。 MySQL 8 默认用 caching_sha2_password,而一些老驱动、老版本的 Navicat、PHP 的某些扩展、以及部分语言的旧版 MySQL 客户端握手不成功,报的错还不一样(有 Authentication plugin 'caching_sha2_password' cannot be loaded,也有直接 Access denied),排查方向完全不同。开发环境我统一退回 mysql_native_password,少一类问题。代价是安全性略低(这无所谓,本地环境);要提醒的是 MySQL 8.4 起这个参数已被废弃,8.4+ 上应该改成 --mysql-native-password=ON,或者干脆升级客户端驱动——版本会变,以官方文档为准。
--default-time-zone=+08:00 是防"时间差 8 小时"。 容器默认是 UTC,而我的应用和同事的客户端都在东八区。典型症状:往库里写 2026-10-05 10:00:00,用客户端工具一看变成 02:00:00,或者反过来——应用读出来的时间是 8 小时前,接口返回的时间戳全错。根因是写入方按 +08:00 的字面值存,读取方按 UTC 解释,TIMESTAMP 类型尤其容易中招(它本身会做时区转换,DATETIME 不会)。统一成 +08:00 之后,开发和测试环境的行为一致,这个问题基本消失。
这里有个 YAML 的坑必须记一下:--default-time-zone=+08:00 在列表里不要加引号,加了引号在某些 compose 版本里会被当成字符串连引号一起传给 mysqld,导致启动失败并报 Unknown or incorrect time zone。另外 +00:00 在 YAML 里会被解析成别的东西,用 --default-time-zone=UTC 更安全。
健康检查里为什么要带密码。 mysqladmin ping 有个反直觉的行为:MySQL 拒绝连接(比如密码错)时它也可能返回成功,所以不带凭据的健康检查会在数据库还没准备好时就报 healthy,docker compose up -d && 立刻跑迁移脚本 就会失败。带上 -uroot -p"$MYSQL_ROOT_PASSWORD" 之后,健康检查才真正等价于"我可以用密码连上"。start_period: 40s 也是必要的:第一次启动要初始化数据目录和系统表,通常 20~40 秒,不给这个缓冲,健康检查会在初始化期间失败并把容器标成 unhealthy。注意 $$ 是 compose 的转义写法——单个 $ 会被 compose 自己在宿主机上展开。
什么时候该直装,而不是用容器
我把直装留在两个场景,都很具体:
- 需要跑在 CI/容器里的场景不适用,但"宿主就是目标环境"的测试需要直装:比如你在验证一个 MySQL 的配置项、
my.cnf调优、或者某种 binlog 行为,容器里的默认值和宿主不一样,这时候直装更贴近真实。 - 机器资源很紧(老笔记本、小内存云主机):Docker Desktop 在 macOS 上本身要占 2GB 左右的虚拟机内存,为了一个 MySQL 付出这个代价不划算。这种情况我直装 MySQL + Redis。
# Ubuntu / Debian
sudo apt install mysql-server redis-server
sudo systemctl enable --now mysql redis-server
sudo mysql_secure_installation # 设 root 密码策略
sudo mysql -uroot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root123456';"
# macOS
brew install mysql redis
brew services start mysql && brew services start redis
mysql_secure_installation
两者维护成本的真实差别在这里:直装的 MySQL 会跟着 apt upgrade 悄悄做大版本升级(我遇到过一次 5.7 到 8.0 的自动升级,认证插件变了,应用连不上,回滚很麻烦),而容器里版本是钉死的,只有你改 image tag 才会变。这是我选容器的最主要理由——可重现比性能重要。
| 维度 | Docker Compose | 直接 apt / brew 装 |
|---|---|---|
| 环境一致性 | 高,image tag 钉死版本,同事拉同一份 yml 就行 | 低,跟系统升级联动,两台机器可能不同 |
| 起停成本 | docker compose up -d / down | systemctl 或 brew services |
| 干净的卸载 | 删容器和卷,机器无残留 | 要清数据目录、配置文件、用户 |
| 性能 | 有一点损耗(网络、I/O),本机开发无感 | 原生,磁盘 I/O 更好 |
| 资源占用 | macOS 上要跑虚拟机,内存开销明显 | 只占服务本身 |
| 数据可携带 | 数据在命名卷里,可导出可重建 | 数据在系统路径,迁移要手工 dump |
| 适合 | 日常开发、多人协作、要复现环境 | 资源紧、要贴近宿主真实行为、CI 宿主测试 |
七个真实踩过的坑
1. 3306 / 6379 被占用。 系统里装了 MySQL 或 Redis 时,容器起不来,报 bind: address already in use。注意 Redis 更隐蔽:Mac 上装了 Redis 桌面客户端、或者某个 App 内置了 Redis,6379 就已经在监听了。先查清楚是谁占的:
sudo lsof -i :3306 -i :6379 -P -n # macOS 同样可用
sudo ss -lntp | grep -E '3306|6379' # Linux
解法两种:要么 docker compose down 掉旧的服务(顺带 systemctl stop mysql),要么把宿主端口改成 13306:3306 / 16379:6379,只改 .env 里的连接端口,容器内部不变。
2. 数据卷权限。 用命名卷(上面那份配置)基本不会遇到,因为 Docker 会在卷上处理好所有权。但只要改成绑定挂载宿主目录,就会撞上这个:./data/mysql:/var/lib/mysql 起不来,日志里是 mysqld: Can't create/write to file ... (Errcode: 13 - Permission denied)。原因是容器里的 mysqld 以非 root 用户(MySQL 镜像里通常是 uid 999)运行,而宿主目录属于你。修法是先把宿主目录的属主改对:
mkdir -p ./data/mysql && sudo chown -R 999:999 ./data/mysql
Redis 镜像里默认是 root,反而不会碰到这个——所以别把"Redis 没问题"当成"MySQL 也没问题"。
3. root 密码只初始化一次。 MYSQL_ROOT_PASSWORD 只在数据目录为空时生效。之后你改 compose 文件里的密码,容器重启后密码还是老的那个,于是你以为配置没用、开始怀疑人生。改密码要走 SQL,或者接受重建:
docker compose exec mysql mysql -uroot -p'root123456' \
-e "ALTER USER 'root'@'%' IDENTIFIED BY 'newpass'; FLUSH PRIVILEGES;"
同理,MYSQL_DATABASE 和 MYSQL_USER 也只在第一次生效,./initdb 里的脚本也只在空数据目录时执行。
4. 容器删了数据就没了。 docker compose down 保留命名卷,docker compose down -v 会连卷一起删——后者的数据是真没了,没有回收站。我给自己定的规矩是:需要清数据的操作一律先 dump 一份。
docker compose exec mysql sh -c 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases' > backup.sql
如果只是想让 Redis 清空,别删容器,docker compose exec redis redis-cli -a redis123456 FLUSHALL 就行。
5. 时区不一致导致时间差 8 小时。 前面写过配置层面的修法,但要知道这类问题有三层,只改一层不够:容器时区(TZ 和 --default-time-zone)、应用的数据库连接参数(Go 的 parseTime=true&loc=Asia%2FShanghai,Java 的 JDBC serverTimezone)、以及应用自己的时区设置。三层不一致时会出现"写进去对、读出来差 8 小时"这种最难受的情况。我现在统一约定:数据库、应用、日志都按 +08:00 跑,接口一律返回带时区的 RFC3339,这样任一层打印出来的时间都能互相对上。
6. utf8mb4 与 emoji。 典型场景是用户昵称带 emoji,插入报 Incorrect string value,或者更糟——不报错但存进去变成 ???。除了上面的服务器字符集,还要检查三处:库字符集、表字符集、以及客户端连接字符集(连接时的 character_set_client/character_set_connection)。用一条命令确认:
docker compose exec mysql mysql -uroot -p'root123456' \
-e "SHOW VARIABLES LIKE 'character_set%'; SHOW VARIABLES LIKE 'collation%';"
只要有任何一处出现 utf8mb3 或 latin1,那个环节就是问题所在。
7. 直装时的 Redis 只监听 localhost。 从 apt 装的 Redis 默认 bind 127.0.0.1 ::1 并开启 protected mode,本机连着没问题,但从容器里、从虚拟机里连宿主机的 Redis 会被拒绝(报 DENIED Redis is running in protected mode)。要么改 redis.conf 里的 bind 和 protected-mode(同时务必配上 requirepass,别把没密码的 Redis 暴露到局域网),要么就统一走容器里的服务。我在公司内网见过暴露在 0.0.0.0 且无密码的 Redis 被扫到写进挖矿脚本的——本地环境也别养成坏习惯。
结尾
这两套方案我用下来的分界线很清楚:要"可重现"就用 Compose,要"贴近宿主"就直装。日常写业务代码时我几乎不关心 MySQL 是怎么装的,我只关心"同事拉下来能不能一条命令跑起来、一周后再起是不是同一套行为"——这两点容器赢得很干脆。真要说代价,就是上面那七个坑里的前四个:端口、权限、初始化只做一次、以及删卷即删数据。这四个全部理解了,本地数据库这一块基本就不会再出事了。