管理主页分类

拖动分类调整顺序,并选择是否在主页显示。设置仅保存在当前浏览器。

  • 微服务课件 31
  • Java 基础 28
  • Java Web 开发 25
  • 多来买 18
  • LeetCode 题解 8
  • 开发工具 8
  • 网络工具 2
  • 黑马头条 2
  • Java 记忆恢复 1

多来买:Redis RMap 购物车实战

2350 字
12 分钟
多来买: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-serviceController、RMap 业务和商品 Feign
duolaimall-product新 SKU 入车时提供商品快照和价格
Redis / Redisson保存按身份隔离的购物车 Map
Gateway把登录或临时身份透传给购物车服务

3. CRUD 主链路#

sequenceDiagram participant C as 客户端 participant CC as CartController participant CS as CartServiceImpl participant R as Redis RMap participant P as Product Service C->>CC: add(skuId, skuNum) CC->>CS: addToCart(skuId, identity, skuNum) CS->>R: containsKey(skuId) alt 已存在 CS->>R: get(skuId) CS->>R: put(数量累加后的 DTO) else 新 SKU CS->>P: getSkuInfo(skuId) P-->>CS: SkuInfoDTO CS->>CS: MapStruct 转 CartInfoDTO CS->>R: put(skuId, dto) end C->>CC: 查询、勾选或删除 CC->>CS: 对应方法 CS->>R: values/get/put/remove

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.javabuildKey

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一个正式或临时身份
fieldskuId
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.javaCartInfoDTO

@Data
public 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;
}
字段组字段作用
归属userIdDTO 创建时使用的身份
商品快照skuIdimgUrlskuName列表展示避免逐项远程查询
交易快照两个价格、数量、勾选展示、核价和下单协作
时间创建、更新时间合并取新值和列表排序

DTO 是快照,不是商品事实源;订单仍会调用商品服务核验实时价格。

8. 两个价格字段#

新条目创建时:

cartPrice = 商品当前价格
skuPrice = 商品当前价格

后续代码用途不同:

  • 订单交易页计算使用 skuPrice
  • 购物车转订单明细也使用 skuPrice
  • refreshCartPrice 只更新 cartPrice

这是当前代码的字段不一致,不应自行解释为已经形成清晰的“展示价/结算价”设计。

9. 前台添加入口#

源码:duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/controller/CartController.javaaddCart

@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.javaProductApiClient

@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.javaskuInfoToCartInfo

@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 不存在时,itemnull,下一行存在空指针触发路径。

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. 单命令与业务原子性#

操作远程步骤整体原子
删除单个 SKUremove接近单命令语义
新 SKU 入车Feign + 转换 + put
已有 SKU 加购containsKey + get + put
修改勾选get + 修改 + put
删除已选遍历 + 多次删除

并发加购示例:

初始数量 1
A 读取 1
B 读取 1
A 写入 2
B 写入 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. 如果重做(非当前实现)#

  1. 在入口要求正式或临时身份至少存在一个;
  2. 校验数量为正并设置合理上限;
  3. 对用户与 SKU 粒度使用原子更新、锁或版本控制;
  4. 批量删除已选 field;
  5. 明确购物车 TTL 与活跃续期规则;
  6. 收敛为单一购物车价格字段,并保留订单实时核价。

22. 关键源码导航#

阅读问题项目相对路径类/方法
Redis 常量duolaimall-common/common-service/src/main/java/com/cskaoyan/mall/common/constant/RedisConst.java购物车 key 与 TTL
DTOduolaimall-cart/cart-api/src/main/java/com/cskaoyan/mall/cart/api/dto/CartInfoDTO.javaCartInfoDTO
前台入口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.javabuildKey、CRUD 方法
商品 Feignduolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/client/ProductApiClient.javagetSkuInfogetSkuPrice
快照转换duolaimall-cart/cart-service/src/main/java/com/cskaoyan/mall/cart/converter/SkuInfoConverter.javaskuInfoToCartInfo

23. 短复习点#

  1. 一种身份对应一个 RMap,field 是 SKU ID,value 是购物车快照。
  2. 新 SKU 调商品服务,已有 SKU 只累加数量并覆盖 DTO。
  3. RMap 单命令原子,不代表多步业务流程原子。
  4. 30 天 TTL 只是常量,当前业务没有实际设置。
  5. cartPriceskuPrice 的使用不一致需要单独说明。

24. 一句话总结#

多来买用 Redisson RMap 实现按身份隔离、按 SKU 定位的购物车,CRUD 链路清晰,但身份校验、多步并发更新、TTL 和价格字段语义仍是代码已确认的边界。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

多来买:Redis RMap 购物车实战
https://firefly-mu-weld.vercel.app/posts/duolaimai-cart-redis-rmap/
作者
Daisy
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录