资讯动态

Spring Cloud购物车微服务拆分实践:Redis存储与Feign调用的落地复盘

发布时间:2026/10/21 4:48:28 来源:尧图企业网站定制
说实话购物车这个模块在电商系统里经常被低估。做微服务拆分的时候大多数团队先把用户、商品、订单这些“看上去更重要”的服务拆完把购物车留在单体里。我参与的一个电商项目也是这样结果后期被购物车坑得很惨——促销改价、库存预占、会员折扣全都要动它单体的每个迭代都因为它变得提心吊胆。后来我们专门搞了一次购物车微服务拆分把加购、列表、改数量、选中状态、删除这些能力独立成一个服务配合注册中心、网关和远程调用框架重新梳理了整个链路。这篇文章就是那次拆分的完整复盘。我把拆分过程中最关键的决策和踩过的坑都写下来。包括购物车服务的边界怎么划、技术栈怎么选、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/itemsuserId、skuId、quantity购物车列表GET/api/cart/itemsuserId修改数量PUT/api/cart/items/{skuId}/quantityuserId、quantity选中/取消选中PUT/api/cart/items/{skuId}/selecteduserId、selected删除条目DELETE/api/cart/items/{skuId}userId清空购物车DELETE/api/cart/itemsuserId购物车角标数GET/api/cart/countuserId统一响应结构是{ 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?useUnicodetruecharacterEncodingutf8 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是skuIdvalue是购物车条目的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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;sku_title和sku_image这两个快照字段有些人会觉得不应该存。我们保留的原因是商品服务的数据即使变更购物车列表在短时间内还是得展示用户加购时看到的内容等商品服务返回最新数据后再覆盖。这个“先展示快照再异步刷新”的策略能避免每次打开购物车都因为商品服务不稳定而白屏。4.3 读写链路与数据恢复方案读写链路的设计思路是所有写操作先更新Redis返回用户结果然后异步同步到数据库。同步用本地消息表或者简单的延迟任务。比如用户加购了商品ARedis里马上累加数量同时往一个本地消息表写一条同步任务后台线程每秒批量把这些任务同步到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 Hashfield是skuIdvalue是条目的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); MapObject, Object entries stringRedisTemplate.opsForHash().entries(cartKey); if (entries null || entries.isEmpty()) { return new CartVO(Collections.emptyList()); } // 1. 收集所有skuId ListLong skuIds entries.keySet().stream() .map(k - Long.valueOf(k.toString())) .collect(Collectors.toList()); // 2. 批量查商品服务 ListSkuInfoVO skuInfos productFeignClient.listSkuInfo(skuIds); // 3. 按skuId建立本地映射 MapLong, SkuInfoVO skuMap skuInfos.stream() .collect(Collectors.toMap(SkuInfoVO::getSkuId, s - s)); // 4. 组装购物车条目 ListCartItemVO items new ArrayList(); for (Map.EntryObject, 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}) ResultSkuInfoVO getSkuInfo(PathVariable(skuId) Long skuId); PostMapping(/api/product/sku/batch) ResultListSkuInfoVO listSkuInfo(RequestBody ListLong 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左右这个指标为后面对接促销、会员等各种扩展场景留足了余量。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑