拆过三个 PHP 单体之后,我把踩过的坑和对应的解法整理成这篇,照着做完你至少能省掉一轮生产事故。
第一步:先别急着拆库——连接数会先炸
现象:代码拆成 5 个服务,MySQL 当天就报 `Too many connections`。
原因是每个服务各建自己的连接池,而 PHP-FPM 是「一个 worker 一条连接」的模型。
show variables like 'max_connections'; -- 默认 151
show status like 'Threads_connected'; -- 当前实际连接
show processlist; -- 谁在占
解法只有两条路:一是先拆代码不拆库,所有服务连同一个库,边界靠命名空间和接口约定;二是引入 ProxySQL 之类的连接复用层。
注意:真实连接上限 = 各服务 `pm.max_children` 之和。一个服务池设 50,五个服务就是 250 条常驻连接,还没算 CLI 脚本和定时任务。改代码之前先算这笔账。
第二步:跨服务写操作,本地事务直接失效
最典型的事故:扣库存成功、扣余额失败,钱货两空。
别上 XA 或两阶段提交,PHP 生态里没有靠谱且好维护的实现。务实做法是本地消息表 + 重试,最终一致:
CREATE TABLE outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
topic VARCHAR(64),
payload JSON,
status TINYINT DEFAULT 0, -- 0待投递 1已投递 2失败
retry INT DEFAULT 0,
created_at DATETIME
);
业务写入和这条消息记录放在同一个本地事务里提交,再由常驻进程或计划任务轮询投递。
消费方必须幂等:用发起方生成的业务唯一键去重,`INSERT ... ON DUPLICATE KEY UPDATE` 或 Redis `SETNX` 都行。
注意:幂等键一定要由发起方生成并透传,不要用消费端的自增 ID——那样重试一次就多扣一次钱。
第三步:登录态各认各的,用户被反复踢下线
用户在 A 服务登录,跳到 B 服务又要重新登录,这是拆分的必修课。
两条路:
方案一,Session 落 Redis,改 php.ini:
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?auth=密码"
方案二,网关统一签发 JWT,各服务只验签名不查库。
JWT 的硬伤是注销、改密、封号无法即时生效。务实组合:JWT 短过期(15 分钟)+ Redis 黑名单,或者 JWT 里只放 uid,权限每次从 Redis 缓存取。
注意:两种方案别混用。见过一个项目一半服务读 Session 一半读 JWT,结果是「同一个用户,两个身份」,排查了两天。
第四步:同步调用套娃,一个慢全慢
A→B→C 串起来,C 慢 200ms 就变成 A 慢 600ms,流量一上来全线阻塞。
所有出网调用必须显式设超时,PHP 默认是没有超时的:
// cURL
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, 200);
curl_setopt($ch, CURLOPT_TIMEOUT_MS, 800);
// Guzzle
new Client(['timeout' => 0.8, 'connect_timeout' => 0.2]);
重试要限量:只重试幂等接口,最多 1 次,加随机抖动退避。无脑重试 3 次等于把下游流量放大 3 倍,反而把它打死。
注意:PHP-FPM 没有内置熔断器。熔断要么自己写在 HTTP 客户端封装里,要么把关键链路收敛到网关层做。
第五步:配置散落,环境漂移
测试环境能跑,生产挂掉,最后发现某个服务的数据库地址还指向旧库。
最小可用方案:一份共享配置 + 环境变量覆盖,且服务启动时打印关键配置(脱敏后)到日志。上线后第一件事就是 grep 日志确认连的是哪个库、哪个 Redis。
注意:不要在代码里留 `if (ENV === 'prod')` 这种分支写死地址。改的时候找不到,查的时候更找不到。
第六步:出问题只能靠猜
请求报 500,日志散在 6 台机器上,不知道哪一步断的。
解法是全链路 trace id:网关生成 `X-Request-Id`,每一层透传并写进日志行;日志统一 JSON 格式,只输出到 stdout,由采集端收走。
PHP 里的最小实现是在入口注册兜底:
$traceId = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8));
set_error_handler(fn(...$a) => logJson($traceId, $a));
register_shutdown_function(fn() => logFatal($traceId));
注意:trace id 必须在网关生成再往下传。各服务自己生成一个,等于没有。
小结
- 先拆代码边界,别急着拆库,连接数是最先爆的那个
- 跨服务一致性用本地消息表 + 幂等消费,别碰 XA
- 登录态只能有一种,Session 或 JWT 别混用
- 所有出网调用必须有超时,重试必须幂等且限量
- 配置集中管理,启动时自检并打印
- trace id 由网关生成,全链路透传