说实话,购物车这个模块在电商系统里经常被低估。做微服务拆分的时候,大多数团队先把用户、商品、订单这些“看上去更重要”的服务拆完,把购物车留在单体里。我参与的一个电商项目也是这样,结果后期被购物车坑得很惨——促销改价、库存预占、会员折扣全都要动它,单体的每个迭代都因为它变得提心吊胆。后来我们专门搞了一次购物车微服务拆分,把加购、列表、改数量、选中状态、删除这些能力独立成一个服务,配合注册中心、网关和远程调用框架重新梳理了整个链路。这篇文章就是那次拆分的完整复盘。
我把拆分过程中最关键的决策和踩过的坑都写下来。包括购物车服务的边界怎么划、技术栈怎么选、Redis和数据库怎么分工、跨服务调用怎么设计、上线时遇到哪些问题。适合正在做微服务改造、或者准备把购物车从单体中拆出来的团队参考,尤其是已经对SpringCloud有一定了解、想看看实际落地细节的人。
1. 购物车为什么值得单独拆出来
1.1 购物车在电商链路里的位置
购物车表面上是一个简单的CRUD模块,实际它在整个交易链路里处于一个非常尴尬的“中间层”。上游要依赖用户信息,要知道是哪个用户加购;要依赖商品信息,要知道SKU是否上架、标题和图片是什么;要依赖价格和促销服务,因为购物车列表里展示的价格不是下单时的最终价,加购时商品可能正在参加满减。下游它又给订单结算提供数据,下单的时候要从购物车里勾选明细生成订单。
这意味着购物车服务天然要跟多个服务做数据交互。而且这个模块的访问特点是读多写少、单用户维度的操作非常集中。加购行为集中在每天的几个高峰时段,购物车列表的打开频率远高于修改频率。这种特征和订单那种强一致、事务型的业务完全不同,和商品那种读多写少但偏静态数据的业务也不同。购物车更像一个“状态型”的中间聚合层,用户的操作结果需要快速反馈,但状态的一致性要求并不苛刻。
这个定位决定了它非常适合独立成微服务。独立之后,购物车的高频访问不会拖累其他模块,其他服务的发布变更也不会频繁波及购物车。
1.2 单体应用里购物车模块的真实痛点
在没拆分之前,购物车代码和用户、商品、订单、促销代码混在同一个工程里,问题会随着业务复杂度增长逐渐暴露。
第一个痛点是促销逻辑的耦合。某个商品参加满减、第二件半价这类活动时,购物车列表要实时展示促销标签和预估优惠价。促销规则变一次,购物车代码就要跟着改一次,而且常常是促销团队的人改完规则后,购物车展示逻辑出了问题,最后查到是公共的优惠计算工具类被连带修改。
第二个痛点是数据库层面的相互影响。购物车表的数据量虽然不大,但读写频率很高。单体应用里所有模块共用一套数据库资源,订单高峰期的大量写入会把数据库连接池占满,购物车接口跟着变慢。用户体感就是下单高峰的时候“购物车打不开”。
第三个痛点是发布频率的互相拖累。小程序端每两周发一个版本,里面可能只是改了商品详情页,但因为是单体应用,购物车服务也得跟着重新发布。购物车这种面向C端的模块,稳定性要求很高,频繁发布带来的风险完全没必要让其他模块承担。
1.3 拆分后的收益与成本评估
拆分的收益不能只从技术角度看,还要从迭代效率看。独立之后,购物车服务可以单独扩容,大促前只需要给购物车服务多开几个实例,不用整个应用一起扩。购物车模块的发布和商品、促销服务完全解耦,改动购物车逻辑时只影响购物车服务本身。
成本方面要老实说,拆出来短期内一定会增加工作量。服务间调用的网络开销、分布式环境下的数据一致性、接口出错的排查链路,都比单体时代复杂。购物车拆出去之后,原来一次本地方法调用变成了RPC调用,接口调用的耗时增加了。如果Feign配置不合理,还会把超时异常带进购物车主链路。
我的判断是,购物车值得拆,但前提是订单、商品、用户这些核心服务已经拆完,注册中心、网关、统一配置中心这些基础设施已经就绪。如果这些都没有,先把购物车拆出来意义不大,反而会让单体里的服务反向依赖购物车。
2. 服务边界与接口契约:拆的时候先算清楚账
2.1 功能归属清单:哪些必须进购物车服务
拆分的核心问题是边界。边界划错了,后面返工成本很高。我们实践下来,购物车服务应该承担的职责很清晰:按用户维度维护购物车条目的状态,包括加入购物车、修改数量、切换选中状态、删除条目、清空购物车、查询购物车列表。
这些功能有一个共性:它们操作的都是“用户购物车里有什么、数量是多少、是否选中”,不涉及价格计算、不涉及库存锁定、不涉及优惠分摊。即使购物车列表要展示价格,那也是调用商品服务拿到实时价格之后展示,购物车服务自己不维护价格快照。
另外我建议把购物车相关的几个辅助能力也放进来。比如购物车商品数量角标(就是导航栏上那个小红点数字)、购物车批量操作(全选/全不选/批量删除)、购物车过期清理任务。这些功能和购物车数据强相关,放别的服务反而又多一次远程调用。
2.2 功能隔离清单:哪些绝对不能放进来
我见过一些团队把购物车服务做成“大杂烩”,最后完全违背了拆分初衷。这里列几个我们明确隔离出去的能力:
价格计算和优惠分摊不能放进来。购物车列表要展示的“预估到手价”,真正的计算逻辑在促销服务里,购物车服务只负责拿到商品基础价格和促销信息后做展示拼接。一旦把优惠计算放进购物车,下次促销规则变动又要改购物车服务。
库存预占和库存扣减不能放进来。购物车是最不适合做库存扣减的环节,用户加购了大量商品但大部分不会下单,预占会造成库存虚占。我们在购物车服务里只做“购物车数量”的累加,真正的库存校验和预占在下单服务里做。
用户资产信息不能放进来。购物车里可能会显示用户会员等级对应的预估优惠,但会员信息本身属于用户服务/会员服务。购物车通过Feign去查询,返回什么展示什么。
这三个原则本质上是一样的:购物车服务只维护“用户选择的状态”,不维护“业务计算的结果”。
2.3 接口契约落地:路径、参数和响应格式
拆分时我们先把所有接口写成接口文档,再让前端和服务端按文档并行开发。这里的关键点是接口路径要提前规划好,网关层直接把购物车相关的请求路由到新服务,前端改动最小。
我们当时定义的接口清单大概是这样的:
| 接口功能 | 方法 | 路径 | 入参要点 |
|---|---|---|---|
| 加购 | POST | /api/cart/items | userId、skuId、quantity |
| 购物车列表 | GET | /api/cart/items | userId |
| 修改数量 | PUT | /api/cart/items/{skuId}/quantity | userId、quantity |
| 选中/取消选中 | PUT | /api/cart/items/{skuId}/selected | userId、selected |
| 删除条目 | DELETE | /api/cart/items/{skuId} | userId |
| 清空购物车 | DELETE | /api/cart/items | userId |
| 购物车角标数 | GET | /api/cart/count | userId |
统一响应结构是{ "code": 0, "message": "success", "data": ... }。这个结构全项目共用,购物车服务也沿用,避免前端每个服务都适配一套响应格式。
接口契约文档还有一个作用:把userId从哪里来定清楚。我们不希望前端每次调接口都显式传userId,因为这样就暴露了用户身份,安全性不好。实际方案是网关统一解析登录态,把userId放在请求头x-user-id里,购物车服务的拦截器从Header取出来,塞进上下文中。后面的业务方法直接从上下文取userId,这也是后面要展开讲的一个细节。
3. 骨架搭建:SpringCloud组件选型和工程结构
3.1 组件选型:注册中心、调用框架、网关
既然项目已经决定用SpringCloud,组件选型就得统一规划。我们沿用团队现有的技术栈,没有为购物车服务单独引入新东西。注册中心用的Nacos,同时承担配置中心的功能,购物车服务的所有配置都从Nacos上拉取,环境切换不用改本地配置。服务间调用统一用OpenFeign,自带声明式API,写起来简单,而且和SpringCloud集成成熟。网关用Spring Cloud Gateway,负责路由和用户鉴权。
这里有一个关键决策:为什么不直接让前端调用购物车服务,而是要在前面加一层网关?因为购物车服务的接口全部要求登录态,如果前端绕过网关直连服务,鉴权逻辑就得在每个服务里重复实现。有了网关,服务内部完全不关心Token怎么解析,只管读Header里的userId,这个设计把所有服务的登录态处理成本都降下来了。
Redis是购物车服务的核心依赖,下面章节会详细讲。选Redis而不是只用数据库,是因为购物车列表接口的响应要求快,几十毫秒内得返回,数据库的查询压力也受不住大促高峰的读请求。
3.2 Maven工程结构与依赖
购物车服务我们是按一个独立的Maven工程来建的,没有做成多模块。工程内部按业务分包,因为购物车本身的领域逻辑不算多,拆成多模块反而增加复杂度。工程结构大概如下:
cart-service/ ├── pom.xml └── src/main/java/com/xxx/cart/ ├── CartServiceApplication.java ├── config/ │ ├── RedisConfig.java │ ├── WebMvcConfig.java │ └── RestTemplateConfig.java ├── controller/ │ └── CartController.java ├── service/ │ ├── CartService.java │ └── impl/ │ └── CartServiceImpl.java ├── feign/ │ ├── ProductFeignClient.java │ ├── MemberFeignClient.java │ └── fallback/ ├── interceptor/ │ └── UserContextInterceptor.java ├── entity/ │ ├── Cart.java │ └── CartItem.java ├── vo/ │ ├── CartItemVO.java │ └── CartVO.java └── common/ ├── Result.java └── BizException.javapom里关键的依赖包括spring-cloud-starter-alibaba-nacos-discovery、spring-cloud-starter-alibaba-nacos-config、spring-cloud-starter-openfeign、spring-cloud-starter-gateway(如果购物车网关路由在单独服务里,购物车本身不需要这个依赖,但一般购物车服务不直接对外暴露,不需要网关依赖)、spring-boot-starter-data-redis、mybatis-plus-boot-starter。数据库持久层我们用MyBatis-Plus,购物车服务的表结构简单,用MyBatis-Plus的BaseMapper就够,不引入复杂框架。
3.3 基础配置与启动类
启动类和普通Spring Boot应用一样,注意副上@EnableDiscoveryClient和@EnableFeignClients注解。@EnableFeignClients要指定feign包路径,不然扫不到商品的Feign接口。
关键配置在bootstrap.yml里(因为要从Nacos拉配置,配置中心的地址必须在bootstrap阶段拿到):
spring: application: name: cart-service cloud: nacos: discovery: server-addr: ${NACOS_ADDR:127.0.0.1:8848} namespace: prod config: server-addr: ${NACOS_ADDR:127.0.0.1:8848} namespace: prod file-extension: yaml而Redis、数据库、Feign超时这些配置放在Nacos上的cart-service.yaml里,这样不用改包就能调整参数:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/cart_db?useUnicode=true&characterEncoding=utf8 username: cart_user password: '******' redis: host: 127.0.0.1 port: 6379 lettuce: pool: max-active: 100 max-idle: 20 feign: client: config: default: connectTimeout: 2000 readTimeout: 3000Feign超时这里特别注意,默认的connectTimeout是10秒,readTimeout是60秒,这对购物车这种面向用户的接口来说太长了。商品服务一旦慢,购物车接口会一直挂着,用户的请求全部堆积。我们后来把超时调到2秒/3秒,配合熔断降级,效果好了很多,这个后面在踩坑部分细说。
4. 数据模型设计:Redis为主、数据库兜底
4.1 为什么选择Redis Hash作为主存储
购物车数据的特点决定了存储选型。每个用户的购物车数据量不大,通常几十个条目,但访问非常集中,用户每次打开购物车都要读全量数据。如果用数据库主表存,高峰期读压力会集中在某些热点用户上,而且一次购物车列表查询要关联商品表,SQL写起来也不轻快。
我们当时对比了几种Redis结构。用String的话,一个key存一个商品条目,比如cart:user:{userId}:{skuId},查询购物车列表时要先scan或者用多个key拼接,性能不高还容易产生大量key。用List可以存购物车条目的有序列表,但每个条目本身有多个字段(数量、选中状态、加购时间),List里要么存JSON字符串,要么序列化对象,改单个字段不方便。
最终选了Hash结构:key是用户维度cart:user:{userId},field是skuId,value是购物车条目的JSON字符串。这样的好处很明显:查询一个用户的所有购物车条目就是一次hgetAll,查询几个指定商品就是hmget,改数量只需要重写对应field的value。
这个选型的核心逻辑是:购物车永远是“按用户查全部”的业务,Hash结构与业务查询维度完全匹配。
4.2 数据库表设计:兜底与对账
既然Redis是主存储,为什么还要数据库表?我们的理由有两个:一是Redis数据可能会丢,不管是宕机还是Key过期,需要有兜底数据把用户的购物车恢复出来;二是运营经常要查“某用户购物车里有没有某个商品”,做数据分析和活动复盘,从Redis里查这种历史数据不现实。
所以我们设计了两个简单的表。购物车主表cart存用户级信息,购物车明细表cart_item存条目信息。
CREATE TABLE `cart` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '用户ID', `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `cart_item` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `cart_id` bigint(20) NOT NULL COMMENT '购物车主表ID', `user_id` bigint(20) NOT NULL COMMENT '用户ID', `sku_id` bigint(20) NOT NULL COMMENT '商品SKU ID', `sku_title` varchar(255) NOT NULL COMMENT '商品标题快照', `sku_image` varchar(500) DEFAULT NULL COMMENT '商品主图快照', `shop_id` bigint(20) DEFAULT NULL COMMENT '店铺ID', `quantity` int(11) NOT NULL DEFAULT '1' COMMENT '数量', `checked` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否选中', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;sku_title和sku_image这两个快照字段,有些人会觉得不应该存。我们保留的原因是:商品服务的数据即使变更,购物车列表在短时间内还是得展示用户加购时看到的内容,等商品服务返回最新数据后再覆盖。这个“先展示快照,再异步刷新”的策略,能避免每次打开购物车都因为商品服务不稳定而白屏。
4.3 读写链路与数据恢复方案
读写链路的设计思路是:所有写操作先更新Redis,返回用户结果,然后异步同步到数据库。同步用本地消息表或者简单的延迟任务。比如用户加购了商品A,Redis里马上累加数量,同时往一个本地消息表写一条同步任务,后台线程每秒批量把这些任务同步到cart_item表。
这里还要说明一个取舍:为什么不直接先写数据库再更新Redis?因为购物车是读多写少,读路径占了绝大多数流量,如果写路径经过数据库,加购接口的耗时可能从几毫秒变成几十毫秒,高峰期的数据库压力也会传导到购物车核心链路上。用Redis为主存储、数据库异步兜底,读接口全部走Redis,性能是最好的。
Redis数据万一全丢,恢复方案是从cart_item表加载用户的购物车数据,重新写入Redis。具体做法是启动时加载全量数据的成本太高,我们只在用户首次访问购物车时判断Redis里有没有该用户的key,没有就尝试从数据库加载。
这里要提一个没有踩到的坑:如果用户购物车数据量巨大(比如运营清数据时误删Redis),首次访问会全部从数据库加载,瞬间打爆数据库。所以恢复逻辑要加开关和限流,确保只能小流量触发,不能一次全量恢复。
5. 核心业务实现逻辑与关键代码
5.1 加购:校验商品状态与数量累加
加购接口是整个服务里链路最长的一个。前端传入userId、skuId、quantity,后端先校验商品是否存在且上架。这个校验必须做,不然后端会存一堆无效购物车数据,前端展示时还可能因为商品信息缺失报错。
核心代码逻辑大概是这样的:
@Override public CartVO addCartItem(Long userId, Long skuId, Integer quantity) { // 1. 先查商品服务,确认SKU有效且已上架 SkuInfoVO skuInfo = productFeignClient.getSkuInfo(skuId); if (skuInfo == null || skuInfo.getStatus() != 1) { throw new BizException(ErrorCode.SKU_NOT_AVAILABLE); } // 2. 构造购物车条目数据 CartItemVO item = new CartItemVO(); item.setSkuId(skuId); item.setTitle(skuInfo.getTitle()); item.setImage(skuInfo.getMainImage()); item.setShopId(skuInfo.getShopId()); item.setQuantity(quantity); item.setChecked(true); item.setAddTime(System.currentTimeMillis()); // 3. 写入Redis Hash,field是skuId,value是条目的JSON String cartKey = buildCartKey(userId); String field = String.valueOf(skuId); String json = JSON.toJSONString(item); stringRedisTemplate.opsForHash().put(cartKey, field, json); // 4. 设置购物车Key的过期时间,比如30天无访问自动清理 stringRedisTemplate.expire(cartKey, Duration.ofDays(30)); // 5. 异步同步数据库,不阻塞主链路 cartSyncService.asyncSyncToDb(userId, skuId, item); return getCartList(userId); }加购这里有个产品细节很容易被忽略:如果同一个skuId重复加购,是直接累加数量还是提示用户“该商品已在购物车”?我们选择累加,因为大部分用户的习惯是连续加购,弹窗确认会打断购物流程。累加的逻辑用Hash的hIncrBy对数量字段做原子累加,避免并发重复加购时数量不对。
5.2 修改数量与切换选中状态
修改数量的接口要限制上限。我们规定单件商品加购数量不能超过99,防止用户一次性加太多导致库存虚占。修改数量和加购类似,先在Redis Hash里拿到当前条目,校验数量合法后更新value中的quantity字段。
切换选中状态比较简单,就是更新value中的checked字段。这里要注意的是,购物车列表的全选和全不选操作,本质上是对该用户Hash里所有field的value做批量更新。Redis没有直接支持“遍历Hash并更新全部field”的原子命令,我们用hGetAll拿到所有条目再逐条put,因为用户购物车条目数量有限(一般最多几十个),量级很小,不会成为性能瓶颈。
修改数量、切换选中状态同样要异步同步到数据库,保证数据库里的数据是最终一致的。
5.3 购物车列表组装:批量查商品信息
购物车列表是最重要的读接口。它的组装过程比较有意思:先从RedishGetAll拿到购物车条目,条目里已经带了标题、图片、数量、选中状态这些快照,但是不包含实时价格和库存状态。这两个信息需要调商品服务获取。
这里必须用批量接口,不能循环里一个一个调Feign。假设用户购物车里有50个商品,循环调50次Feign,每次网络耗时几毫秒,整个接口就上百毫秒了,而且会给商品服务造成瞬时压力。正确的做法是先收集所有skuId,一次Feign调用把50个商品的信息查回来,本地组装。
@Override public CartVO getCartList(Long userId) { String cartKey = buildCartKey(userId); Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(cartKey); if (entries == null || entries.isEmpty()) { return new CartVO(Collections.emptyList()); } // 1. 收集所有skuId List<Long> skuIds = entries.keySet().stream() .map(k -> Long.valueOf(k.toString())) .collect(Collectors.toList()); // 2. 批量查商品服务 List<SkuInfoVO> skuInfos = productFeignClient.listSkuInfo(skuIds); // 3. 按skuId建立本地映射 Map<Long, SkuInfoVO> skuMap = skuInfos.stream() .collect(Collectors.toMap(SkuInfoVO::getSkuId, s -> s)); // 4. 组装购物车条目 List<CartItemVO> items = new ArrayList<>(); for (Map.Entry<Object, Object> entry : entries.entrySet()) { Long skuId = Long.valueOf(entry.getKey().toString()); CartItemVO item = JSON.parseObject(entry.getValue().toString(), CartItemVO.class); SkuInfoVO sku = skuMap.get(skuId); if (sku != null) { item.setPrice(sku.getPrice()); item.setStock(sku.getStock()); item.setStatus(sku.getStatus()); } else { // 商品已删除或失效,标记不可售,不直接抛异常 item.setStatus(0); } items.add(item); } // 5. 按加购时间倒序 items.sort((a, b) -> Long.compare(b.getAddTime(), a.getAddTime())); return new CartVO(items); }这条链路里,商品服务返回的缺失商品,我们选择在列表里标记为失效,而不是直接报错。购物车列表永远是“能看就展示全”,一个商品数据异常不应该让整个购物车打不开。
5.4 删除与清空操作
删除购物车条目是hDelete(cartKey, skuId),清空是直接删除整个Key。这两个操作也要同步数据库。清空购物车有一个产品场景要注意:用户下单成功后,系统要删除购物车里已经下单的商品条目。这意味着购物车服务需要提供一个“按skuId列表删除”的接口给订单服务调用,这个接口我们放在购物车服务的内部API里,不对外暴露。
这种内部接口的鉴权比较头疼。外部接口通过网关统一鉴权,内部服务间调用如果不做处理,等于裸奔。我们当时的方案是:内部API走独立的Feign通道,加一个服务的内部token校验,防止外部请求直接打到内部接口。
6. 跨服务协同:OpenFeign调用商品与会员服务
6.1 商品服务需要提供的Feign能力
购物车对商品服务的诉求主要有两个:单个SKU的实时校验,以及批量SKU信息查询。单个校验用在加购时,批量查询用在购物车列表组装时。这两个Feign接口长这样:
@FeignClient( name = "product-service", fallbackFactory = ProductFeignClientFallbackFactory.class ) public interface ProductFeignClient { @GetMapping("/api/product/sku/{skuId}") Result<SkuInfoVO> getSkuInfo(@PathVariable("skuId") Long skuId); @PostMapping("/api/product/sku/batch") Result<List<SkuInfoVO>> listSkuInfo(@RequestBody List<Long> skuIds); }这里有几个接口字段上的细节。SkuInfoVO里必须包含:skuId、商品标题、主图、店铺ID、销售价格、库存、上下架状态。购物车列表展示、加购快照、下单校验都需要这些。如果商品服务对外VO里没有这些字段,购物车服务要么多调几次接口拼数据,要么就得在本地维护冗余字段,都不划算。
6.2 会员与促销信息的联动
购物车列表里展示会员价、促销标签时,我们通过会员服务提供的一个批量接口拿到会员等级信息。这个接口我们不直接在主链路上同步调用,而是用异步的方式在列表组装完成后,再根据会员等级计算预估优惠。
异步的原因很简单:主链路已经等了一次商品服务的批量查询,如果再等一次会员服务,接口耗时又增加了。购物车列表展示“预估到手价”这个数字本身就不是强一致的,晚200毫秒刷出来用户感知不到,但接口变慢500毫秒用户体感非常明显。
购物车的促销预估我们其实进一步简化了:购物车列表只展示“该商品参与的促销活动名称”,比如“满300减40”,不展示精确的到手价。精确价格留给结算页去算。这个产品决策大幅降低了购物车服务与促销服务的耦合度,我们当时改了这个方案之后,购物车接口从平均120ms降到了80ms。
6.3 登录态传递与用户上下文
前面提到网关统一鉴权后,把userId放在请求头x-user-id里。购物车服务要做的事就是解析这个Header,放到一个线程变量里,业务代码通过上下文工具类取userId。
代码如下:
@Component public class UserContextInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String userId = request.getHeader("x-user-id"); if (StringUtils.isBlank(userId)) { throw new BizException(ErrorCode.USER_NOT_LOGIN); } UserContext.setUserId(Long.valueOf(userId)); return true; } @Override public void afterCompletion(...) { UserContext.clear(); } }UserContext是一个ThreadLocal持有userId的静态工具类,请求结束后必须清理,不然线程池复用线程时会把上一个用户的id带到下一个请求里。这个清理动作一定要放在afterCompletion里,不能只放在业务方法的try-finally里,因为异常发生后也可能直接跳过清理逻辑。
6.4 Feign超时、熔断与批量接口设计
Feign调用商品服务时,如果商品服务响应慢,会拖垮购物车服务自己的线程。我们给购物车服务的Feign客户端配了合理的超时和熔断。OpenFeign配合Sentinel做熔断降级,配置在Nacos上动态调整。
购物车场景的降级经验:商品信息查询失败时,购物车列表应该返回Redis里已有的快照数据,只是实时价格变成“加载中”或者使用快照价格。绝对不能因为商品服务挂了就让用户购物车整体白屏。这个降级逻辑写清楚之后,购物车服务在商品服务故障期间依然能维持基本可用。
批量接口设计还有一个约束:单次Feign传的skuId列表不能太多。购物车里可能有些人放了二三十个商品,没问题。但如果有人恶意塞了几百个商品,一次批量查询会超过HTTP body和网关的传输限制。我们限制一次批量查询最多传100个skuId,超过就分批调用,防止参数过大被网关拦截。
7. 上线过程中的坑与针对性优化
7.1 缓存穿透与空值缓存方案
上线后遇到的第一个性能问题,是购物车列表偶发性地出现慢查询。排查日志发现,大量请求商品服务批量查询时,传过去的skuId里有不存在的商品。这些无效skuId是怎么进Redis的?是用户用一些抓包工具自行构造请求,往购物车里塞了不存在的商品ID。这类请求每次都会穿透到商品服务,商品服务查不到数据,又穿透到商品数据库,形成一条无效链路。
我们的优化方案是两层。第一层,加购时如果商品服务返回“商品不存在”或“已删除”,直接拒绝写入Redis,从源头上挡住无效数据。第二层,对商品服务返回的已失效商品,在购物车列表里标记为不可售,但保留缓存,用户点击删除后清理。这样既挡住了新的脏数据,又给旧数据留了可视化的处理入口。
更极端的缓存穿透场景是恶意用户直接调购物车列表接口,传一个从没见过的userId。我们的购物车Key是按用户维度建的,这种穿透打到Redis没问题,但会每次都穿透到数据库找该用户的购物车记录,造成数据库压力。针对这个场景,我们在Redis里对不存在的用户也缓存了空购物车结果,有效期5分钟,CD时间很短但足以挡住恶意量。
7.2 热点商品与本地缓存
大促期间最大的问题是热门商品的SKU信息查询量暴增。100个用户同时打开购物车,里面可能都有同一个爆款SKU,商品服务的批量接口会被这个SKU的热点请求打满。商品服务自己做了缓存,但还是扛不住超大规模的集中访问。
购物车服务这边的优化是引入了一层本地缓存Caffeine,缓存的是商品服务返回的SKU基础信息。本地缓存命中率高,热门的SKU信息几十个用户同时查询时只有一次真实打到商品服务。缓存的过期时间设了60秒,对购物车展示来说完全够用。
这个优化效果非常明显。压测数据对比,引入本地缓存后购物车列表P99耗时从180ms降到了95ms,商品服务的QPS峰值降了一半多。
7.3 Feign超时导致购物车接口变慢
还有一个印象深刻的问题:上线初期购物车接口偶尔会突然大批量超时。排查半天发现罪魁祸首是Feign的readTimeout配置得太长。商品服务某次发布后,有个接口因为内部锁竞争变慢,平均响应时间到了5秒。购物车列表接口在等商品服务返回,一个请求挂了5秒,网关配置的全局超时是10秒,虽然没触发网关报错,但Tomcat线程被大量占用,其他正常请求排队,购物车整体变慢。
我们把Feign的connectTimeout改成2秒,readTimeout改成3秒,配合Sentinel的熔断规则:商品服务接口错误率超过20%时,直接熔断10秒。熔断期间购物车列表走快照数据降级。这样商品服务的单点故障对购物车的影响被限制在一个可控范围内,购物车主接口的P99稳定在了100ms以内。
这个改动给了我们一个启示:微服务拆分后,每个服务间的调用一定要设置合理的超时和熔断,而且熔断参数不能照搬默认值,要根据业务接口的实际耗时分布来调。
7.4 网关路由与Header传递的坑
购物车服务上线后,有一个线上问题困扰了我们半天:前端调购物车列表接口偶尔返回401。排查发现,前端请求到达网关后,网关正常解析了登录态,把userId放进了x-user-id请求头。但是网关在转发到购物车服务的路上,经过了一次自定义的GlobalFilter,这个Filter在构建请求时用了新创建的HttpHeaders,没有把原始的x-user-id拷贝过去,导致购物车服务拿不到userId,直接拒绝了请求。
这个问题的排查过程很典型。先看购物车服务日志,发现有人没带userId,再看网关日志,发现Header里有userId,最后定位到网关的Filter实现有Bug。修复很简单:在GlobalFilter里拷贝原始Header,而不是新建Headers再手动放几个字段。
踩过这个坑之后,我们在网关层加了一个日志过滤器,专门记录每个请求转发前后的Header变化,这种问题如果再出现,看日志就能直接定位。
7.5 分布式事务的取舍
最后说说分布式事务。购物车拆分后,有一个很自然的场景会想到分布式事务:用户加购商品A时,同时给用户发放一个加购优惠券,这两个操作分布在购物车服务和优惠券服务里,如果优惠券发放失败,购物车要不要回滚?
我们的结论是不要。加购优惠券失败对用户的影响是“少了一张券”,用户下次还能通过其他活动获得,但加购必须立刻成功,否则用户的购物体验就是“添加到购物车失败”。所以这里我们选择的是最终一致:加购成功后,购物车服务往本地消息表写一条“发放加购优惠券”的消息,由后台任务推给优惠券服务。如果推送失败,消息表里会重试;重试多次还失败,就走人工补偿或者直接放弃,因为这张券的权益价值很低,不值得为了它牺牲主链路。
如果真的遇到强一致场景,比如购物车删除和订单创建要保证原子性,那就得引入Seata这类分布式事务框架。但我们分析下来,购物车跟其他服务的交互里找不到这种强一致场景。购物车本身就是可变的、临时的数据,用最终一致性来处理,是符合业务本质的。
通过这次拆分,我个人最大的体会是:购物车微服务能不能拆好,关键在于边界意识的建立。它不是一个单纯的CRUD工程,而是连接用户侧操作和后端交易链路的“状态聚合服务”。在技术选型上,用Redis做为主存储、数据库做兜底,把读接口的耗时控制在百毫秒内;在服务协同上,通过批量Feign接口、本地缓存、超时熔断和降级,把跨服务依赖的风险降到最低。
最后再分享一个小建议:如果你也准备拆购物车,一定先把“购物车列表接口”的性能指标定下来,再动手写代码。因为这个接口是所有购物车功能的汇聚点,它快,整个服务就快;它慢,后面所有优化都是填坑。我们当时定的目标是P99不超过150ms,最终上线后稳定在100ms左右,这个指标为后面对接促销、会员等各种扩展场景留足了余量。