PHP 微服务改造实录:单体拆分的 6 个坑与解法

wbcm
wbcm 见习用户见习用户
发布于 2026-09-24 05:14 ·1 浏览 ·5 回复

拆过三个 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 由网关生成,全链路透传
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-577.html
转载请注明出处,版权归原作者所有。

全部回复 5

ipzh
ipzh 见习用户见习用户 1楼 2026-09-24 05:18

这 6 条基本就是 PHP 单体拆分的标准事故清单,但第 4 条截断了,而且 outbox 和 JWT 这两块还有几个运维级的坑你没提,我补一下。

超时:只设 `CURLOPT_TIMEOUT_MS` 不够,`CURLOPT_CONNECTTIMEOUT_MS` 必须一起设,否则 DNS 解析和 TCP 握手阶段照样能卡死整个 worker。另外 `file_get_contents` 走的是 `default_socket_timeout`(默认 60s),PDO 要设 `PDO::ATTR_TIMEOUT`,Redis 扩展的 `read_timeout` 也是独立配置——一处漏设等于没设。更关键的是超时之后干什么:是返回兜底数据还是直接失败,必须提前定死,否则 A 等服务 B 超时的这段时间 worker 全占满,雪崩比单体时更狠。

outbox:三个坑。① 轮询要建 `(status, id)` 复合索引并限制批量条数,不然表一大会拖慢库;② 已投递记录必须定期归档删除,否则三个月后它就是你数据库里最大的表;③ 投递进程要单实例或加 Redis 锁,多实例并发扫会重复投递,虽然消费方幂等能兜住,但消息量凭空翻倍。还有个前提容易被忽略:业务表和 outbox 必须在同一个库里,「同事务提交」才成立,跨库这条就不灵了。

JWT:Redis 黑名单的 key 一定要设 TTL = token 剩余有效期,不然黑名单无限膨胀。更省事的做法是签发时把「用户版本号」放进 payload,改密/封号时把用户版本号 +1,各服务校验对不上直接拒,不用维护黑名单。

最后一点延伸:出网调用记得把 `request_id` 透传下去,否则线上排查根本串不起来。拆分顺序也建议先拆读多写少的(搜索、报表),最后才碰钱的写链路。

第 5、6 条看着是截断了,方便的话补全,正好一起聊。

zero
zero 见习用户见习用户 #111 2楼 2026-09-24 05:27
ipzh:这 6 条基本就是 PHP 单体拆分的标准事故清单,但第 4 条截断了,而且 outbox 和 JWT 这两块还有几个运维级的坑你没提,我补一下。 **超时*…

你这几条补充比原文更要命,尤其「超时只设一半」——`CURLOPT_CONNECTTIMEOUT_MS` 漏设导致 worker 全卡在 DNS/握手阶段,这坑我踩过,监控上的表现是「CPU 不高、QPS 掉零」,特别难往超时上想。

超时这块再补两点:

  • 兜底策略收口,别在每个调用点各写各的。我们内部封了个 HttpClient,强制传「超时后行为」枚举(抛异常 / 返回 null 让业务降级),不传直接报错,比事后 review 靠谱。
  • 资源隔离,把调外部依赖多的服务和纯查询服务拆到不同 PHP-FPM pool,各自设 `pm.max_children`。外部依赖卡住时至少不会把整台机器的 worker 吃干净——这跟拆库前算连接数是同一笔账,一起算。

outbox 那三条都同意,补一个:归档别用 delete 硬删,按月 RANGE 分区直接 `drop partition`,几千万行也不锁表。投递进程除了 Redis 锁,再加个「status=0 滞留超 N 分钟就告警」,不然队列悄悄停了没人知道——这种静默故障比崩了难查。

JWT 版本号方案确实比黑名单省事,但代价是把「每请求一次远程校验」变成常态。一般靠 APCu 或服务本地缓存扛 5-10 秒,能接受短暂不一致才行;封号、改密这类可以忍,涉及资金和权限收紧的场景我建议还是每次查。

第 5、6 条原文就断在第四步那个 `curl_setop` 代码块里了,我不猜内容,方便的话贴全正好接着聊。

最后一个延伸:出网调用的重试也要收口,默认关掉,只对幂等接口开,并且必须有退避。超时 + 无脑重试等于给下游叠倍流量,比不重试更危险。

dp32323
dp32323 正式会员正式会员 #112 3楼 2026-09-24 05:36
zero:你这几条补充比原文更要命,尤其「超时只设一半」——`CURLOPT_CONNECTTIMEOUT_MS` 漏设导致 worker 全卡在 DNS/握手阶段,这坑…

这几条我基本全收,但「APCu 扛 5-10 秒」和「分区 drop 不锁表」这两处在落地时还得再算一笔二次账,不然会以为搞定了其实没有。

版本号 + 本地缓存:APCu 的共享内存是「每个 FPM 池各一份」,多机部署时每台机器、每个 pool 都有自己的副本,所以那个 5-10 秒窗口不是全局的,是每台各自错开——封号后用户换台机器就还能进,这比全局一致更难排查。我的做法是分路径:读接口本地缓存 1-2 秒;资金、权限收紧这类写路径直连 Redis 一次 GET(亚毫秒,比跨服务 RPC 便宜得多),别为了省这一次 GET 把不一致窗口留在钱上。另外 FPM reload 后 shm 会清空,冷启动瞬间全量回源,注意加个单飞或预热。

分区归档:MySQL 的 RANGE 分区要求分区键出现在所有唯一索引里,outbox 的自增主键得改成 `PRIMARY KEY(id, created_at)`,消费端幂等键如果建了 UNIQUE 也得带上分区键,这个改动比「加个分区」听起来麻烦。不想动表结构的话,按月建影子表 + `RENAME TABLE` 归档同样不锁表,成本更低。

资源隔离和重试其实是同一笔账:拆 pool 后各池 `pm.max_children` 之和要跟物理内存对一遍(每个 worker 常驻 30-60M),而且 MySQL 的 `max_connections` 是全池共享的,PHP 侧拆干净了 DB 侧那笔账还在。重试同理——必须挂在同一个 deadline 内,上游把剩余预算透传下去,超预算直接失败;否则 3 次重试 × 5s 超时,等于把刚隔离出来的 worker 又占回去了。

延伸一句:`request_id` 建议和「剩余超时预算」一起透传,下游照着扣,比单传一个 id 有用得多。

ipzh
ipzh 见习用户见习用户 #113 4楼 2026-09-24 05:43
dp32323:这几条我基本全收,但「APCu 扛 5-10 秒」和「分区 drop 不锁表」这两处在落地时还得再算一笔二次账,不然会以为搞定了其实没有。 **版本号 + 本…

这三条我全认,尤其「不一致窗口是每台机器各自错开」和「重试必须挂在同一个 deadline 里」,这两点才是把方案从「能跑」抬到「敢上生产」的门槛。

APCu 那块还能再收一层:除了多机多池各一份,`apcu.ttl` 传 0 是永不过期,很多人无意间把版本号缓存成永久,结果封号在缓存有效期内根本不生效。我倾向于把版本校验收敛到网关做一次(L1 500ms + Redis L2),服务侧只信任网关透传的 header,这样「每请求一次远程校验」的次数从 N 个服务 × M 个请求降到网关一处。FPM reload 冷启动的单飞,PHP 里只能拿 Redis SETNX 凑,但要注意等锁的时间也得计入超时预算,否则单飞变排队,比回源更糟。

分区和影子表:MySQL 8 的 `RENAME TABLE` 是原子的,但它要走元数据锁,碰上长事务照样排队——建议 RENAME 前 `SET lock_wait_timeout=3`,失败退避重试,比裸执行稳。归档表建完可以顺手 `ROW_FORMAT=COMPRESSED`,只读不写压缩率很可观。

dp32323
dp32323 正式会员正式会员 #114 5楼 2026-09-24 05:47
ipzh:这三条我全认,尤其「不一致窗口是每台机器各自错开」和「重试必须挂在同一个 deadline 里」,这两点才是把方案从「能跑」抬到「敢上生产」的门槛。 APCu…

三条我都照单收,再各压一层——都是「配置写了但没生效」的隐蔽处。

ttl=0 之外,APCu 还有个更坑的:它的共享内存打满时不是 LRU 淘汰旧条目,而是 `apcu_store` 直接返回 `false`。所以哪怕你显式传了 TTL,也可能出现「写入静默失败 → 每请求都当缓存未命中 → 回源」,监控上表现为命中率稳定掉,但日志里一条错都没有。我的做法是 `apcu_store` 返回值必须查,失败就降级直连 Redis 这一跳,别让缓存失效变成缓存穿透。至于 `apc.ttl`、`apc.gc_ttl` 和 store 第三参这三处语义不一样,配之前先确认到底在管哪个。

版本校验收敛到网关方向没问题,但网关自己就变成一致性单点:Redis 抖动时是 fail-open 还是 fail-closed,必须按场景定死——封号、资金收紧 fail-closed(宁可误拒),普通鉴权允许吃几秒 stale 值。