多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

把 12 个微服务合并回 4 个之后,P99 从 680ms 降到 470ms:过度拆分的 5 笔账

把 12 个微服务合并回 4 个之后,P99 从 680ms 降到 470ms:过度拆分的 5 笔账 title: 把 12 个微服务合并回 4 个之后P99 从 680ms 降到 470ms过度拆分的 5 笔账tags: [微服务, 单体架构, SOA, Serverless, 架构演进]category: 后端新来的同事第一周问了个问题查一个订单详情要调几个服务我打开链路追踪给他看那张图网关 → 订单服务 → 用户服务 → 会员等级服务 → 权益服务 → 商品服务 → 库存服务 → 价格服务 → 优惠券服务 → 物流服务 → 售后服务 → 评价服务。12 跳其中 5 跳是串行的7 跳能并行。P99 是 680ms其中真正执行业务逻辑的时间加起来不到 90ms剩下的全在网络往返、序列化、线程切换和等待上。他说了句这不就是把方法调用改成了 HTTP 调用吗。这句话不客气但准确。那之后我们花了七个月把这套东西合并回 4 个服务P99 降到 470ms故障率降了一半团队的迭代速度反而快了。这篇把这个过程里算清的五笔账写下来。先承认当初拆成 12 个理由在当时是成立的2022 年那次拆分不是拍脑袋。当时的单体应用有 47 万行代码一次全量构建 18 分钟部署要停机 6 分钟20 多个人共用一个代码仓库每次发布前的合并冲突能开半天会。任何一个模块的内存泄漏都能把整个应用拖垮——我们真的遇到过报表导出功能的一个 List 没释放导致下单接口全线 OOM。拆分之后这些问题确实解决了单服务构建 2 分钟独立部署故障隔离。问题在于拆分的粒度。我们当时按领域名词拆看到会员等级这个概念就拆一个服务看到权益又拆一个。结果是会员等级服务只有 3 张表、8 个接口代码量 4000 行日常唯一的调用方就是权益服务而权益服务的唯一调用方是订单服务。三个服务串成一条线中间隔着两次 HTTP却从来没有第二个调用方。这就是典型的分布式单体物理上分开了逻辑上还是一坨改一个需求要同时发三个服务还得排好顺序。第一笔账跨服务调用的固定成本一次进程内方法调用大约 1-10 纳秒。一次同机房的 HTTP 调用我们实测的分解是这样的环节耗时P50说明服务发现 负载均衡0.02ms本地缓存命中时请求序列化Jackson0.3ms平均 2KB 报文网络往返同可用区0.4ms不含服务端处理服务端反序列化0.3ms服务端业务逻辑3-8ms真正干活的部分响应序列化 反序列化0.5ms客户端线程切换 / 回调0.1ms异步框架下固定开销小计约 1.6ms不含业务逻辑1.6ms 看起来不多但要乘以调用次数而且这是 P50。P99 的分布完全不一样——任何一跳发生 GC、连接池等待、DNS 重解析都能贡献几十毫秒。12 跳串下来只要每跳有 1% 的概率慢 50ms整条链路慢的概率就是 1-0.99^12 11.4%。这就是为什么微服务的 P99 天然比单体差。我们那条链路上5 跳串行的固定开销是 8ms但 P99 贡献了 210ms。第二笔账数据一致性的复杂度单体里下单扣库存是一个本地事务Transactional(rollbackFor Exception.class) public Order createOrder(OrderRequest req) { // 三张表在同一个数据库同一个事务要么全成要么全滚 inventoryMapper.deduct(req.getSkuId(), req.getQty()); Order order orderMapper.insert(buildOrder(req)); couponMapper.markUsed(req.getCouponId(), order.getId()); return order; }拆成三个服务之后这段代码变成了public Order createOrder(OrderRequest req) { String txId IdGen.next(); // 第一步预扣库存TCC 的 Try 阶段冻结但不真扣 InventoryFreezeResult freeze inventoryClient.freeze( new FreezeRequest(txId, req.getSkuId(), req.getQty())); if (!freeze.isSuccess()) { throw new BizException(库存不足); } try { // 第二步锁定优惠券同样是可撤销的中间态 couponClient.lock(new LockRequest(txId, req.getCouponId())); } catch (Exception e) { // 补偿撤销库存冻结。注意这里的 catch 必须捕获所有异常 // 包括超时——超时意味着不知道成没成功也必须补偿 safeCancel(() - inventoryClient.unfreeze(txId)); throw e; } Order order; try { order orderMapper.insert(buildOrder(req, txId)); } catch (Exception e) { // 补偿两个顺序无所谓但每个都要保证幂等 safeCancel(() - couponClient.unlock(txId)); safeCancel(() - inventoryClient.unfreeze(txId)); throw e; } // 第三步确认Confirm 阶段。这一步的失败最难处理—— // 订单已经落库了但库存还是冻结态。只能靠异步补偿任务重试 // 而重试期间用户看到的订单状态是处理中 asyncConfirm(txId, order.getId()); return order; } private void safeCancel(Runnable action) { try { action.run(); } catch (Exception e) { // 补偿失败只能记账交给对账任务兜底。 // 这张 compensation_failure 表我们线上一天能积几十条 compensationLogMapper.insert(CompensationLog.of(action, e)); log.error(compensation failed, recorded for retry, e); } }从 5 行变成 40 行还要额外维护冻结记录表、补偿失败表、对账任务、超时清理任务、幂等去重表。我粗略统计过这套分布式事务的配套代码有 2300 行是原来那 5 行业务逻辑的 460 倍。更要命的是它引入的新故障模式。我们上线后半年内跟分布式事务相关的线上问题有 14 起悬挂事务Cancel 先于 Try 到达3 起、空回滚 2 起、幂等失效导致重复扣减 4 起、补偿任务把已完成的订单又回滚了 1 起、对账脚本自身的 bug 4 起。第三笔账本地缓存失效单体里我们大量使用 Caffeine 做本地缓存商品基础信息的命中率能到 96%。拆分之后商品服务变成独立进程订单服务想缓存商品信息就得自己维护一份而失效通知要通过 MQ 广播。Component public class ProductLocalCache { private final CacheLong, ProductVO cache Caffeine.newBuilder() .maximumSize(50_000) // TTL 不能设太长本地缓存 MQ 失效的组合里 // MQ 消息丢失是必然会发生的TTL 是最后的兜底 .expireAfterWrite(Duration.ofMinutes(5)) // refreshAfterWrite 让过期时只有一个线程去回源 // 其他线程先拿旧值避免缓存击穿 .refreshAfterWrite(Duration.ofMinutes(2)) .recordStats() .build(this::loadFromRemote); private ProductVO loadFromRemote(Long productId) { // 这里必须做超时和降级商品服务挂了不能让订单服务跟着挂 try { return productClient.getById(productId); } catch (Exception e) { // 返回 null 会让 Caffeine 不缓存下次继续打远程 // 高峰期这会变成雪崩。返回一个降级对象更安全 log.warn(load product {} failed, use degraded, productId, e); return ProductVO.degraded(productId); } } /** * 商品服务发出变更消息后各个消费方清本地缓存。 * 广播模式消费每个实例都要收到——这一点在 RocketMQ 里 * 要显式设置 MessageModel.BROADCASTING默认是集群模式只有一个实例收到 * 我们上线第一周就栽在这个默认值上导致 7 个实例里只有 1 个清了缓存 */ RocketMQMessageListener(topic product-change, consumerGroup order-service-product-cache, messageModel MessageModel.BROADCASTING) public void onProductChange(ProductChangeEvent event) { cache.invalidate(event.getProductId()); } }这段代码本身不复杂问题是它要在每个需要商品信息的服务里复制一份。我们有 6 个服务需要商品信息就有 6 份几乎一样的缓存代码6 个消费组6 套监控指标。任何一处的失效逻辑写错就是一次数据不一致事故。而在单体里这就是一个 Bean。第四笔账排查成本单体时代排查一个订单金额算错了的问题流程是看日志找到那次请求从入口跟到出口全在一个日志文件里20 分钟能定位。12 个服务之后同样的问题要拿 traceId 去链路追踪系统查调用链找到可疑的那一跳去那个服务的日志系统按 traceId 过滤前提是它正确透传了 traceId发现是它的下游返回了错误数据再往下一跳……我们统计过2023 年跨服务问题的平均定位时间是 2 小时 40 分钟比单体时代长了 7 倍。链路追踪能缓解但解决不了因为追踪只告诉你哪一跳慢/错了不告诉你为什么。而为什么往往需要看那个服务的内部状态那就得找到对应的负责人——如果那人在休假等着吧。第五笔账组织成本这一笔最容易被忽略也最贵。12 个服务每个都需要一套 CI/CD 流水线、一套监控告警、一份容量规划、一个值班责任人、一份接口文档、一套压测脚本。这些东西的边际成本不是零加起来是实实在在的人力。我们算过一笔账维护一个微服务的固定运维成本大约是每月 0.3 人日不含业务开发12 个服务就是 3.6 人日/月一年 43 人日。而当时团队只有 9 个人。更隐蔽的是决策成本。一个需求要改 3 个服务就要 3 个人对齐排 3 次发布做 3 次回归。康威定律说组织结构决定架构反过来也成立过细的架构会强行制造出不必要的协作边界。我们怎么合并的按变更频率而不是按领域名词合并的原则是看它们是不是总是一起改。我们扒了两年的 Git 提交记录统计每两个服务在同一个需求里同时被修改的频率服务对共同变更次数独立变更次数耦合度会员等级 ↔ 权益473 / 50.85订单 ↔ 售后3188 / 120.24商品 ↔ 价格526 / 40.84库存 ↔ 订单961 / 880.06优惠券 ↔ 价格3814 / 60.65耦合度 共同变更 / (共同变更 独立变更之和)。这个数字超过 0.6 的服务对基本可以判定不该分开——它们没有独立演进的能力只是被物理分开了。最终的合并方案会员等级 权益 → 会员域服务商品 价格 优惠券 → 商品域服务订单 售后 评价 → 交易域服务库存、物流、用户各自保留它们的耦合度都低于 0.2而且有多个独立调用方。12 个变成 4 个加上保留的 3 个实际是 7 个但核心链路上只剩 4 跳。合并不是简单地把代码复制到一起。我们保留了模块边界合并后的服务内部按原服务划分 Maven 模块模块之间只能通过定义好的接口调用用 ArchUnit 写规则在 CI 里强制检查。AnalyzeClasses(packages com.example.merchandise) public class ModuleBoundaryTest { /** * 价格模块不能直接访问商品模块的 Mapper 层只能走 Service 接口。 * 这条规则的意义在于将来如果需要重新拆开边界还在 * 不至于合并两年后代码烂成一团再也拆不动 */ ArchTest static final ArchRule price_module_should_not_touch_product_dao noClasses().that().resideInAPackage(..price..) .should().accessClassesThat() .resideInAPackage(..product.dao..) .because(跨模块访问必须走 Service 接口保留将来拆分的可能); /** * 各模块的领域对象不能互相引用跨模块传递只能用 DTO。 * 这条比上面那条更容易被违反——写代码时随手 import 一个实体类太顺手了 */ ArchTest static final ArchRule domain_objects_should_not_cross_module slices().matching(com.example.merchandise.(*)..) .should().notDependOnEachOther() .ignoreDependency( resideInAPackage(..price..), resideInAPackage(..common.dto..)); }四种架构模式的适用边界模式团队规模部署单元数据一致性主要成本什么时候选它单体1-15 人1 个本地事务构建慢、故障不隔离业务未定型、快速验证阶段模块化单体10-40 人1 个本地事务需要纪律维持模块边界业务清晰但团队不大我认为这是最被低估的选项SOA / 粗粒度服务30-100 人5-15 个少量分布式事务服务治理、接口版本管理有明确的独立演进诉求微服务100 人以上几十到上百大量最终一致运维、排查、组织协调团队多到必须并行发布Serverless不限函数级强依赖外部状态冷启动、可观测性弱、供应商绑定流量波峰波谷极端、事件驱动型任务我把模块化单体单独列出来是因为它在国内讨论得太少。它保留了单体的部署简单和本地事务同时用模块边界和构建期检查获得了大部分的关注点分离收益。代价是需要纪律——但需要纪律的架构总比需要 43 人日/年运维成本的架构划算。Serverless 那一行我要多说一句。我们在两个场景上用了函数计算图片处理和数据导出。这两个场景的共同点是事件触发、执行时间短、并发波动极大。图片处理的日常 QPS 是 5但运营做活动时能到 800用常驻服务就得按 800 备容量。换成函数计算之后这块成本降了 74%。但我们没有把任何核心链路放上去——冷启动 300ms 到 2 秒的不确定性在下单链路上是不可接受的。我的判断拆分的触发条件应该是组织问题不是技术问题。这个模块太复杂了不是拆分理由那是重构理由。真正的拆分理由只有一个两拨人需要独立发布且合在一起会互相阻塞。不要按名词拆按变更频率拆。领域驱动设计里的限界上下文是个好概念但实践中很多人把它简化成了按业务名词分类。真正的边界应该用数据说话——把 Git 历史扒出来算耦合度比开三天架构评审会有用。合并回去不丢人。这一点我想强调。技术圈有种氛围好像架构只能往更先进的方向走合并服务等于承认失败。但架构本来就该跟着业务和团队规模双向调整。我们那次合并最大的阻力不是技术是有人觉得这是在开倒车。先把模块边界立住再考虑要不要拆成进程。边界清晰的单体随时可以拆边界模糊的微服务永远合不回去也拆不明白。复盘几个数字合并前核心链路 12 跳5 串 7 并P99 680ms其中业务逻辑 90ms。合并后核心链路 4 跳P99 470ms降幅 31%。业务逻辑耗时没变省下的全是网络和序列化。分布式事务相关代码从 2300 行降到 610 行库存和订单之间仍需要跨服务事务但只剩一处。跨服务问题平均定位时间从 2 小时 40 分降到 55 分钟。运维固定成本从 3.6 人日/月降到 1.5 人日/月。服务器成本降了 22%合并消除了大量为每个服务至少 2 个实例保证高可用而付出的冗余。合并耗时 7 个月分 4 个阶段灰度期间发生 P2 事故 1 次会员权益合并时漏迁了一张配置表影响 40 分钟。留个问题如果你现在接手一个 30 人团队、40 个微服务、核心链路 15 跳的系统第一步会做什么我的答案是先花两周把调用拓扑和 Git 耦合度算出来不动代码。但我见过另一种做法直接冻结新服务创建所有新需求只能加到现有服务里靠不再恶化换时间。两种做法你更倾向哪一种为什么
返回列表