先交代一个背景:我们要拆的系统是个典型的PHP单体,MVC结构,代码量二三十万行,支撑着一个日活不小的电商项目。为什么拆?因为不拆确实不行——发布排期越拉越长,数据库连接数经常被打满,十几个人在同一份代码里互相踩脚,任何一次改动都要全量回归。当时我们团队读了不少微服务相关的文章,信心满满地定了第一刀:按功能模块拆成用户、订单、支付、商品四个服务。结果呢?这一刀切错了,后面整整返工重构了三个月。
今天这篇不想讲大道理,就把我们自己踩过的坑、重切边界的过程一点点摊开,给准备对PHP老项目动刀的同学做个参照。尤其想聊清楚一个事:为什么看起来非常合理的拆分维度,实际跑起来会让人崩溃,以及我们后来是怎么把边界一点点扭正的。
1. 项目背景:什么样的PHP单体,为什么非要拆
1.1 当时单体已经撑不住了
先交代一下项目本身。这个系统不是那种课堂Demo,是一个跑了两年的真实电商业务,PHP加MySQL,经典三层MVC结构。项目早期开发只有三个人,功能堆得很快,等团队涨到十几个、业务线铺开之后,问题开始集中爆发。
第一是发布堵车。一个功能从提测到上线,基本要等一整轮全量回归,QA排期经常两三天起。某个模块改个字段,其他模块的用例可能也跟着挂,发布窗口拉得很长。我们甚至遇到过为了修一个促销活动的bug,硬等了三天窗口期,结果活动都结束了线上还没发上去。
第二是数据库压力。业务高峰期MySQL连接数经常打满,慢查询一大把。核心订单表几千万行,各种表还互相join,随便一个报表查询都能把库拖慢半拍。第三是团队协作开始内耗,十几个开发在同一个仓库提交,光解决合并冲突就能耗掉半天,代码review也渐渐流于形式。
1.2 微服务改造最初的心理预期
在这种背景下,技术负责人提出拆微服务,团队内部其实没人反对,大家已经被单体折腾得够呛了。当时我们看了不少技术分享,觉得微服务就是画个边界、定义几个接口、把代码搬一搬,再补点管理配置就完事了。我们甚至提前在内部白板上画好了第一版架构图:用户服务、订单服务、支付服务、商品服务,四个框加几条箭头,整洁又对称。
结果现实狠狠打了脸。那幅看起来很漂亮的架构图,直接养活了我们接下来三个月。写这篇文章不是劝大家别拆微服务,而是想先把嘴边的经验讲透:第一刀如果切错,后面的返工成本会让你怀疑人生。如果你也准备给手头的PHP单体动刀,建议先看看第二、三节,我们在那里吃了最大的亏。
2. 第一刀切在哪里:按功能模块拆出四个服务
2.1 当时定的拆分方案
第一刀定的拆分维度很简单:打开后台管理菜单,照着页面上的功能清单分。用户管理归用户服务,订单管理归订单服务,支付管理归支付服务,商品管理归商品服务。每个服务保留自己的控制器、模型、模板和静态资源,对外暴露HTTP接口,入口通过Nginx按URL前缀转发。
当时觉得这个方案简直天经地义:用户相关代码放用户服务,逻辑清晰、目录整洁,每个服务还能独立扩容。PHP项目代码确实大,数据模型也复杂,但拆成四个目录级别的服务,至少从直观感受上,整个项目清爽了不少。我们甚至给每个服务起了独立的名字,提交到不同的Git仓库,自我感觉非常专业。
2.2 为什么看起来合理,实际走不通
这里必须把话说透:我们犯的错不是拆服务这个动作本身,而是拆的维度完全错了。功能模块是产品形态上的分类,业务边界是数据和能力归属上的边界,两者经常不是一回事。我们按前者拆,拆出来的四个服务,代码上确实分了家,但在数据和业务上完全纠缠在一起。
最要命的点是数据库还是原来那一个。订单服务下单时要校验用户状态,直接连库查用户表;支付回调到账后,支付服务直接UPDATE订单表的状态字段;商品服务的一个上架接口,还要JOIN用户运营的行为表。服务之间的代码依赖虽然用HTTP接口替代了一部分,但底层数据模型还是单体的,所有业务还是一张网。
PHP单体还有一个特有的坑:很多公共逻辑放在一个共享的函数库或基类库里。拆服务的时候这部分代码被复制到各个服务里,形成了大量各改各的代码副本。有次用户状态字段加了个新取值,四个服务里的常量定义改了三个,排查半天才发现漏了一个。
2.3 拆完之后的日常:分布式毛团
四个服务上线之后,业务链路一点都没变短。一个下单请求从入口进来,可能要依次调用订单服务、用户服务、营销服务、支付服务再绕回订单服务,最少七八跳。如果中间某个服务慢上几百毫秒,整个接口的响应时间就跟着崩。为了图省事,服务之间的鉴权逻辑做得也很随意,有的接口直接裸奔,全靠内网环境兜底。
更麻烦的是共享Session。PHP单体时代登录态都放在服务端Session里,拆服务后一开始大家还是共用同一个Session存储。但不同服务的读取时机不一样,经常出现用户在订单服务里是登录态、到用户服务里却要求重新登录的诡异现象。后来我们专门做了统一登录态查询,又引入缓存,但每次改动还是牵一发动全身。
这还只是开始。因为服务间用了同步HTTP调用,事务的原子性彻底丢了。最典型的是扣库存和生成订单:原来在同一个数据库事务里能保证要么都成功要么都失败,拆成两个服务之后,订单服务调库存服务扣库存,库存服务扣成功了,订单服务这边却因网络超时回了失败,于是库存没了,订单也没生成。这个问题第一天就遇到了,当时以为是偶发问题,重试了几次就把锅甩给网络抖动。直到线上库存经常莫名其妙对不上,才意识到不是网络问题,是拆分边界把事务撕碎了。
3. 确认切错:一场复盘会,和数据地图
3.1 从功能模块思维到数据所有权思维
转机出现在一个周六的复盘会。当时线上又出了一次全站故障:一个促销活动接口因为多服务串调打爆了数据库连接池,几乎整个站点的写操作都不可用。排查结束后,我们萌生了画一张数据地图的想法——把库里每一张表、每一个关键字段,都标出谁在负责写、谁在负责读、谁只是拿个副本。
图画到一半,所有人都沉默了。订单表里有用户手机号、昵称、地址,还有支付回调的状态、第三方交易号;营销活动表里存着商品分类ID;库存表被库存服务、订单服务、客服系统三处直改。说白了,我们之前分割的不是业务核心,只是把代码搬了家,数据层面的依赖还是原来那套。从那一刻起,我们才真正接受一个结论:服务边界应该由数据归属来定义,而不是由代码目录来定义。谁的数据由谁负责写,谁负责写才说明谁拥有这个领域,然后才谈得上把能力封装成服务。
这里可以展开说一句:数据所有权是微服务拆分中最容易被忽视、却又最核心的标尺。按页面菜单拆,你只看到了功能入口的聚合;按数据归属拆,你才能真正看到"哪个服务崩溃会影响哪一片业务"。把数据地图画出来之后,很多之前觉得"挺合理"的拆分方案,一眼就能看出问题。
3.2 数据归属表的示例
第二刀开始之前,我们先把核心业务线重新梳理了一遍,做了一张类似下面的归属表:
| 服务 | 拥有的数据 | 对外提供的能力 |
|---|---|---|
| 用户服务 | 用户主数据、账号、收货地址、实名信息 | 注册、登录、资料查询、地址管理 |
| 订单服务 | 订单主体、订单明细、订单状态流转记录 | 下单、订单查询、取消、售后单创建 |
| 支付服务 | 支付单、支付流水、退款单、对账单 | 发起支付、回调处理、退款、对账 |
| 库存服务 | 可用库存、预占库存、库存流水 | 预占、释放、确认扣减、查询 |
| 营销服务 | 优惠券模板、用户券实例、活动配置 | 发券、验券、核销、活动查询 |
这里有个非常关键的判断标准:一行数据到底归哪个服务,看哪个服务负责它的创建和修改,而不是看哪个服务用得多。比如订单详情页要展示手机号,但手机号的权威源在用户服务,订单服务只能把手机号作为查询快照缓存起来,不能自己改。谁要是打破了这条规则,谁就是在给下一次重构埋雷。
3.3 确认切错的几个信号
如果你不确定自己手头项目的拆分边界对不对,可以对照这几个信号自查:
- 跨服务直接查库:服务代码里出现从别的服务的表里SELECT数据的语句,不用犹豫,边界有问题;
- 两个服务能改同一张表:尤其是有状态的表,比如订单状态、支付状态,两边都改,早晚要出脏数据;
- 一个业务需要跨三个以上服务同步调用:链路太长,性能问题和故障定位问题都会成倍放大;
- 任何一次发版都要牵动多个服务联动:接口签名改一个字段,下游所有服务都得陪着一起发,这叫假拆分;
- 促销活动一上线,全站跟着挂:说明服务之间没有真正隔离,故障爆炸半径还是全站级别。
4. 返工重构:三个月把错误边界扭正
4.1 返工的整体节奏:不推倒重来
确认第一刀切错之后,摆在面前的有两条路:一条是把服务先合并回单体,重新按数据边界再拆一遍,理论上最干净;另一条是在现有错误服务基础上,逐步把数据和代码挪回正确的归属。现实很骨感,业务还在跑,不可能把线上下线三个月等我们重新来。所以只能选第二条路。
整个返工过程我总结成四个阶段:先定数据归属,再动表结构,再改代码依赖,最后切流量。顺序不能反,尤其不能一上来就改服务接口,否则会同时踩数据不一致和接口不兼容两个坑。
第一阶段大概花了两周,把所有事情聚焦在把归属表细化到字段级,并且跟所有开发对齐,在各服务里把我该拥有什么、不该碰什么写进文档。第二阶段开始拆表,把一个共享数据库拆成按服务分库的多个实例,对外只开放本服务的数据读写能力。第三阶段改造代码依赖,把所有跨库直查改成接口调用或消息订阅。第四阶段才是平滑切流,通过开关和灰度把线上流量逐步切到新链路。
4.2 数据迁移:双写、对账、切流的细节
返工里风险最高的部分就是数据迁移。我们当时用了比较稳妥的双写策略,过程大概是这样的:
- 双写阶段:在代码里加一个开关,新写入操作同时写旧表和归属服务的新表。这个阶段要保证两边都成功,任何一边失败都要有补偿机制。
- 对账阶段:写定时任务,每天对比新旧两边的数据,差异项自动生成报告。对账脚本不要只对比主键和数量,还要对比关键业务字段的更新时间,以免漏掉更新。
- 切流阶段:开关按比例放流量,比如先放10%,观察一天,再逐步提升。切流期间保留旧表只读,保证可以随时回滚。
- 清理阶段:新链路稳定运行一两周之后,再把旧表字段停写、下线,减少后面维护成本。
每一步都有坑。双写时最容易出问题的是顺序:如果先写新表、后写旧表,旧表失败会触发报错,业务方可能以为写失败了,但实际上新表已经写成功,于是又重试一遍,产生重复数据。我们后来把两边放到同一个事务里,或者用事务消息加本地日志的方式保证一致性。对账脚本也得注意,不要只查一次,要对同一批数据连续两天跑,排除统计口径不一致的假差异。
4.3 从同步调用到异步消息解耦
把数据拆开之后,服务间通信方式也得跟着变。第一刀时期我们清一色用同步HTTP调用,链路长、稳定性差。这次重构我们把大部分跨服务交互改成了异步消息:比如支付成功之后,支付服务只负责更新自己的支付单状态,然后往消息队列里发一条支付成功事件;订单服务订阅到这个事件后,再更新自己的订单状态。库存预占同理,下单服务发起占库存请求,库存服务异步处理后回执结果。
这样做有几个直接的好处:
- 调用链变短:接口不再需要等待一堆下游全部返回,整体响应时间降下来;
- 故障隔离:下游服务短暂不可用,上游可以先把请求落地,等恢复后再处理;
- 减少分布式事务的痛苦:用最终一致性替代强一致,配合本地消息表和对账任务,绝大多数场景都能自洽。
但异步也引入了新问题:状态不一致的窗口期变长了。用户支付成功后刷新页面,可能订单还是待支付,因为消息还在队列里排队。这个体验问题我们通过前端状态机部分缓解:支付回调页面先展示支付处理中,服务端把订单状态作为提示文案返回,而不是让用户看到矛盾的待支付字样。给后来者提个醒:异步化解耦之前,先想好用户体验上哪些状态可以容忍延迟,哪些不行。
4.4 为什么整整用了三个月
最后聊一下三个月的构成。时间不是平均分配的,大概是这样:
- 确定数据归属和边界:两周,但这步最花脑力,边界争吵就是从这里产生的;
- 数据库拆分和双写改造:一个月,涉及历史数据、统计报表、存储过程的改造,到处都得照顾;
- 代码依赖改造:三周,把跨服务直查全部改成API或消息,还有大量公共类和常量定义要重新组织;
- 联调、回归、灰度、切流量:三周,期间穿插线上问题修复;
- 剩余时间消耗在返工旧代码、清理死代码、调整部署流水线:零零散散,加起来有半个多月。
实际上如果再来一遍,有效干法大概率能压缩到六到八周。省时间的核心是:不要同时动多条业务线,一次只理顺一条主链路,全链路验证OK后再开下一条。我们前期贪快,四条业务线同时改,结果互相牵制,返工率反而更高。
5. 重构后的架构和工程手段
5.1 最终落地形态
三个月的返工结束后,线上跑的终于不再是披着微服务外衣的单体了。最终形态是这样的:
- 每个服务有独立的Git仓库、独立的部署单元、独立的数据库实例;
- 服务之间只能通过对外API或消息队列通信,禁止跨服务直接连库查表;
- 每个服务维护自己的配置、日志、缓存;
- 服务注册发现先继续用Nginx upstream加域名的方式,等服务数量超过十五个再考虑引入更重的注册中心;
- 全链路唯一请求ID写入日志,排查问题靠日志聚合平台按ID串联。
这套架构没有上很多文章里吹得天花乱坠的全套组件,但已经拿回了最核心的价值:订单服务挂了,商品页面照样能逛,用户登录不受影响;数据库连接数也不集中在同一个实例上,峰值期整体稳定很多。
5.2 灰度发布和回归测试怎么做
拆服务之后,回归测试的复杂度肉眼可见地上升了。单体时代一条主流程用例,现在要横跨好几个服务,任何一个服务没起来,整条用例就失败。我们最终采取了三个措施:
- 搭了一套契约测试:每个服务对外接口的请求和响应结构做成契约文件,服务间联调时用Mock模拟下游,只在集成环境跑真实联调。
- 主链路回归用例常驻运行:商品浏览到下单支付再到退款这条链路每天定时跑一遍,任何异常直接提缺陷单,不等人工发现。
- 灰度上线的开关放在流量入口:按用户ID做灰度,从10%慢慢放到100%。放量过程中盯两个指标,核心接口成功率、数据库慢查询数。
这里有个容易踩的点:拆服务之后,开发环境不像单体那样一键启动了。我们一开始要求每个开发在自己机器跑全套,结果配置折腾半天,后来改成在测试环境维护一套全量联调环境,每个开发提交代码后就部署自己的分支服务上去,通过请求头路由到对应分支,联调效率高了很多。
5.3 部署和日志层面的调整
PHP项目的部署方式也变了。原来的单体是几台机器跑PHP-FPM,配一个Nginx入口;现在每个服务都有自己的FPM池和机器组,光Nginx配置就从一份变成六份。部署流水线从一次构建一次发布变成多服务并行构建、按依赖顺序发布,中间要处理服务版本兼容问题。下游服务升级接口,必须保证上游服务旧版本还能跑一段时间。
日志层面最痛苦。单体时代一个文件看全部日志,拆服务后同一个用户请求要跨多个文件查。我们早期排查一个问题要在五六个服务的日志窗口来回切,效率极低。后来统一了日志格式,每个请求都打上唯一请求ID,并接入统一的日志采集平台,才算把排查效率拉回来。这个事我们本来以为可以后期再做,结果后期真被绊住了,建议你们一开始就把日志规范定下来。
6. 常见问题与排查技巧实录
6.1 返工过程中遇到的高频问题
把三个月里反复出现的问题做个速查表格,每一行都是真实踩过的,没夸张:
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 下单接口响应时间翻倍 | 同步调用链太长,下游服务稍慢就拖垮链路 | 非核心交互改为异步消息;必须同步的调用设置超时、熔断 |
| 支付成功但订单显示未支付 | 支付服务直接改订单表失败,事务回滚后消息丢失 | 用本地消息表:先写库事务,再投递消息,投递失败有定时任务补投 |
| 服务拆了但数据库连接数还是满 | 所有服务仍然共用同一个库 | 按数据归属分库,每个服务只连自己的部分,跨服务改API查询 |
| 改了主库数据但页面不更新 | 读写分离有延迟 | 关键读走主库;写后主动删缓存或用版本号 |
| 某个服务挂掉后其他服务跟着挂 | 跨服务调用无超时、无熔断 | 所有跨服务调用必须设置超时和重试上限;同步链路禁止超过两层 |
| 灰度期间新旧逻辑不一致 | 切换代码时没有双跑对账 | 像数据迁移一样做新旧逻辑对账,灰度期拉长 |
| 多个服务各自发版步骤冲突 | 服务间接口版本没有契约管理 | 引入契约文件,接口变更先升级下游消费方再改提供方 |
6.2 一个死锁案例:从SQL排查到边界问题
最后分享一个印象很深的排查案例。现象是高峰期下单和领券两个操作频繁报数据库死锁,看日志发现两个事务都在抢同一张订单明细表:一个事务做的是插入订单明细加更新营销表里的券实例状态,另一个事务做的是核销券加更新订单状态。两边都跨了服务边界,又都在同时操作彼此的数据。
我们一开始的思路是优化SQL、调整索引,把死锁日志翻来覆去地看,结果索引加了好几轮,问题还是偶发。后来回到数据归属表上一对比,发现问题根子在于:订单服务和营销服务的业务边界没有完全分开,券核销和订单创建在数据层存在交叉写入。处理方式是把下单创建和券核销彻底解耦:用户下单时先预占券,生成一个预占凭证;订单服务创建订单成功后,发消息通知营销服务正式核销;营销服务再异步更新券的状态。两边数据不再交叉,死锁直接消失。
这个案例给我的启发是:排查微服务问题,不要一上来就翻SQL和日志,先看数据归属是否符合预期。死锁、慢查询、数据不一致,很多时候只是结果,根子出在服务边界的划分上。
7. 复盘清单:给准备拆PHP单体的人几条实在建议
7.1 动手之前先回答五个问题
如果你也准备做微服务改造,先把下面五个问题写下来,答完再动刀:
- 我的核心业务链路是什么?这条链路跨了哪些实体和数据?
- 哪些数据是权威源,哪些只是查询副本?
- 当前最痛的点是什么?是发布频率、性能瓶颈,还是团队协作?
- 哪些故障我希望隔离?隔离之后,剩余的强依赖还有多少?
- 团队里有没有人能独立负责某个服务的设计和运维?
答完这五个问题,基本就知道第一刀往哪里切了。如果答不上来,别急着拆,先回去画数据地图。
7.2 合理的拆分顺序是先小后大
我们最失败的地方是试图一上来就把四个核心服务拆完。如果再做一次,我会先选一个和主链路耦合最少、压力又最大的能力下手。比如这个项目里的商品查询,它读多、压力大、对其他模块的依赖少,很适合作为第一个试点服务。把它拆干净跑稳,团队就积累了完整经验:定边界、拆数据、改接口、灰度过量。后面再拆订单、支付这种高耦合模块,心里就有底了。
这个顺序还有一个好处:能看到业务收益。第一个服务拆完,商品查询的慢查询和连接压力立刻缓解,管理层也更愿意继续支持后续改造。反过来,如果一上来就先拆最难的支付链路,三个星期不出效果,项目很可能就被质疑了。
7.3 关于团队协作,说几句不中听的
微服务改造本质上是一次团队认知升级,不是纯技术升级。代码可以花时间重构,但人的习惯很难短期改过来。我们返工期间最头疼的不是架构设计,而是部分开发仍然习惯直接查库,新边界规定写进文档也没人细看,非得在代码review里一次次打回去。后来我们把禁止跨服务查表写进CI检查脚本,一旦扫描到可疑的跨库SQL直接构建失败,情况才好转。
所以我的建议是:拆服务之前,先在团队里对齐两件事——第一,数据所有权优先于代码便利性;第二,接口契约是服务之间唯一的依赖方式。这两条如果没形成共识,后面的架构再漂亮,也会被人为拉回单体。
三个月下来,我最大的体会是:拆微服务的成败,往往不在技术能力,而在第一次切边界的判断。第一刀切对了,后面都是按图施工;切错了,返工成本远超你最初的想象。这篇文章讲的不是微服务有多好,而是一个普通团队如何在错误中把架构扭回来的真实过程。如果你正站在拆分的起点上,希望这个复盘能帮你少走几个月的弯路。