场景
一个 SaaS 部署系统,每次创建新租户时自动启动一组 Docker 容器:一个 Nginx+PHP-FPM,一个 MySQL。部署脚本的流程是:
docker compose up -d # 启动容器
↓
等待 MySQL 就绪
↓
导入 schema.sql
↓
运行安装 API
“等待 MySQL 就绪”这一步,我用了一行看起来理所当然的命令:
docker exec mysql-{name} mysqladmin ping -u root -p{pass}
mysqladmin ping 是 MySQL 自带的健康检查工具。返回 mysqld is alive 就说明 MySQL 准备好了。逻辑没问题。
然后连上了 5 次,每次都一样:
mysql: ready
schema: imported → ERROR 2003: Can't connect to MySQL server (111)
install: failed → Connection refused
mysqladmin 说 alive。MySQL 说 connection refused。同一秒。
排查
第一个怀疑是密码错了。用 root 和同样的密码直接进容器里跑 mysql 命令——能连上,能查表。密码没错。
第二个怀疑是 docker exec 的问题。用 root 进 MySQL 容器单独执行 mysqladmin ping 和 mysql 命令——都能正常工作。单独执行没问题。
第三个怀疑是时序问题。会不会是 shell_exec 和 docker exec 之间有时差?在 MySQL 就绪和 schema 导入之间加了 sleep(5)——还是失败。
最终是在容器内单独跑两条命令时发现的区别:
# 这条成功
docker exec mysql-xxx mysqladmin ping -u root -p
# 这条失败
docker exec mysql-xxx mysql -h 127.0.0.1 -u root -p -e 'SELECT 1'
mysqladmin ping 走的是 Unix socket。mysql -h 127.0.0.1 走的是 TCP 连接。
MySQL 启动过程是这样的:先创建 Unix socket 文件,进程变成 alive 状态,然后再初始化 TCP 监听。mysqladmin ping 检测的是第一步。我的应用需要的是第二步。
中间那个窗口期,在生产环境这台老服务器上,大约是 10-30 秒。
修复
把健康检查从 Unix socket 改成 TCP:
// 改前:Unix socket,进程起来就返回 alive
$check = shell_exec("docker exec mysql-{$name} mysqladmin ping ...");
// 改后:TCP 连接,端口真正就绪才通过
$check = shell_exec("docker exec mysql-{$name} mysql -h 127.0.0.1 -e 'SELECT 1'");
// 最多重试 10 次,每次等 3 秒
for ($i = 0; $i < 10; $i++) {
$check = shell_exec("docker exec mysql-{$name} mysql -h 127.0.0.1 -u root -p{$pass} -e 'SELECT 1' 2>&1");
if (strpos($check, '1') !== false) {
break;
}
sleep(3);
}
就这一行改动,花了几个小时排查。
还没完
TCP 检测通过之后,schema 导入偶尔还是失败。同样的 Connection refused。
这次的原因是 MySQL 5.7 的初始化流程。MySQL 进程启动、TCP 端口打开之后,还有一个短暂的再初始化步骤——它会重启 TCP 监听。如果 schema 导入恰好在这个窗口发起,连接就失败。
解决方式是给 schema 导入也加了重试:
for ($j = 0; $j < 5; $j++) {
$out = shell_exec("docker exec -i mysql-{$name} mysql -h 127.0.0.1 ... < schema.sql 2>&1");
if (strpos($out, 'ERROR') === false) {
break;
}
sleep(2);
}
两层健康检查,两层重试。
另一类静默失败
TCP 和重试解决了连接问题,但安装 API 中还有一个更隐蔽的坑。
安装脚本负责在 MySQL 中创建 webusers 表,供用户登录使用。代码是这样的:
$mysqli->query("CREATE TABLE IF NOT EXISTS webusers (...)");
$mysqli->query("INSERT INTO webusers (...) VALUES (...)");
没有错误检查。如果 CREATE TABLE 失败(比如应用账号 wms_user 没有 CREATE TABLE 权限),INSERT 也会失败,但两行都静默——API 返回”部署成功”,用户拿到的凭据无法登录。
排查时发现 webusers 表根本不存在。解决方式是改用 root 账号在部署阶段预创建该表,绕过权限问题。但这暴露了一个更根本的问题:任何 SQL 操作如果不检查返回值,就是在赌它一定成功。
总结
mysqladmin ping 没骗我——它的职责是检查 MySQL 进程是否 alive,它完成了。是我误解了它的含义,把它当成了”MySQL 端口已接受 TCP 连接”的等价物。
这个误解在生产环境暴露,是因为 Docker 容器里 MySQL 的 Unix socket 和 TCP 初始化有时序差。在裸机 MySQL 上,这个时序差可能只有几毫秒,没人在意。容器放大了这个差距。
几个具体教训:
Unix socket 和 TCP 是不同的东西。依赖 TCP 连接的健康检查,就用 TCP 方式去验证。mysqladmin ping 返回 alive 不等于能连上 -h 127.0.0.1。
时序相关的 bug 不能靠加 sleep 修。sleep(5) 可能在你的机器上能行,在慢一点的服务器上就不行。重试是更可靠的方式。
SQL 操作永远检查返回值。$mysqli->query() 不报错不等于成功了。静默失败不是系统的问题,是开发者没有给它发出声音的机会。