Skip to content
// 0x
Go back
0x18 // 后端实践

mysqladmin ping 骗了我——Unix socket 和 TCP 之间的调试陷阱

场景

一个 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() 不报错不等于成功了。静默失败不是系统的问题,是开发者没有给它发出声音的机会。


Share this post on:

Previous Post
把个人经验编译为 AI Agent 可复用资产 — Praxis 知识库的架构思路
Next Post
优惠码批量生成系统的设计 — Stripe Promotion Codes + 自建管理层