ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从单体到Service Mesh:Java电商返利系统架构升级实录

从单体到Service Mesh:Java电商返利系统架构升级实录

做电商返利App的架构升级,这半年多我们基本把整套系统从单体到微服务再到Service Mesh重新梳理了一遍。项目本身不算太复杂,核心就是围绕返利场景做订单归集、返利计算、佣金结算和提现,但它有个典型特征——流量会跟着大促和社交裂变暴涨,业务规则又经常调整,团队并行开发的需求也越来越多。这种情况下,单体应用的瓶颈很快暴露出来。我决定把整个演进过程拆成几个阶段记录下来,重点说说在Java技术栈下的落地路径,以及每一轮改造真正付出了什么代价、换来了什么收益。

如果你是中小团队的技术负责人,或者正在纠结“要不要拆微服务”、“上了Service Mesh会不会性能更差”,这篇文章应该能给你一份可以参考的实测数据:我们做了好几轮压测和成本收益分析,结论可能和你预期的不太一样。

1. 从单体起步:返利系统的业务特征与原始架构

1.1 返利业务到底在做什么

返利App的业务链路比普通电商要复杂一点。用户从App或小程序进入,看到淘宝、京东、拼多多这类渠道的商品,通过返利平台打开的特定链接下单。平台拿到订单号后,要持续跟踪这笔订单的状态,确认收货后,回来计算返利金额,再经过一段锁定周期把佣金发到用户账户。

这个流程里最核心的一个字,就是“等”。确认收货前要等物流,返利生效前要等结算周期,用户提现前还要等风控审核。而这些等待都依赖一个可靠的方式记录订单状态,并且能在正确的时机触发返利计算。我们初始阶段用Java写了一个还算规矩的单体应用,用Spring Boot搭基础框架,MyBatis做持久化,MySQL存业务数据,Redis放热点商品和临时状态,定时任务负责订单状态轮询和返利试算。

单体阶段最大的好处是简单。业务逻辑都在一个进程里,下单、锁单、算佣金的代码直接方法调用,没有跨网络的问题,调试也方便。最初几个月开发速度非常快,一个后端开发两天就能上线一个新渠道的接入。

1.2 单体应用的技术栈与运行形态

我们的单体应用是标准Java代码加外置依赖的模式:Spring Boot作为主框架,内部按业务模块分包而不拆进程。路由层直接用Spring MVC的Controller处理HTTP请求,核心业务层分为商品、会员、订单、返利、结算、消息通知几个模块,模块之间共享一个数据库连接池和事务管理器。数据库做了读写分离和分库分表的预留,但短时间内还是单实例加从库结构。

线上部署形态是两台Java应用服务器加一台MySQL主库,前面挂Nginx做简单负载均衡。Redis是独立部署,用来做商品缓存、登录态和订单去重校验。整个系统跑了大半年,遇到最频繁的问题其实不是性能,而是一个最简单的“改一处要全量发布”:改返利比例的后台逻辑,就要带着全部代码一起上线,回滚也可能影响其他功能。

我记得有一次改返利试算的精度处理,顺手动了一个公共工具类,结果同一个类被提现服务引用,间接导致一批用户的提现计算出现偏差。这类问题在单体里不多见,但一旦触发就是低级却严重的线上事故。

1.3 压垮单体的最后一根稻草

真正逼我们启动微服务改造的,不是某一台机器突然扛不住,而是三件事同时发生的。

第一件事,业务规则变得太快。返利平台开始接入更多渠道,每个渠道的订单接口协议都不一样,佣金计算规则也从“按比例”变成“按固定金额加比例再叠加活动返利”。每次改动都要发布整个应用,发布窗口越来越长。

第二件事,数据量开始滚雪球。订单按照“年”来累积,单表数据量在几千万级别后,查询订单日志或者跑返利补单任务时,数据库查询开始明显变慢。定时任务全在一个进程里跑,几个大任务同时执行时,接口响应时间从几百毫秒升到3秒以上。

第三件事是团队结构的变化。开发从原来2个人涨到8个人,前后端加测试大家同时在一个仓库里改代码,合并冲突越来越频繁,开发自测环境变成稀缺资源。任何一个小模块的性能抖动都可能把整个App拖下水。

2. 第一次转身:Java微服务拆分与基础组件落地

2.1 服务边界划分:按业务域拆而不是按页面拆

第一次做微服务拆分的时候,我们小组里争论最多的问题就是服务边界怎么划分。有人建议按用户端、商家端、管理后台来拆,听起来直接,但那个方案忽略了一个事实:返利计算的逻辑在三个端都得复用,按端去拆必然造成大量重复调用。

最后我们用的是领域驱动里最常见的做法——按业务能力域和服务边界拆。整个系统拆分成五个核心微服务:

  • 用户服务:负责会员注册、登录态、用户画像基础数据。
  • 商品服务:管理渠道商品映射、价格快照、活动商品位。
  • 订单服务:承接订单创建、订单状态流转、订单查询。
  • 返利计算服务:独立出来专门算佣金,根据渠道规则实时试算,也负责异步批量的对账计算。
  • 结算与提现服务:管理用户佣金账户、冻结金、流转记录和提现申请。

网关在最前面统一入口,认证、参数校验和请求转发都在网关层完成。服务之间用Feign做声明式HTTP调用,服务注册与发现引入Nacos,配置中心也用它统一管理。

这样拆完之后,每个服务的职责非常聚焦。返利计算服务单独部署,因为它是业务里CPU密集的部分,缩扩容可以和订单服务完全解耦。双11大促时,流量重点压到商品浏览和订单创建上,返利计算服务可以先慢慢异步抗。

2.2 注册发现与配置管理:Nacos的落地思考

服务注册与发现在微服务架构里是水龙头一样的基础设施,选择Nacos,主要是看中它把注册中心和配置中心做在了一起。刚开始我们的想法很简单:运维成本低,一个组件解决两件事。

Nacos落地起来比想象中要谨慎一点。首先是命名空间和分组的设计,我们一开始没有规划好,所有环境共用同一个默认命名空间,结果测试服务注册到了生产集群,排查了好几个小时才发现是命名空间串了。后来规范是:每个环境一个命名空间,每个业务域一个分组,服务名用“服务名-环境”作为后缀约束。

Java接入Nacos的坑主要在于版本兼容。我们的Spring Boot项目用了比较新的版本号,而Nacos客户端依赖的某些底层库和项目里已有的组件存在冲突。这个坑在博客里普遍被忽略,但在真实项目里很扎心——你会有种错觉是代码本身有问题,翻来覆去找半天,最后发现是依赖传递引起的jar包冲突。后面我们在工程里专门加了一层依赖统一管理,把Nacos相关的gRPC依赖强制对齐。

2.3 网关、限流与分布式事务的选择

微服务化之后,原来的Nginx直接转发肯定不够了,我们引入Spring Cloud Gateway做统一网关。网关承担了三个职责:认证拦截、基于Redis的滑动窗口限流、以及按服务名转发请求。限流粒度上,一开始做的是全局限流,后来发现某个渠道的爬虫流量会占满所有配额,给正常用户都挡住了。改造后按用户维度、渠道维度、接口维度分别设了限流Key,并给返利试算接口单独划了比较大的配额池。

最棘手的是分布式事务问题。返利链路里,订单状态流转后要记录佣金流水,而佣金流水可能跨订单服务和结算服务,如果直接跨库写,强一致很难保证。我们选了Seata做最终一致方案,用的AT模式,对整个返利流程加上全局事务。但实践之后发现AT模式在并发量上来时对数据库连接资源的占用比较明显,建议只在高可靠的资金链路上使用,像订单状态刷新这一类中间状态,用本地消息表加定时补偿反而更简单,性能开销也更可控。

2.4 微服务化初期的性能账

拆分后的第一轮效果和我们预期的有些不一样。从功能角度,服务模块独立了,团队并行的效率明显提升,发布也不需要全量回归了。但从技术性能角度,单个请求的链路变长了——用户下单从原来的“一个HTTP请求进单体”变成“网关到订单服务再到商品服务再到返利试算服务”,网络开销增加是必然的。

初期压测数据让我印象挺深:单体架构下,下单接口同一压测模型下平均RT在80ms左右;微服务化之后,接口平均RT涨到约230ms,其中三次网络往返和序列化开销占了大概100ms,剩下的压力则在数据库连接和各个服务的线程池排队上。好在我们在架构上做了一个取舍——把强一致链路刻意拉长,将大量非核心逻辑改成异步。

异步化带来了明显的性能改善。确认收货之后的返利试算不再实时阻塞主流程,订单状态消息扔到MQ里,返利计算服务消费事件再计算。这样用户看到的下单响应时间,又回到了100ms左右,但真正的返利计算变成了秒级到分钟级完成。

3. 微服务的舒适区与天花板:为什么又盯上Service Mesh

3.1 SDK升级引发全链路回归

微服务架构稳定运行一段时间后,新的痛点变得具体起来。最典型的就是SDK升级的“全链路回归”。我们的Java服务通过Feign调用其他服务,通过Sentinel做限流,通过Seata做分布式事务,这些能力都依赖Java开发框架提供的SDK。

可是当你想给这些SDK做一次版本升级时,问题来了——每个服务的代码都要跟着改。假设有35个Java微服务,每个服务都要升级Sentinel、Nacos客户端、OpenFeign的版本,即使只升级其中的某一个公共安全漏洞,也要重新构建、重新走一遍回归测试。正常迭代版本的时候,这种成本是可以接受的,但如果你一个月内要升级三个SDK,开发资源基本就耗在无关业务的事情上了。

有一次因为Tomcat和底层HTTP连接池的版本不兼容,我们所有Java服务的连接池参数都需要调整,这事情的结果是:一个小改动引发了六七个服务发布和回退,期间客户查询订单出现零星失败。SDK治理的碎片化由此成了我们最头疼的问题。

3.2 治理代码散落在业务模块里

微服务的治理功能,比如超时、重试、熔断、降级,正常都应该由框架层统一处理。但我们在实际开发里,业务开发人员经常把治理逻辑“顺手”写在业务代码里。比如某个调用失败后,开发同学就自己在Service里加了一个try-catch,catch里又做一次Feign调用,还顺带加了System.sleep做重试等待;另一个服务会在异常时返回默认空对象。这种散装治理规则,看起来解决了一些临时问题,但形成体系之后很难排查——因为你不知道某个超时是被哪段代码吞掉的。

Service Mesh的思路恰好是把这些能力下沉到独立的代理层,让微服务业务代码只管业务,治理逻辑交给配套的基础设施。对Java项目来说,这个解耦的意义特别大:减少了业务代码对框架SDK的依赖,以后升级治理能力,不需要再把所有Java服务重新构建一遍,只需要更新代理配置。

3.3 异构部署与多语言团队的现实

还有一个被很多人忽略的现实状况:团队并不完全是Java的。返利平台的运营工具后台,我们后来用了一部分Go语言开发,负责大规模数据清洗和一次性返利任务;数据分析团队使用Python处理离线数据,偶尔也需要调用Java服务的接口。Java SDK的治理能力再强,Golang和Python服务也用不上。

如果继续走SDK这条路,就得在每种语言里各写一套服务治理组件,维护成本非常高。Service Mesh用独立Sidecar代理,语言无关,这让我们把非Java服务也纳入同一套服务治理体系成为可能。统一监控、统一超时重试策略、统一路由规则,而不是每个语言自己玩一套。

3.4 Java技术栈在Mesh时代的定位

说了这么多,有人会担心一个问题:Service Mesh是不是要把Java微服务替代掉?我们实践下来的结论恰恰相反。Java最擅长的领域是业务建模和复杂事务状态机管理,这种能力在Mesh时代依然是核心。Mesh不会拿走你的Spring Boot和业务逻辑,它只把服务治理和流量管理从应用进程里挪到旁边独立的Sidecar进程里。

Java在这种架构下反而更轻松了。原来为了治理功能怎么做而踩的坑,比如Feign超时参数该传多少、线程池要不要隔离这些,都可以部分交给Mesh层做统一配置。Java服务更专注于业务实时性和一致性,把那些与业务无关的关注点释放出去。

4. 渐进式改造:以Java应用为核心落地Service Mesh

4.1 先南北向后东西向:灰度引入Mesh能力

Service Mesh的建设不能走“一夜重构”的路子,容易把系统搞崩,我们对整个改造做了两步走的灰度。

第一步,先改造南北向流量。把网关接入Mesh体系,让网关的流量通过Sidecar转发到后端微服务。这时Java应用自身并不直接接入Sidecar,老的服务调用方式不变,只是网关这一层的路由规则、超时策略统一由Mesh管理。

第二步,做东西向调用迁移。我们按服务的重要程度排了一张表,从最边缘的回单通知服务开始,逐步接入Sidecar。优先改造业务链路上比较独立、调用关系清晰的服务,等稳定之后再向订单服务、结算服务这样的核心应用推进。这样做的好处是实际风险小,出了问题我们可以按服务回滚,而不是整个集群一起回滚。

4.2 Kubernetes环境下Sidecar注入与资源规划

Sidecar注入依赖Kubernetes的Pod机制。我们为每个Java服务Pod添加Sidecar容器,业务进程不感知代理的存在,只通过固定的本机端口访问代理。这个方案最大的好处就是前面说的,Java代码完全不需要改动,只需要在部署的YAML里增加一段Sidecar声明。

但资源规划要提前做。Sidecar本身是另一个进程,每个实例会占用额外的CPU和内存。我们用了一个比较保守的起步配置:每个Sidecar预留0.25核CPU和512MB内存,压测之后动态调整。后来发现实际占用比这略低,但网络模块在高并发的时候会临时拉升CPU,预留不能卡得太死。

Java应用和Sidecar在同一个Pod里,网络经过localhost进行回环,延迟损耗可以控制在很低水平。在Kubernetes环境里要让业务容器和Sidecar的启动顺序可控,业务进程要等待Sidecar就绪再接受流量,这个依赖k8s的Readiness Probe和InitContainer机制去控制。

4.3 路由、超时、重试与熔断的配置实践

把服务接入Mesh之后,一个重要变化是原来写在Java代码里的超时和重试配置,都转移到统一的配置规则里。下面是我们当时用过的核心配置片段,可以作为参考:

apiVersion: networking.example.io/v1beta1 kind: VirtualService metadata: name: order-service-vs spec: hosts: - order-service http: - match: - sourceLabels: version: v1 route: - destination: host: order-service subset: v1 weight: 100 - retries: attempts: 3 retryOn: connect-failure, retriable-status-codes perTryTimeout: 2s timeout: 9s

这套配置解决了一个特别常见的问题——原来每个Java服务各自定义超时时间,服务A等待服务B超时了8秒,但服务A自己定义的接口超时只有5秒,反而先把自己熔断。Mesh层把所有服务纳入统一的超时预算管理,通过配置把这些数值整体拉齐,能显著减少链路中各层超时不一致带来的级联失败。

需要提醒的是,重试不能盲目加。我们在大促前有一次事故就是因为重试策略配置过猛,导致某个下游服务本来只是慢,被上游两个服务同时并发重试打爆了线程池。后来重试策略加上了DJANGO风格的限制——要求框架对每类异常设置白名单,只有幂等且明确超时的连接类错误才允许自动重试。

4.4 可观测体系与回滚预案

上了Mesh之后,可观测性是最大的受益点。原来需要每个Java服务自己打点上报的调用链,现在可以由Sidecar统一收集访问日志和指标。我们快速搭建了一套基于Prometheus的监控看板,核心指标包含Service Inbound和Outbound的QPS、P99延迟、错误码分布。由于每个Pod里都有Sidecar,我们甚至能够精确到某个服务实例的出流量延迟,这对定位下游慢调用很有用。

回滚预案其实就是保证Sidecar不成为单点。我们的策略是:Sidecar失效时,业务流量自动降级为直连原目标服务。避免Sidecar故障导致整个服务不可用。每次变更Mesh配置,先在一个边缘服务的命名空间灰度验证24小时,再推全量。包含一条铁律:线上Mesh配置变更必须有对应回滚版本,一次只改一条规则。

5. 性能权衡:单体、微服务、Mesh三阶段实测对比

5.1 压测链路设计与指标口径

我们搭建了一套独立的压测环境,用三组同样的核心链路做对比:用户查看商品详情并下单,包括登录校验、拉取商品信息、创建订单、触发返利试算、返回下单结果。压测工具选择JMeter,请求模型是阶梯加压,从500并发逐步上升到2000并发,每档运行5分钟,统计稳定期的均值。

指标只用四个:平均响应时间、P99延迟、QPS峰值、每台实例的CPU和内存占用。另外观察发布效率和开发迭代节奏作为定性指标,毕竟架构选择的收益很多不在压测数据里。

5.2 三组实验结果:QPS、延迟、CPU与内存

架构阶段平均RT(ms)P99 RT(ms)QPS峰值(单实例)CPU占用内存占用(JVM持有)
单体8018768072%2.1G
微服务(Feign调用)23041252061%各服务累计4.3G
微服务+异步化(无Mesh)9621091048%各服务累计3.6G
微服务+Service Mesh11223687051%各服务累计4.0G

从数据可以清晰看出:引入Mesh比纯异步微服务的RT高大概15~20ms,这主要来自Sidecar的代理转发。但相比微服务初期没有治理规则的情况,Mesh后的P99延迟反而比Feign直接调用好,原因在于统一的重试和超时策略降低了级联失败,P99长尾被明显收窄了。

另一个值得注意的点是QPS。加入Sidecar之后单实例QPS峰值略微下降,但还不够显著。主要原因是我们压测的瓶颈其实在下游MySQL和Redis,Sidecar并非首要瓶颈。如果把数据库换成更强配置,Mesh本身的损耗比例会更清晰。

5.3 从成本收益看架构演进

真要算总账,不能只看压测数字,更要看运维收益和发布效率。单体阶段一次发布影响全量,发版时间从准备到稳定需要差不多半天;微服务化后,我们按服务独立发布,单个服务一次发版平均只需要十来分钟,但SDK升级回导致批量回归,有时平台性的调整需要花费一周做跨服务协调。Mesh把治理能力统一之后,后续基础组件升级大部分通过配置调整完成,业务服务的代码不需要来回改动。

成本也很现实。引入Mesh让集群里多了一批Sidecar进程,我们的Kubernetes节点规模从原来十几个增加到二十多个;运维同学需要学习新的配置模型和排障思路。如果团队没有容器化基础,没有专门的SRE精力,我反而建议不要直接上Mesh。

5.4 权衡后我们留下的经验

经过这一轮比较,我们沉淀了几条比较笃定的原则:

  • 核心业务链路优先采用异步化和事件驱动的设计。这和Mesh不矛盾,实际上Mesh解决的是链路治理,事件驱动解决的是链路解耦,两者加成之后效果最佳。
  • Sidecar为“可治理性”买单。如果服务只有两三个,单独维护SDK更划算;当服务超过二十个且由不同团队维护时,统一治理的收益才会显现。
  • 引入Mesh后,保存一份可回退的直连模式配置非常重要。我们在线上做流量切换时始终留有一条“不经过Sidecar”的逃生通道,被验证过好几次能救命。

6. 架构演进中无法回避的“人”的问题

技术方案聊到最后,绕不开团队能力。单体架构不需要懂容器编排,微服务勉强强一点也还能应付;但Mesh要求团队必须熟悉Kubernetes网络模型和流量管理概念。我们在落地过程中专门用节省出来的运维时间,做了三轮面向全员的架构原理培训。内容不深,只要求大家理解五个问题:Sidecar是什么、服务的流量入口在哪、路由规则在哪里定义、超时重试为何集中管理、Mesh宕机了服务会怎样。

事实证明,这轮培训比任何一套监控系统都重要。很多线上故障并非Sidecar本身出错,而是业务同学以为Sidecar会兜底,结果把原本应该做在应用层的幂等处理也省了。架构下沉不等于业务责任下沉——Mesh可以管理流量,但它不会帮你校验参数,更不会接管业务状态的一致性。

如果你也准备走这条路,我给一个最实际的建议:先让团队用两个星期时间做一个不需要上生产的模拟项目。把马来(Java)服务接入Sidecar,配置一个虚拟的流量路由,让每个开发独立操作一遍。等所有人都对“流量从哪进、从哪出”有了直观感受,再谈全量切换。

我最后的一点实际操作体会

回头看整条演进曲线,很多决定在当时看起来是“技术选择”,放到时间线上其实是业务节奏的产物。单体阶段的快速试错能力,微服务阶段的团队并行能力,以及Mesh阶段的集中治理能力,每一层都在上一层的代价出让后获得新的收益。这不是一条“越先进越好”的路,而是“越匹配当前规模越好”的路。

我的体会是,如果不是订单和返利链路对最终一致性要求极高,我们必须花那么多精力在事务和补偿上,其实完全可以把Mesh改造放在更晚的节点。而如果你已经有了三五个Java服务,老老实实单体加缓存加MQ可能才是最舒服的状态。千万不要为了技术而架构升级,先问清楚自己的痛点到底在哪,再决定要不要把这一层复杂度请进来。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进