多来买:Redis RMap 购物车实战
多来买:Redis RMap 购物车实战
事实来源:当前 cart-service、cart-api、product-api 的购物车模型、CRUD 与商品查询源码
证据状态:代码已确认,未启动购物车服务、未连接 Redis。
阅读目标:理解购物车的 Redis key、field、DTO 快照,以及添加、勾选、删除和列表读取的真实实现。
1. 为什么购物车放在 Redis
购物车具有高频读写、按用户隔离、按 SKU 更新和允许短期状态的特点。
当前项目没有为购物车建立 MySQL 表,cart-service 也排除了数据源自动配置;核心状态存放在 Redisson RMap<Long, CartInfoDTO> 中。
本文只讲一辆购物车内部的数据模型与 CRUD。匿名和登录购物车合并见第 2 篇,订单协作见第 3 篇。
2. 模块与职责
| 模块 | 职责 |
|---|---|
duolaimall-cart/cart-api | 定义跨服务购物车 DTO |
duolaimall-cart/cart-service | Controller、RMap 业务和商品 Feign |
duolaimall-product | 新 SKU 入车时提供商品快照和价格 |
| Redis / Redisson | 保存按身份隔离的购物车 Map |
| Gateway | 把登录或临时身份透传给购物车服务 |
3. CRUD 主链路
4. Redis key
源码:duolaimall-common/common-service/src/main/java/com/cskaoyan/mall/common/constant/RedisConst.java,购物车常量。
public static final String USER_CART_KEY_SUFFIX = "user:cart:";public static final long USER_CART_EXPIRE = 60 * 60 * 24 * 30;源码:duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/service/impl/CartServiceImpl.java,buildKey。
private String buildKey(String userId) { return RedisConst.USER_CART_KEY_SUFFIX + userId;}正常形态:
正式购物车:user:cart:10001临时购物车:user:cart:temp-8f3a...参数名虽然叫 userId,实际既可能是正式 ID,也可能是临时 ID。
方法不校验空值、格式或身份类型。
5. RMap 的 field 与 value
user:cart:{identity}├── field 101 -> CartInfoDTO(skuId=101, skuNum=2, ...)├── field 202 -> CartInfoDTO(skuId=202, skuNum=1, ...)└── field 303 -> CartInfoDTO(skuId=303, skuNum=4, ...)| Redis 层级 | 当前内容 |
|---|---|
| key | 一个正式或临时身份 |
| field | skuId |
| value | 商品和交易快照 CartInfoDTO |
同一购物车中 skuId 天然唯一,相同 SKU 再次加入会覆盖同一 field,而不是追加第二条记录。
6. 为什么选择 RMap
高频操作包括:
containsKey(skuId)get(skuId)put(skuId, dto)remove(skuId)values()相较 List,更新一个 SKU 不需要遍历整个列表;相较每个 SKU 一个独立 key,一辆购物车又可以作为整体读取或删除。
但 RMap 只保证单个远程命令的语义,containsKey -> get -> put 仍不是原子业务操作。
7. CartInfoDTO 快照
源码:duolaimall-cart/cart-api/src/main/java/com/cskaoyan/mall/cart/api/dto/CartInfoDTO.java,CartInfoDTO。
@Datapublic class CartInfoDTO { private String userId; private Long skuId; private BigDecimal cartPrice; private Integer skuNum; private String imgUrl; private String skuName; private Integer isChecked = 1; private BigDecimal skuPrice; private Date createTime; private Date updateTime;}| 字段组 | 字段 | 作用 |
|---|---|---|
| 归属 | userId | DTO 创建时使用的身份 |
| 商品快照 | skuId、imgUrl、skuName | 列表展示避免逐项远程查询 |
| 交易快照 | 两个价格、数量、勾选 | 展示、核价和下单协作 |
| 时间 | 创建、更新时间 | 合并取新值和列表排序 |
DTO 是快照,不是商品事实源;订单仍会调用商品服务核验实时价格。
8. 两个价格字段
新条目创建时:
cartPrice = 商品当前价格skuPrice = 商品当前价格后续代码用途不同:
- 订单交易页计算使用
skuPrice; - 购物车转订单明细也使用
skuPrice; refreshCartPrice只更新cartPrice。
这是当前代码的字段不一致,不应自行解释为已经形成清晰的“展示价/结算价”设计。
9. 前台添加入口
源码:duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/controller/CartController.java,addCart。
@RequestMapping("cart/add/{skuId}/{skuNum}")public Result addCart(@PathVariable Long skuId, @PathVariable Integer skuNum, HttpServletRequest request) { String userId = request.getHeader("userId"); if (StringUtils.isBlank(userId)) { userId = request.getHeader("userTempId"); }
cartService.addToCart(skuId, userId, skuNum); return Result.ok();}身份选择顺序:正式 ID 优先,否则使用临时 ID;两者都缺失时 null 继续进入 Service。
10. 商品 Feign 契约
源码:duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/client/ProductApiClient.java,ProductApiClient。
@FeignClient("service-product")public interface ProductApiClient { @GetMapping("/api/product/inner/getSkuInfo/{skuId}") SkuInfoDTO getSkuInfo(@PathVariable Long skuId);
@GetMapping("/api/product/inner/getSkuPrice/{skuId}") BigDecimal getSkuPrice(@PathVariable Long skuId);}新 SKU 需要完整信息创建快照;价格刷新只需要当前价格。
11. MapStruct 创建新条目
源码:duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/converter/SkuInfoConverter.java,skuInfoToCartInfo。
@Mapping(source = "skuInfo.price", target = "cartPrice")@Mapping(source = "skuInfo.skuDefaultImg", target = "imgUrl")@Mapping(source = "skuInfo.skuName", target = "skuName")@Mapping(target = "isChecked", constant = "1")@Mapping(source = "skuInfo.price", target = "skuPrice")@Mapping(target = "createTime", expression = "java(new java.util.Date())")@Mapping(target = "updateTime", expression = "java(new java.util.Date())")@Mapping(target = "userId", source = "userId")CartInfoDTO skuInfoToCartInfo( SkuInfoDTO skuInfo, Integer skuNum, Long skuId, String userId);新条目默认勾选,两个价格字段相同,创建和更新时间都取转换时刻。
12. 添加商品的两个分支
源码:CartServiceImpl#addToCart。
public void addToCart(Long skuId, String userId, Integer skuNum) { RMap<Long, CartInfoDTO> cart = redissonClient.getMap(buildKey(userId));
if (cart.containsKey(skuId)) { CartInfoDTO item = cart.get(skuId); item.setSkuNum(item.getSkuNum() + skuNum); item.setUpdateTime(new Date()); item.setIsChecked(1); cart.put(skuId, item); } else { SkuInfoDTO skuInfo = productApiClient.getSkuInfo(skuId); CartInfoDTO item = skuInfoConverter.skuInfoToCartInfo( skuInfo, skuNum, skuId, userId); cart.put(skuId, item); }}已有 SKU 只更新:
skuNum += 本次数量updateTime = 当前时间isChecked = 1不会重新查询商品名称、图片或价格。
新 SKU 才会通过 Feign 获取商品信息并生成完整快照。
13. 查询列表
查询最终把 RMap values 转成列表,并按更新时间倒序。
排序规则:
Comparator.comparing(CartInfoDTO::getUpdateTime) .reversed()这依赖 updateTime 非空。新建、重复加购和勾选都会刷新时间,但旧结构或异常数据可能不满足前提。
正式与临时两份车怎样选择和合并在第 2 篇展开。
14. 修改勾选状态
源码:CartServiceImpl#checkCart。
public void checkCart(String userId, Integer isChecked, Long skuId) { RMap<Long, CartInfoDTO> cart = redissonClient.getMap(buildKey(userId));
CartInfoDTO item = cart.get(skuId); item.setIsChecked(isChecked); item.setUpdateTime(new Date()); cart.put(skuId, item);}当前没有校验:
isChecked是否只能为 0 或 1;- SKU 是否存在;
- 并发期间 DTO 是否被其他请求更新。
SKU 不存在时,item 为 null,下一行存在空指针触发路径。
15. 删除单个 SKU
源码:CartServiceImpl#deleteCart。
public void deleteCart(Long skuId, String userId) { RMap<Long, CartInfoDTO> cart = redissonClient.getMap(buildKey(userId)); cart.remove(skuId);}这是最接近单个 Redis 命令语义的业务操作:按 field 删除,不必先遍历列表。
16. 删除全部已选 SKU
源码:CartServiceImpl#deleteChecked。
public void deleteChecked(String userId) { RMap<Long, CartInfoDTO> cart = redissonClient.getMap(buildKey(userId));
Iterator<Map.Entry<Long, CartInfoDTO>> iterator = cart.entrySet().iterator();
while (iterator.hasNext()) { Map.Entry<Long, CartInfoDTO> entry = iterator.next(); if (entry.getValue().getIsChecked() == 1) { iterator.remove(); } }}它不是 Lua 或一次批量 field 删除,而是远程遍历并逐项删除。
当前 Redisson 版本下远程迭代器的具体并发表现没有运行验证。
17. 单命令与业务原子性
| 操作 | 远程步骤 | 整体原子 |
|---|---|---|
| 删除单个 SKU | remove | 接近单命令语义 |
| 新 SKU 入车 | Feign + 转换 + put | 否 |
| 已有 SKU 加购 | containsKey + get + put | 否 |
| 修改勾选 | get + 修改 + put | 否 |
| 删除已选 | 遍历 + 多次删除 | 否 |
并发加购示例:
初始数量 1A 读取 1B 读取 1A 写入 2B 写入 2最终 2,而非 3问题来自多步读改写,不是单次 put 不原子。
18. TTL 的真实状态
项目声明 USER_CART_EXPIRE = 30 天,但 CartServiceImpl 中没有看到 expire 或带 TTL 的写入。
因此准确表述是:
- 30 天常量存在;
- 当前购物车业务没有使用它;
- 是否有外部任务设置或清理,未验证。
不能说购物车当前会自动在 30 天后过期。
19. 失败矩阵
| 场景 | 当前静态结果 |
|---|---|
| 两种身份都缺失 | 可能访问 user:cart:null |
skuNum <= 0 | 仍参与数量计算 |
| 商品 Feign 失败 | 新条目不写入,请求异常 |
| 商品返回空 | 交给 MapStruct,结果未运行验证 |
| Redis 写失败 | 请求失败,无本地备份 |
| 勾选不存在的 SKU | 存在空指针路径 |
updateTime 为空 | 列表排序可能失败 |
isChecked 为空 | 删除已选比较可能自动拆箱失败 |
20. 已确认限制与候选风险
20.1 已确认限制
- 身份和
skuNum没有统一校验。 - 已有 SKU 加购采用多步读改写。
- 已有 SKU 不刷新商品快照。
- 勾选不存在的 SKU 没有空值保护。
- 删除已选不是一次批量操作。
- 购物车没有设置代码级 TTL。
- 两个价格字段后续使用不一致。
20.2 候选风险
| 风险 | 触发机制 | 缺失证据 |
|---|---|---|
| 并发加购丢数量 | 两个请求读取旧 DTO 后覆盖 | 未并发压测 |
| 空身份共享购物车 | 多请求落到相同空 key | 未观察真实请求头 |
| Redis 数据长期堆积 | 没有 TTL 调用 | 未观察 key 生命周期 |
| 价格快照过旧 | 已有 SKU 不重新查商品 | 未构造调价场景 |
21. 如果重做(非当前实现)
- 在入口要求正式或临时身份至少存在一个;
- 校验数量为正并设置合理上限;
- 对用户与 SKU 粒度使用原子更新、锁或版本控制;
- 批量删除已选 field;
- 明确购物车 TTL 与活跃续期规则;
- 收敛为单一购物车价格字段,并保留订单实时核价。
22. 关键源码导航
| 阅读问题 | 项目相对路径 | 类/方法 |
|---|---|---|
| Redis 常量 | duolaimall-common/common-service/src/main/java/com/cskaoyan/mall/common/constant/RedisConst.java | 购物车 key 与 TTL |
| DTO | duolaimall-cart/cart-api/src/main/java/com/cskaoyan/mall/cart/api/dto/CartInfoDTO.java | CartInfoDTO |
| 前台入口 | duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/controller/CartController.java | 添加、列表、勾选、删除 |
| RMap 业务 | duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/service/impl/CartServiceImpl.java | buildKey、CRUD 方法 |
| 商品 Feign | duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/client/ProductApiClient.java | getSkuInfo、getSkuPrice |
| 快照转换 | duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/converter/SkuInfoConverter.java | skuInfoToCartInfo |
23. 短复习点
- 一种身份对应一个 RMap,field 是 SKU ID,value 是购物车快照。
- 新 SKU 调商品服务,已有 SKU 只累加数量并覆盖 DTO。
- RMap 单命令原子,不代表多步业务流程原子。
- 30 天 TTL 只是常量,当前业务没有实际设置。
cartPrice与skuPrice的使用不一致需要单独说明。
24. 一句话总结
多来买用 Redisson RMap 实现按身份隔离、按 SKU 定位的购物车,CRUD 链路清晰,但身份校验、多步并发更新、TTL 和价格字段语义仍是代码已确认的边界。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!