我们团队在决定对系统做微服务拆分时,第一个被拎出来开刀的不是用户中心,也不是订单系统,而是看上去结构最简单的购物车模块。当时的理由很直接:购物车承载着几乎所有核心流程的入口流量,但它对数据一致性的要求又相对“宽容”,非常适合作为团队练兵和验证拆分方案的首个试点。这篇文章就把我们拆分购物车微服务的完整思路、踩过的坑、以及沉淀下来的设计细节整理一遍,给准备动手做拆分的团队一个可参考的样本。
1. 拆分之前:购物车为什么值得单独成一个服务
1.1 先想清楚边界,再谈技术选型
很多团队拆微服务时容易犯一个毛病:拿到一个模块就直接开拆,拆到一半才发现业务边界是糊的,最后无非是酿成了又一个大泥球。我们当时首先坐下来画了一张老系统的调用关系图,重点标出了购物车相关请求的走向。
单体架构里购物车模块做的事非常杂:读取 Cookie 或会话信息、查询商品最新价格和库存、计算优惠、甚至还要在用户结算时马上把数据同步给订单系统。表面上看它是“个小模块”,实际上却隐含着巨大的耦合面,因为它夹在商品、营销、订单、库存四个中心之间。既然耦合面这么大,把它独立出来的价值就显而易见了:它可以单独扩容应对大促流量,可以单独迭代不影响其他链路,也可以单独把那些“脏逻辑”隔离起来治理。
1.2 购物车适合做切分的三个信号
我当时判断一个模块适不适合做微服务拆分,主要看三个信号。第一个信号是变化频率是否独立,购物车模块的需求变更通常来自营销玩法或者前端交互,频率高但涉及面窄,非常适合独立发布。第二个信号是资源消耗特征是否明显,购物车的读写比例严重偏向读,而且热点商品和热点用户的请求极度集中,拆出来后能针对性地调整缓存策略和限流阈值,不用再看其他模块的脸色。第三个信号是团队协作是否有瓶颈,单体仓库里购物车代码和订单代码往往纠缠在一起,两个团队同时提交时冲突不断,拆开之后各自维护自己的模型和接口,并行开发顺畅得多。
这三个信号我们全中,所以就定了先拆分购物车这个决定。
1.3 拆分后的整体视图
拆分后的购物车微服务大致承担这几类职责:购物车数据的存取、商品信息的轻量冗余与刷新、价格和库存的实时校验、与订单服务之间的数据交接、以及面向 C 端 App 和 H5 的统一接口。它和商品服务之间通过接口服务获取商品详情和价格快照,和订单服务之间通过领域事件或者同步接口传递“已选商品清单”,和用户服务之间校验登录态,和营销服务之间获取优惠信息。
这里面最重要的一条约束是:购物车服务自己不直接访问商品数据库,也不直接写订单数据库,所有跨服务的数据查询一律走接口,所有需要强一致的数据变更一律走异步事件或者分布式事务协议。这条约束帮我们挡住了后面很多因为 Domian 模型越界导致的连环问题。
2. 拆购物车服务前必须定的三件事
2.1 购物车数据到底放哪:Redis 还是数据库
这是每次分享都会被问到的问题。很多团队对购物车数据的存储方案非常纠结,其实核心要看两点:一是用户对购物车数据的强一致诉求有多高,二是丢失购物车的代价有多大。
我们最终的方案是“Redis 做主存储 + 数据库做冷备恢复”。用户在购物车里的增删改操作直接落在 Redis 的 Hash 结构上,一个用户对应一个 Key,字段是商品 SKU ID,值是商品数量和时间戳。之所以这么设计,是因为购物车的实时性要求极高,用户在 App 上每点一次“加购”按钮都期望立刻看到结果,数据库的写入延迟难以满足这种体验,而 Redis 的响应时间能稳定在毫秒级。
但纯 Redis 又不行,像用户误删 App 导致本地缓存丢失、换设备登录、Redis 集群故障等场景,必须有兜底手段。我们设计了定时快照任务,每隔半小时把热用户的购物车全量异步写到数据库的备份表里,用户在登录新设备时可以从备份中恢复购物车。数据库在这里的角色不是主存储,而是“保险柜”。
2.2 会话与用户身份识别方案
拆分前购物车模块主要靠单体内的 Session 机制识别用户,服务拆出去之后必须重新设计身份传递链路。我们要解决的是两个问题:怎么知道请求来自哪个用户,以及怎么在多个服务之间传递用户身份。
方案上有两个主流选择:JWT 无状态令牌或者 Redis 会话。考虑到购物车服务本身对性能敏感,而且我们需要在网关层统一校验登录态,我们选择了 JWT 加 Redis 白名单结合的方式。网关负责校验 JWT 签名,校验通过后把用户 ID 放进请求头向下游传递,购物车服务不做重复鉴权,只信任网关传递过来的用户身份。
这里有个容易踩的细节:JWT 一旦签发就无法主动失效,用户主动退出登录时,如果不清除令牌,购物车接口依然可以被访问。所以我们额外引入了 Redis 令牌版本号机制,每次用户退出或修改密码就让版本号失效,网关校验 JWT 时同时比对版本号,达到“主动踢人”的效果。
2.3 接口协议与数据模型的改动
服务拆出去后,从前端视角看到的接口肯定不能和原来单体内的内部方法一模一样。我们对购物车服务的所有对外接口都做了 REST 化改造,统一返回结构,所有时间字段统一为毫秒级时间戳,所有金额字段统一为“分”单位的整数。
数据模型方面也做了清理。原来单体内的购物车实体包含了商品图片、商品标题、价格等一大堆冗余字段,但在微服务架构里这些数据应该由商品服务动态提供。我们最终让购物车表只保留 SKU ID、数量、加购时间、选中状态这些核心字段,商品详情和价格则通过 OpenFeign 调用商品服务实时获取。
代价也明确:每一次购物车列表查询都变成一次“购物车服务 + 商品服务”的聚合查询,对商品服务的接口性能要求更高了。不过我们后来又单独给商品详情做了本地缓存,接口压力其实可控,这是后话。
3. 购物车微服务的核心模块设计与代码落地
3.1 领域模型该怎么划分
购物车服务内部的代码结构,我强烈建议按领域模型而不是按技术架构来分包。我们划分了这几个主要领域对象:Cart 聚合(购物车整体)、CartItem(购物车条目)、SkuSnapshot(商品快照)、CartCheckoutEvent(结算事件)。
Cart 是整个购物车的聚合根,所有对 CartItem 的操作都必须经过 Cart 的领域方法,不允许在应用服务层直接修改 CartItem 的属性。举个例子,用户把某个商品的数量从 2 改成 5,应用层只能调用cart.changeQuantity(skuId, 5)这样的方法,由聚合根内部去校验数量上限、库存状态和限购规则,然后再把最终结果返回给前端。
这个设计的好处是后续如果要在加购逻辑中加入更复杂的营销规则(比如满减、多买优惠),只需要改动聚合根内部逻辑,而不需要改动上层接口。我们在项目后期深有体会,营销规则实验几乎每周都在变,如果逻辑散落在各个 Controller 里,那就是一场灾难。
3.2 核心接口的实现细节
第一个核心接口是添加商品到购物车。整个流程分成三步:第一步从请求里解析用户身份并校验令牌有效性;第二步调用商品服务查询当前商品的实时状态(是否上架、是否删除、当前价格),如果商品不可售则直接拦截;第三步以用户 ID 和 SKU ID 为维度,到 Redis 里执行 HINCRBY 操作累计数量。
第二步的实时查询不能漏,否则会出现商品已经下架但购物车里还能结算的脏数据。我们在商品服务侧维护了商品的上下架状态,购物车服务每次加购都实时获取,同时本地维护一个短时间窗口的缓存来削峰,缓存时间控制在 30 秒左右。
第二个核心接口是修改购物车商品数量。这里的难点是容易产生并发问题,用户连续点击加号按钮时,请求达到服务端是并发的,如果采用“读 - 修改 - 写”的方式,肯定会出现数量覆盖丢失。我们的方案是直接用 Redis 的 HINCRBY 原子操作,增量和减量都用这个指令实现,通过传入正负数完成。这样即使并发请求到达,Redis 内部也会串行执行,避免覆盖问题。
第三个核心接口是获取购物车列表。Redis 里至少存了两份数据结构:一份是购物车原始数据(Hash),一份是购物车选中状态标记(Set)。查列表时要做三步操作:读取 Hash 全量数据,读取 Set 中的选中 SKU 集合,然后过滤出选中状态的条目并装配商品详情数据。最终返回购物车各个条目的商品信息、数量、即时价格、库存状态,如果某个商品库存不足以支持当前数量,返回结果里要额外标识“库存不足”状态,方便前端做出提示。
3.3 价格到底要不要在购物车服务里存储
之前我们聊到购物车服务的数据模型清了冗余,但价格字段是一个特例,需要仔细权衡。我在拆分过程中对这个问题反复纠结过,最后得出的结论是:购物车服务里应该保存一份加购时刻的价格快照,同时展示最新价格。
为什么需要价格快照?因为用户把商品加进购物车后,商品可能涨价也可能降价,如果购物车不保存任何价格信息,用户在结算前看到的价格全是动态查询的结果,一旦商品服务出现波动,购物车页面可能直接挂掉。更重要的原因是,用户在加购那一刻看到的商品价格,本质上是他形成购买预期的基础,而降价促销和改价后重新计算是另一套逻辑。
我们的实现方式是把加购成功的价格写入 Redis 的 Hash 中,同时在购物车列表接口中同时返回“加购价格”和“当前实时价格”,前端根据业务规则决定展示哪个价格。结算时则强制使用实时价格从商品服务重新获取,并校验价格变动幅度,如果价格变动超过用户加购时的 10%,需在确认页提示价格变化,避免结算金额与用户预期偏差过大。
4. 微服务拆分后,购物车与其他服务的协作链路
4.1 商品服务依赖与本地缓存策略
购物车服务对商品服务的调用主要发生在两个场景:加购时获取商品实时状态、以及列表展示时获取商品详情。这两个场景的共性是,请求 QPS 会随着用户活跃度剧烈波动,而商品详情数据本身变化频率不高。
我们最终采用的是两级缓存策略:一级是购物车服务本地 Caffeine 缓存,缓存时间 5 分钟,存储 SKU 基础信息;二级是 Redis 缓存,缓存时间 15 分钟,存储完整的商品详情对象。查询时本地缓存未命中才去查 Redis,Redis 仍未命中才调动商品服务接口,并将结果分别回填到两级缓存。
这套方案上线后,商品服务的调用量下降了 92%,购物车列表接口的 P99 延迟从 180ms 降到了 45ms 左右。库存数据没有进缓存,因为库存变化太频繁,我们选择每次结算前实时调用或者订阅库存变更事件来刷新库存标记。
4.2 OpenFeign 调用如何设计才会不拖垮购物车
购物车服务是典型的“入口服务”,面向 C 端的高频调用使得它的依赖调用必须特别谨慎。我们踩过的最大的坑是默认超时时间设置不当,导致商品服务接口偶发慢请求把购物车的线程池全部占满。
后来我们单独给购物车服务里所有 OpenFeign 调用做了三个改造:第一,统一设置连接超时 500 毫秒、读取超时 1 秒,绝对不允许默认值;第二,每个 Feign Client 都配置独立的线程池隔离,商品服务的慢请求不能影响营销服务的调用;第三,所有外部调用失败时必须有降级兜底,比如商品详情查不到就返回一个“商品已失效”的占位对象,绝不能因为下游服务一个接口超时就把整个购物车接口拖挂。
4.3 结算流程中的数据交接设计
购物车和订单服务之间的交接是拆分后最容易产生一致性问题的环节。用户在购物车点击“去结算”时,系统需要把选中的购物车条目锁定,生成一个结算凭证,再交给订单服务创建订单。而这个时候下单流程和购物车服务已经不在同一个数据库事务里了。
我们最终采用的是乐观锁加结算令牌的方案。用户点击结算后,购物车服务生成一个唯一的结算令牌,并且给相关条目加一个“结算锁定”标记,令牌有效期 10 分钟。订单服务接收令牌后去购物车服务查询锁定条目并创建订单,创建成功后购物车服务再把对应的锁定条目删除。如果订单创建失败或者用户放弃结算,令牌过期后锁定标记自动释放,条目回到可编辑状态。
这样设计的出发点是,购物车条目和订单条目不要求强一致,但结算过程中不能让同一个商品同时被两个请求下单。库存扣减则完全交给订单服务去处理,购物车服务只负责状态标记,有效降低耦合。
5. 购物车服务拆分后的性能优化与稳定性保障
5.1 缓存穿透、击穿、雪崩的应对
购物车场景下最容易碰到的缓存问题是穿透。恶意用户或者爬虫脚本可以批量伪造 SKU ID,如果这些 ID 在商品服务里根本不存在,每次请求都会穿透缓存打到数据库,很快就能把商品服务的读库打崩。
我们做了两道防护。第一道是布隆过滤器,把所有上架商品 SKU ID 初始化到过滤器里,查询前先判断这个 SKU ID 是否存在,不存在直接返回空结果,连缓存都不会查。第二道是对空结果也做短时间缓存,比如一个 SKU 被确认不存在后缓存 30 秒,防止集中在同一时间窗口内的重复穿透。
雪崩方面,我们给所有缓存加随机过期时间,避免同一批 key 在同一时刻集体失效导致后端压力尖刺。比如基础缓存有效期 15 分钟,实际使用时每个 key 的有效期在 12 到 18 分钟之间随机浮动,避免批量失效的问题。
5.2 热点用户与大促场景的隔离
购物车服务的流量分布非常不均匀,少数头部用户会产生大量写请求,而更多普通用户以读为主。大促期间,热门商品的加购请求会在短时间内集中在某一个 Redis 分片上,形成热点。我们当时的处理方案是给 Redis 加了一层本地热点缓存,对于热度超过阈值的商品的库存和基础信息,不再每次去 Redis 读取,而是在购物车服务本地维护一个高命中率的缓存区域。加购请求经过热点检测后,直接读本地缓存中的商品状态,价格数据的实时性和一致性通过后台定时任务做准实时刷新。
5.3 监控指标与告警设置
拆分后的购物车服务最有价值的几个监控指标,第一个是购物车列表接口的 P99 延迟,第二个是加购接口的 TPS 和成功率,第三个是 Redis 的响应延迟和内存占用率,第四个是商品服务调用的平均耗时和超时次数。
我们在这些核心指标上设置了多级告警。比如加购成功率低于 99.99% 持续 1 分钟产生 P2 告警,低于 99% 持续 1 分钟产生 P1 告警并且自动触发限流熔断。Redis 内存占用率达到 80% 时会告警并自动触发 key 清理和容量扩容流程,确保不至于因为 Redis 内存耗尽导致购物车服务大面积降级。
6. 购物车拆分过程中遇到的典型问题和排查思路
6.1 “购物车加购偶尔消失”的排查经过
上线后收到用户反馈:加购成功后刷新页面,商品偶尔不在购物车里。这种偶现问题在单体架构里基本不会出现,但微服务拆分后链路长了,问题定位难度成倍增加。
排查后发现了两个问题叠加。第一个问题是网关层与购物车服务之间的请求头在异步线程中丢失了,用户身份传递断掉,加购操作落到了匿名用户头上,刷新后当然看不到。第二个问题是购物车写入 Redis 后,列表接口读的是另一个 key,这是因为我们在改造时把读写 key 的拼接规则悄悄改过,但部分旧接口没有同步更新。最终把两个不一致点统一修复后问题彻底消失。这次经历让我养成了一个习惯:微服务改造期间,任何涉及到 key 拼接和用户身份传递的改动,必须先列出全部调用方清单再动手。
6.2 结算时部分商品丢失的判断
出现一个比较诡异的问题:购物车里有 5 个商品,但点击去结算时只有 3 个进入订单。追溯后发现是结算令牌生成和购物车条目锁定之间不是原子的。用户快速连点两次“去结算”,第一次生成的令牌锁定了 3 个商品,第二次生成的令牌锁定了另外 2 个商品,后续订单服务只认其中一个令牌,导致另一部分商品丢失。
修复方案是在令牌生成逻辑中增加了一步操作:生成令牌前先检查当前用户是否还有未过期的有效令牌,如果有则复用已有令牌并更新条目集合,而不是再生成新令牌。同时增加一个分布式锁,保证同一用户的并发结算请求只会有一个会真正执行锁定和令牌生成。
6.3 商品变更后购物车显示价格不一致
运营在后台修改了某商品的价格,购物车里缓存的价格快照还是旧值。我们在这里做过几次取舍,最终的方案是:商品服务在价格变更时发送一个 MQ 消息,购物车服务消费消息后主动更新价格快照。这个方案的可靠性和实时性都更好,避免购物车在结算时才发现价格对不上。但这里也引入了一个新的问题:如果消息积压或者消费异常,需要有一个兜底补偿任务定时去刷新那些长时间没有更新的价格快照,双保险确保关键价格数据不陈旧。
6.4 并发加购导致的超卖预占问题
在库存总量充足的情况下,高并发加购依然导致部分用户在结算时看到“库存不足”。原因是商品服务在库存扣减时只检查了实时库存,但购物车锁定条目时记录了预占数量,而这个预占数量没有回传给商品服务,导致预占与扣减两套数据各自独立。后来我们统一在商品服务侧维护了“预占库存 + 已扣库存”的组合状态,购物车服务锁定条目时实时发送预占请求,商品服务在预占成功后才允许结算流程继续。这样虽然增加了一次调用开销,但彻底避免了高并发场景下的预占数量失真。
7. 拆分后的性能数据与思考沉淀
7.1 拆分前后的性能对照
购物车微服务独立上线后,我们对核心接口做了压测对比。加购接口的 TPS 从单体架构下的 3200 提升到了独立部署后的 9500,瓶颈也从数据库连接池转移到了网关层和 Redis 集群带宽。购物车列表接口的 P99 延迟从 260ms 降到 60ms 以内,主要原因是缓存命中率提高和热点数据本地化。此前购物车模块查询商品详情频繁走 DB 和接口,现在绝大多数请求在两级缓存中就能完成数据装配。
但性能提升也伴随着成本的增加,独立部署意味着多了一套服务实例、多了一套监控体系、多了一堆需要维护的配置。对于用户量不大、业务复杂度不高的团队来说,我的建议是先评估清楚是否有必要做这个拆分,别为了“上微服务”而拆,而是为了“解决真实痛点”而拆。
7.2 团队协作模式的转变
购物车服务拆出去后,我们团队的开发模式也发生了明显变化。购物车小组可以独立排期、独立上线,不再需要等订单组或者商品组的同事一起联调联发。购物车服务旁边维护了一份简易版的接口契约文档,基于 OpenAPI 规范自动生成,每次接口变更后自动推送到相关团队的企业通讯群里,通知成本大幅降低。
不过也有一些协作关系变复杂了。以前一个事务里就能解决的问题,现在要跨服务做数据同步。我们在拆分初期对“到底哪些操作可以容忍最终一致,哪些必须强一致”做了很长时间的讨论。最终沉淀出来的原则是:用户操作产生的数据(比如购物车条目)可以最终一致,但金额和库存相关的数据必须强一致,不允许出现显示金额和实际扣款金额不一致的情况。
7.3 对后续服务拆分的启发
做完购物车这个试点后,我们总结出了一套内部可复用的拆分检查清单。第一个问题:这个模块是否能独立定义清晰的数据边界,如果它要直接访问其他服务的数据库表,说明边界还没划清楚,暂时不适合拆。第二个问题:这个模块是否有独立的变更频率和扩容需求,如果总和另一个模块同步变更,拆了反而增加通信开销。第三个问题:拆分后的接口契约是否能在不改动老代码的前提下,让调用方平滑切换,必要时需要做好双写和灰度策略。
顺着这套清单,我们后续又拆分了订单服务和营销服务,虽然没有购物车这么顺利,但底层的设计方法论基本是一致的。核心思想就一句话:微服务拆分不是技术目标,而是手段,真正的目标是把复杂问题拆小,让团队能更好地响应业务变化。
8. 购物车微服务后续演进的两个方向
8.1 向量化存储与大规模购物车支持
目前购物车服务基于 Redis 的 Hash 结构完全可以支撑普通用户的购物车需求,每个用户的条目数量一般不超过 200 个,单 key 的读写速度很快。但业务同事提出了一个需求:用户想把大量的商品先存放在“暂存”区域,可能达到 2000 个以上,然后再慢慢整理。这种情况下,单个 Hash 的存储效率和操作效率都会下降,目前我们已经在调研 Redis 模块中更合适的数据结构,不排除后续对超大购物车场景做分片存储,比如按时间切片或者按类目切分。
8.2 基于事件驱动的数据同步
购物车服务和商品服务之间目前还是以同步调用为主,正在规划部分场景的异步化改造。比如浏览页加购时,可以先把购物车写入消息队列,再由消费者批量入 Redis,减少对主链路的线程占用。这些改造都是在稳定运行很久之后才逐步引入的,初期所有链路都走同步其实更有利于问题定位和排查,这也是我对所有刚要拆微服务团队的一条建议,不要一上来就想把整个系统做成异步事件驱动,先把同步链路跑通,观察数据规律之后再渐进演进。
购物车这个服务体量不大,但麻雀虽小五脏俱全,分布式会话、缓存一致性、接口幂等、跨服务事务边界、性能容量治理这些微服务架构里的经典命题它全都涉及到了。我个人在实际操作中的体会是,把一个看似简单的模块拆出真正的复杂度,比新做一个复杂系统更能训练架构思维。后面每次遇到新的拆分问题时,我还会回到购物车这个例子上来对照,很多道理其实是相通的。