如果你所在的电商团队,手头维护着一个同时承载SPS商家服务和CPS联盟推广结算的Java单体项目,而且最近每次上线都像拆炸弹——改了商家入驻的代码,结果CPS归因那边跟着报错,数据库里一张订单表被十几个服务拽着不放,那这篇文章你大概率用得上。
今天想聊的,是这种SPS/CPS混合单体在向Java微服务拆分演进时,最容易被忽略也最致命的一环:边界怎么定,以及边界定完之后,Java工程里那些几乎每天都会踩的实操坑该怎么绕。先说明白,我讲的是实操技巧,不是架构PPT——该画的边界会画,该给的代码会给你看,该避的坑一个不落。适合正在做服务化改造的Java后端开发者和技术负责人看,也适合想搞懂微服务拆分底层逻辑的同学当作一份拆解前手记来读。
我在实际项目里见过太多团队,把Spring Cloud全家桶装起来,Nacos、Feign、Sentinel该上全上,结果服务拆了二十个,线上事故反而翻倍。问题基本都出在两个地方:一是边界根本没想清楚,按Controller拆、按表拆、按心情拆;二是拆完以后,分布式环境下的事务、锁、序列化、日志追踪这些Java基本功没跟上。所以下面这套拆法,不是我拍脑袋总结的,是踩过坑之后复盘出来的。
1. 为什么要拆:SPS/CPS单体应用的失控现场
1.1 从“能跑就行”到“大泥球”
大部分电商平台的SPS(商家/供应商绩效与商品管理)和CPS(按效果付费的联盟推广结算)系统,早期都长在同一个Java单体应用里。一开始业务简单,一个订单进来,商家那边要扣库存,推广那边要记佣金,同一个事务里顺手写完,逻辑链路短,部署也省事。但业务跑两三年以后,问题就藏不住了。
我接手那会儿,这个单体项目大概有四十多万行Java代码,三百多张表,WAR包扔在Tomcat里,启动要五六分钟。SPS的商家资质审核和CPS的推广链接点击统计,从代码上看起来井水不犯河水,实际上共用了一套会员体系、一套订单表、一套支付回调逻辑。每次需求排期,只要有人动订单相关的Service,两边测试都得回归一遍,因为谁也不知道这段改动会顺着哪个函数引用捅到哪儿去。
这种状态有个很形象的名字,叫大泥球。不是代码写得烂,而是业务边界长期没有梳理,模块之间像藤蔓一样缠在一起。这时候谈微服务拆分,不是赶时髦,是结构性问题逼着你必须动手。
1.2 哪些信号说明必须拆了
不是所有单体都该拆。如果团队五个人,业务规模一天几千单,老老实实把单体代码写干净比什么都强。但SPS/CPS混合系统一旦出现下面几个信号,基本就到了拆分窗口期:
- 发布互相牵连。SPS新增一个商品字段,结果CPS结算那边因为依赖同一个工具类,也要跟着一起发版。
- 数据库耦合严重。一张订单扩展表上挂着商家ID、推广位ID、媒体主ID、结算状态,报表SQL跨域join几十行,慢查询把CPU打满,连带着CPS的点击计费也跟着延迟。
- 团队协作互相踩脚。SPS组和CPS组改同一个Git仓库,每天最大的成本是解决合并冲突,而不是写业务代码。
- 硬件资源没法分级保障。SPS那边跑一个绩效大报表,直接把机器内存吃满,CPS这边的实时点击统计跟着一起遭殃。拆开以后,重资源、高并发的服务可以独立扩容,这才是微服务最大的红利之一。
这里多说一句,我在不少Java交流群里看到有人问“我们应用有二十万行代码,该不该拆”,其实代码行数不是核心指标,核心指标是变更频率和故障半径。一个模块每天被三个团队改、每次改都要全量回归,不管多少行代码,都该拆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 边界界定:先把SPS和CPS的业务地图画出来
2.1 SPS/CPS到底是什么,各自管哪些事
做微服务拆分,第一件事不是打开IDEA准备抽代码,而是跟产品和运营坐在一起,把业务地图画出来。我在文章里说的SPS,指商家/供应商的绩效与商品管理相关子系统,CPS指联盟推广按效果付费的结算系统,也就是大家常说的淘宝客类分佣体系。这两块业务天然分属两个不同的业务域,是拆分的“第一刀”最合适的切入点。
拿我们当时的梳理结果举例,两边的核心域和表归属是这样切的:
| 业务域 | 核心子域 | 典型数据表 | 未来候选服务 |
|---|---|---|---|
| SPS | 商家入驻、资质审核、商品管理、绩效看板 | 商家表、资质表、商品表、月度绩效表 | 商家服务、商品服务、资质服务、看板服务 |
| CPS | 媒体主管理、推广位/短链、点击追踪、订单归因、佣金结算、对账 | 媒体主表、链接表、点击流水、归因结果表、佣金账单表 | 媒体服务、链接服务、归因服务、结算服务、对账服务 |
这张表看着简单,但它是后面所有拆分动作的锚点。每个表都必须有明确的业务域归属,不允许出现“这张表两个域都在用,先留着公共”的模糊地带。我们当时定了一条硬规矩:跨域场景一律通过接口访问,不允许直接查对方库表。
2.2 判定服务边界的三个黄金问题
边界定得准不准,可以用三个问题自测。团队里每个人对这三个问题给出不同答案,那说明边界还没理清,先别急着动手。
第一个问题:这份数据到底是谁的?数据归谁,谁就有权修改和对外提供访问接口。比如订单归因结果表,虽然数据源头是订单域,但“媒体点击产生的归因关系”是CPS域的核心资产,所以归因结果的写权力必须归CPS归因服务,SPS要查归因结果只能走API。
第二个问题:一个需求变更的驱动源在哪个域?如果改的是一个“推广链接失效后商家商品下架”的需求,核心流程在SPS商品服务,但需要CPS链接服务通知驱动,那么两个服务之间通过事件解耦,而不是把商品下架的Service直接引用到CPS代码里。谁的业务生命周期更长,谁就是数据主域,另一方只做订阅。
第三个问题:这个服务挂了,影响谁?CPS结算服务挂掉,影响的是一批媒体主当天不能提现;SPS商品服务挂掉,影响的是商家后台商品列表。两者的SLA要求完全不一样。把这两个服务放进同一个发布单元,等于让它们互相拖累,这本身就是不合理的。
2.3 第一刀怎么切:按业务能力拆,不按代码分层拆
很多Java团队拆微服务有个误区,喜欢把单体里的Controller、Service、Mapper各抽一层,形成“Controller服务”“Service服务”“DAO服务”。这种拆法看着像微服务,其实是把同一个业务需求打散到多个部署单元里,一个下单请求要串联五六个服务,性能差、事务不可控、排错想哭,最后还得靠分布式事务把数据硬凑回来。
正确的第一刀应该按业务能力拆,也就是业界常说的限界上下文。SPS域内部可以再切商家入驻、商品/品牌管理、资质审核、绩效看板;CPS域内部可以再切媒体主管理、推广链接/短链、点击追踪、订单归因、佣金结算和对账。每个能力都能独立描述业务、独立部署、独立演进,这才是微服务的边界。
当时我们内部吵得最凶的一个问题是“SPS绩效看板”到底算哪个域。它本身只做只读报表,但又依赖商品、订单、结算数据。最后定的方案是:看板服务作为SPS域的读模型服务,数据通过事件同步到独立的报表库,底层不依赖CPS结算服务的实时接口。这其实就是微服务里常见的CQRS思想,读模型和写模型分离。
3. 实操路线:用“绞杀者模式”完成Java微服务拆分
3.1 拆分之前必须做好的三件事
拆分不是一天能搞完的,我强烈推荐绞杀者模式:老的单体继续对外提供服务的同时,把新服务一点点长出来,流量逐步切换,最后老代码自然淘汰。这个模式对电商这种7x24小时在线的系统最友好。
动手前,至少要完成三件事:
第一,依赖分析。把现有代码里SPS和CPS之间的类依赖、方法调用、Spring Bean注入全部列出来。IDE自带依赖分析不好用就写脚本扫,或者用ArchUnit这种工具做架构约束测试。重点看反向依赖:SPS的代码里到底有多少处直接调用了CPS的Service,这些调用点将来全部要改成HTTP或消息。
第二,数据资产梳理。把数据库里每一张表的读取方和写入方列出来。这里有个很容易被忽视的坑:很多表看着是SPS在用,但CPS的历史报表SQL一直在join它,拆库的时候必须先把这些隐藏的跨域查询找出来,要么让对账服务订阅数据同步一份,要么先通知业务方改接口,否则拆完数据库一分离,线上报表直接挂掉。
第三,接口契约先行。新服务对外提供的接口,先定好OpenAPI/Swagger文档,再动代码。接口字段名、类型、必填项、错误码都要对齐。Java这边尤其要确认DTO里的时间类型是LocalDateTime还是Date,long类型字段传到前端会不会精度丢失,这些后面细说。
3.2 第一步:先拆只读服务,降低第一刀的风险
第一刀建议挑风险最低的只读服务下手。我们当时最先拆的是CPS数据看板服务,它只读不写,拆出去最多就是页面暂时刷不出来,不会造成资金数据错误。
具体做法是:在CPS域里新建一个cps-report服务,把点击趋势、佣金预估、媒体主效果报表相关的查询逻辑全部搬过去。报表数据不再直连老库,而是通过监听订单、点击、结算等业务表的Binlog/消息事件,异步同步到独立的报表库。过渡期允许它读老库的只读副本,但新数据一律通过事件源同步。
这个阶段验证成功的标准很简单:报表服务独立启动、独立部署,SPS和CPS发布时不再需要带它,线上报表数据延迟在可接受范围内。走完这一步,团队对“拆服务”这件事就有了信心,后面动写服务的时候不会那么慌。
3.3 第二步:再拆写服务,双写与流量切换
只读服务跑顺以后,就可以拆写链路了。我们这轮挑的是CPS订单归因服务,因为它是CPS域的核心写入口——点击流水进来以后,要和订单数据做匹配,算出“这笔订单归因给哪个媒体主”。它和SPS那边几乎没有直接写耦合,风险相对可控。
拆写服务最稳妥的方案是双写加异步校验。老逻辑继续写老表,新服务同时把归因结果写到新表,通过MQ发一条事件,对账任务定期比对两边数据。等两边数据连续稳定一致N天,再把线上流量通过开关切到新服务,老代码只保留一个降级开关,最后再清掉。
这里有个Java侧很容易踩的坑:双写阶段,新服务里一定不要跟老服务共用一个数据源,否则你分库分了个寂寞。新服务必须连自己独立的库,哪怕物理上还在同一台数据库实例上,逻辑上也要先分库。等稳定以后再把物理库拆出去,风险最小。
3.4 第三步:清理老代码与数据库解耦
流量切换完成不代表拆分结束,后面还有一堆收尾活:删掉老服务里已经被替代的Controller、Service、Mapper,清理不再使用的Spring Bean,修改老单体的启动配置,把对老表的写入权限收回。这一阶段最容易被拖延,但一定要顶住压力做干净,否则老代码留在那里,下个季度又有人“临时复用”一下,拆分的成果就白费了。
数据库解耦也一样。我们当时的作法是每拆完一个服务,就做一次表归属巡检,凡是出现两个服务同时写同一张表的情况,立刻拉到周会上暴露问题,定出owner表或拆表方案。给表定owner这件事,比写代码难多了,但它是边界真正落地的地方。
4. Java生态下的拆分技术细节与避坑指南
4.1 RPC与注册中心选型:Spring Cloud Alibaba/Nacos + OpenFeign
微服务拆完,服务之间通信是第一个技术选型问题。网上关于Dubbo和Spring Cloud的争论一直很多,就我所在的SPS/CPS这种以HTTP语义为主、Spring生态相对统一的电商后台系统来说,我推荐Spring Cloud Alibaba + Nacos + OpenFeign。理由不复杂:团队对Spring MVC最熟,OpenFeign的接口写法跟写本地接口很像,学习成本低;Nacos同时解决了注册中心和配置中心,小团队少维护一套组件;Feign底层基于JDK动态代理生成HTTP客户端,你跟面试官聊动态代理的时候其实天天都在用。
OpenFeign接口定义,我们当时长这样:
java复制@FeignClient(name = "cps-settle-service", contextId = "settleClient")
public interface SettleFeignClient {
@PostMapping("/api/settle/order/{orderId}")
Result<Void> reportOrder(@PathVariable("orderId") String orderId,
@RequestBody OrderSettleRequest request);
}
注意这个FeignClient的name是注册到Nacos里的服务名,不能带下划线,必须小写。曾经有同事把服务名写成cps_settle_service,注册倒是成功了,但服务间调用时负载均衡老是找不到实例,排查半天,就是命名规范的问题。
选型时还要考虑团队对线程池、超时、重试的掌控力。Feign默认在调用超时时不会重试,但底层Ribbon负载均衡在某些版本里会对同一请求自动重试,如果接口不是幂等的,重试可能造成重复下单或重复结算。所以接结算这种资金类接口,一定要把重试策略关掉,幂等由业务侧自己保证。
4.2 分布式事务与幂等:CPS佣金计算的最终一致性
拆微服务以后,原来单体里一个@Transactional能搞定的事,现在跨了服务、跨了库,本地事务彻底失效。CPS里的典型场景是:订单归因成功 → 更新佣金明细 → 给媒体主账户加余额。这三个操作如果强行放在一个分布式事务里,不仅性能差,而且任何一个参与者抖动,整个链路都要回滚,用户体感就是“推广订单一直结算不出来”。
我们用的方案是最终一致性:订单归因成功后发一条事务消息,结算服务消费消息后写佣金明细,再发消息给账户服务加余额。中间任何一步失败,都由MQ重试加对账任务兜底。状态用枚举状态机管理,避免到处if/else判断状态流转:
java复制public enum SettleStatus {
WAIT_ATTRIBUTE(0, "待归因"),
ATTRIBUTED(1, "已归因"),
WAIT_SETTLE(2, "待结算"),
SETTLED(3, "已结算"),
FAILED(9, "结算失败");
}
这里要特别强调幂等设计。消息重试、HTTP重试、用户手动触发对账,任何一个入口都可能把同一笔结算请求送进来。我们当时的做法是,每一笔业务带一个全局唯一的业务单号(bizId),在结算流水表上建唯一索引,数据库层直接挡住重复插入。只要底层幂等兜住,上层随便重试都不怕。
4.3 Redis计数、分布式锁与那些“经典错误”
CPS系统里高频点击和PV/UV统计,Redis是少不了的。而SPS的绩效看板、商家余额展示也会用到缓存。这里有个Java开发几乎都踩过的坑,就是Redistemplate的increment()返回值类型问题。很多同学这样写:
java复制// 错误写法
Integer clickCount = redisTemplate.opsForValue()
.increment("cps:link:click:" + linkId);
然后运行时直接报类型转换异常,因为increment()返回的是Long,不是Integer。Redis自身的incr命令对value还有个限制:如果key已经存在但value不是整数,会直接报“value is not an integer or out of range”。所以用Redis做计数器,一定要约定好key的初始化方式,别拿它存字符串。正确写法:
java复制Long clickCount = redisTemplate.opsForValue()
.increment("cps:link:click:" + linkId);
再说分布式锁。CPS的订单回调、提现接口,都要求同一笔单不能被并发处理。单体时代写个synchronized就行,拆成微服务以后,多个实例同时收到请求,本地锁锁不住别家的JVM。我们用的是Redisson的可重入锁:
java复制RLock lock = redissonClient.getLock("cps:order:lock:" + orderId);
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BizException("订单处理中,请勿重复提交");
}
注意锁的key要设计好粒度,能用orderId就别用媒体主ID,粒度太粗会串行化整个媒体主的请求,性能扛不住。
4.4 跨服务接口的序列化坑:Bean命名、Map滥用、枚举丢失
拆完服务以后,接口联调的工作量翻倍,原因就是序列化问题。最经典的是Java Bean属性名第一个字母大写,序列化成JSON时字段名变了。比如有个媒体参数类的字段叫KUID,Java里getter是getKUID,Jackson序列化时默认会把getter后面的字母小写,结果前端收到kuid,接口文档里写的却是KUID,两边对着半天字段,浪费一下午。解决办法是用@JsonProperty显式指定序列化名称:
java复制public class MediaParam {
@JsonProperty("KUID")
private String KUID;
}
另一个坑是在接口里滥用Map。我见过不少老代码喜欢返回Map<String, Object>,觉得灵活,结果拆成服务以后,调用方根本不知道Map里到底有哪些key,里面值的类型是String还是Long,文档也不写,全靠猜。微服务环境下,接口就是团队之间的契约,强类型DTO比Map可靠得多。哪怕多写几个类,也比联调时排错强。
还有LocalDateTime序列化问题。接口返回给前端的时间,如果没配@JsonFormat或全局的Jackson配置,不同服务之间可能一个返回数组,一个返回字符串,前端解析直接炸。我们当时统一了全局序列化配置,时间全部输出成yyyy-MM-dd HH:mm:ss字符串,Long型ID在给到前端时统一转成String,防止JS精度丢失。
4.5 日志、TraceId与“问题排查半小时地狱”
拆成微服务以后,查一个问题要从A服务日志翻到B服务日志,如果没有一个贯穿全链路的TraceId,排查线上问题就是地狱。我们的做法是,在网关和MQ消费者入口生成一个traceId,塞进MDC里,再通过Feign的RequestInterceptor把traceId放到HTTP Header传给下游。
java复制public class TraceIdRequestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String traceId = MDC.get("traceId");
if (traceId != null) {
template.header("X-Trace-Id", traceId);
}
}
}
下游服务在Filter里读取这个Header,重新放入MDC,这样一次请求从SPS入口到CPS结算,所有日志都能用同一个traceId串起来。没有这套东西,你拆完服务会发现一个简单问题要查半小时,有了它,基本是秒级定位。这一步一定要在拆服务的同时做,千万别等到出事了再补。
5. 常见问题与排查技巧实录
5.1 现象一:服务拆完了,性能反而更差了
拆完以后发现接口变慢,最常见的元凶就是循环调用Feign。列表页一次性查100条商品,代码里for循环一条一条去调商品服务,每次都是网络开销,100次调用加起来几百毫秒,比原来单体内一次联表查询慢得多。
解法是批量接口。原来单个查询接口保留给详情页用,列表页用一个批量查询接口一次传入ID列表,服务端用IN查询一次性返回。Java这边也可以结合Lambda的stream把结果分组,减少重复调用。拆服务以后,API设计要考虑调用方的使用场景,不能简单地把原来的方法平移成HTTP接口。
5.2 现象二:数据对不上账,订单与佣金金额不一致
CPS结算最怕的就是对不上账。我们遇到过一例:订单归因服务消费MQ消息时,偶发消费失败但没进入重试队列,导致佣金明细少了一条。对账任务每天凌晨跑,比对订单表里的“已支付订单ID”和佣金明细里的“订单ID”,发现差异后自动发告警。因为幂等键和唯一索引都在,补跑脚本把缺失的明细重新消费一遍,金额就对上了。
我的经验是,资金类链路一定要有三层防线:第一层是接口幂等,第二层是MQ重试加本地消息表,第三层是离线对账。前面两层全挂了,最后一层兜底也能发现并修正问题。
5.3 现象三:@Transactional“神秘失效”,事务到底管到哪
拆服务以后,很多人发现原来单体里好使的@Transactional开始“失灵”了。其实多半不是Spring Bug,而是两类情况:一是多数据源场景下,Spring声明式事务默认只管理主数据源,跨数据源的操作不会在同一个本地事务里;二是同类内自调用,一个Service方法this调用另一个带@Transactional的方法,代理不生效,事务直接废掉。
所以拆完以后,每个服务内要明确事务边界:只在自己的服务里管本地事务,跨服务通过消息和补偿机制保证最终一致性,而不是指望一个注解解决所有问题。这个道理讲起来简单,但真是每个拆微服务的团队都要趟一遍的坑。
5.4 现象四:发版后Consumer调用404、超时
服务拆完上线,消费者调用新服务偶尔404或超时。多数情况是这几个原因:服务没注册到Nacos的正确namespace、服务名大小写不一致、Feign接口路径与Controller的RequestMapping不匹配。排查思路是先看Nacos控制台里服务是否在线,再看调用方日志里负载均衡有没有选到实例,最后用curl或者Postman直连一下新服务的接口地址,确认HTTP层是否通。
分布式环境下的故障不会只出现一次,建议把这些排查命令和步骤写进团队的运维手册。每次发版后的冒烟测试脚本里,把核心链路的跨服务调用都覆盖一遍,能省掉后面大量救火时间。
| 问题现象 | 可能原因 | 排查方式 | 解决建议 |
|---|---|---|---|
| 服务间调用404 | 服务名大小写/namespace不一致 | Nacos控制台检查实例列表 | 统一命名规范,接口先行 |
| 列表接口严重变慢 | 循环Feign调用 | 抓取调用链看外部调用次数 | 提供批量接口,禁止for循环调Feign |
| 佣金明细缺失 | 消息消费失败未重试 | 对账任务扫描差异 | 本地消息表+离线对账兜底 |
| 事务不生效 | 自调用或跨数据源 | 查看代理日志 | 服务内管本地事务,跨服务用最终一致性 |
| Redis incr报类型异常 | increment返回值用Integer接收 | 看异常堆栈 | 用Long接收,初始化时保证value是整数 |
| 前端收到ID精度丢失 | Long型雪花ID转JSON | 看接口返回 | 序列化时Long转String |
5.5 拆分效果度量:用数据判断这次拆分值不值
拆分是一个长期工程,不能凭感觉评估效果。我们当时拆分前后跟踪了几个指标:每次发布的平均耗时、单次发布影响的业务范围、线上故障平均恢复时间、需求从排期到上线的周期、核心接口的TP99延迟。拆完每个服务,就把这些指标拉一次对比,用数据判断这次拆分到底是赚了还是亏了。
拿发布耗时来说,单体时代一次全量发布经常要挑凌晨,SPS和CPS一起更,耗时四十分钟,出了问题只能集体回滚。拆完以后,SPS商品服务单独发布只要五分钟,CPS结算服务单独发布也不用等商家那边。单这个收益,就足够让团队对拆分这件事有信心。
6. 最后想说的三件事
第一,拆分这件事,本质是管理复杂度,不是安装微服务组件。如果没有配套的监控、链路追踪、日志平台、对账机制,拆完以后的日子只会比单体更难过。什么时候开拆、先拆谁,都要看团队当前最痛的点在哪里,而不是照着别人的架构图抄作业。
第二,数据边界比接口边界难十倍。接口边界定义错了,改个Feign接口就行;数据边界没理清,拆完数据库还是混在一起,你永远在解决慢查询和脏数据。所以拆之前,舍得花时间跟产品和运营把表归属盘清楚,这个时间花得最值。
第三,团队认知统一比技术方案更重要。微服务拆分不是架构师的独角戏,SPS组、CPS组、测试、运维都要理解为什么要拆、怎么拆、拆完以后各自负责什么。我们后来定了一条玩法:每个服务指定一个Owner,Owner对服务的代码质量、数据归属和线上稳定负责。没有这个责任制,拆完的服务很快就会变成新的泥球。
每次拆完一个服务,看到发布日志上那行“本次仅发布cps-settle-service”,团队成员终于不用凌晨守着全量发布,我觉得这活儿就值了。微服务拆分没有银弹,边界想清楚,数据分干净,Java基本功跟上,剩下的就是按部就班地推进。希望这篇实操笔记,能让你少踩几个我们踩过的坑。
