购物车服务设计:基于 Redis Hash 的高效实现
在电商系统中,购物车是用户操作最频繁的模块之一。它需要支持高并发读写 、实时数量更新 、商品信息动态变化等特性。本文将深入剖析如何利用 Redis 的 Hash 数据结构来构建购物车服务,涵盖数据模型设计、核心操作流程、命令详解、并发控制以及常见问题(如大 Key、合并购物车)的解决方案,并辅以流程图与代码示例。
一、购物车服务核心流程概览
购物车通常提供以下功能:
- 添加商品到购物车(若已存在则增加数量)
- 修改商品数量
- 删除购物车中的商品
- 选中/取消选中商品
- 查询购物车列表
- 清空购物车
整体业务流如下:
添加/修改数量
删除
查询
清空
用户操作购物车
操作类型
Add/Update
Delete
Query
Clear
更新 Redis Hash
HDEL 删除字段
HGETALL 获取全车
DEL 删除整个 Key
返回最新购物车
二、为什么选择 Redis Hash?
2.1 常见方案对比
| 数据结构 | 存储方式 | 优点 | 缺点 |
|---|---|---|---|
| String(JSON) | 整个购物车序列化为一个字符串 | 实现简单 | 修改一个商品需全量读写,存在并发覆盖风险 |
| List | 每个商品作为一个元素 | 可有序存储 | 修改/删除需遍历,效率低 |
| Hash | 用户 ID 为 Key,SKU ID 为 Field,商品详情为 Value | 可单独操作 Field,原子性支持增减 | 单 Key 下 Field 过多可能导致大 Key |
| Set | 商品 ID 为元素 | 去重方便 | 无法存储数量、选中状态等附加信息 |
结论 :Hash 结构在灵活性(支持单字段操作)、性能(O(1) 的读写)、原子性(HINCRBY)上表现最佳,是购物车服务的首选。
2.2 Redis Hash 关键概念
-
大 Key(Big Key) :Redis 中的 Key,对应一个用户的购物车。
命名规范:
cart:user:{userId}大 Key 可能包含成百上千个 Field(商品),需注意性能问题(详见第五部分)。
-
小 Key(Field) :Hash 内部的字段,对应商品的 SKU ID (库存单位唯一标识)。
使用 SKU ID 作为 Field 可以快速定位商品。
-
Value:存储该商品的购物项详情,通常是一个 JSON 字符串或 Hash 结构(推荐 JSON),包含:
skuId(商品 ID)skuName(商品名称)price(单价)quantity(数量)selected(是否选中,1/0)image(图片 URL)stock(库存状态,可实时查商品服务)
实际存储示例(Redis 命令行):
bash
> HSET cart:user:1001 123456 '{"skuId":123456,"skuName":"iPhone 14","price":5999,"quantity":2,"selected":1}'
(integer) 1
> HSET cart:user:1001 123457 '{"skuId":123457,"skuName":"AirPods Pro","price":1899,"quantity":1,"selected":0}'
(integer) 1
三、核心 CRUD 操作及 Redis 命令
假设我们使用 Spring Data Redis(或 Jedis/Lettuce)进行操作,以下为典型命令映射。
3.1 添加商品到购物车(Add / Increment)
场景:用户点击"加入购物车",若商品已存在,则增加数量;否则新增商品项。
Redis 命令 :HINCRBY 或 HSET + 读后写。
推荐使用 HINCRBY 对数量进行原子增减,再更新其他字段。
java
public void addToCart(Long userId, Long skuId, Integer delta) {
String cartKey = "cart:user:" + userId;
String field = skuId.toString();
// 1. 原子增加数量(如果 field 不存在,则从 0 开始)
Long newQuantity = redisTemplate.opsForHash()
.increment(cartKey, field, delta);
// 2. 如果数量 <= 0,则删除该商品项
if (newQuantity <= 0) {
redisTemplate.opsForHash().delete(cartKey, field);
return;
}
// 3. 若是首次添加(增量前为0),需要补充商品详情
if (newQuantity == delta) {
// 远程调用商品服务获取 SKU 详情
SkuInfo sku = productService.getSkuInfo(skuId);
CartItem item = CartItem.builder()
.skuId(skuId)
.skuName(sku.getSkuName())
.price(sku.getPrice())
.quantity(newQuantity.intValue())
.selected(1)
.image(sku.getImage())
.build();
// 将完整对象存入 Hash
redisTemplate.opsForHash().put(cartKey, field, JSON.toJSONString(item));
} else {
// 已存在:只更新数量字段,其他字段保留
String oldJson = (String) redisTemplate.opsForHash().get(cartKey, field);
CartItem item = JSON.parseObject(oldJson, CartItem.class);
item.setQuantity(newQuantity.intValue());
redisTemplate.opsForHash().put(cartKey, field, JSON.toJSONString(item));
}
}
对应 Redis 命令组合:
HINCRBY cart:user:1001 123456 1→ 数量 +1HSET cart:user:1001 123456 '{"..."}'→ 首次写入完整对象HDEL cart:user:1001 123456→ 数量减到 0 时删除
3.2 修改商品数量(Update)
与添加逻辑类似,只需传入正或负的 delta,复用上述方法。
3.3 删除购物车商品(Delete)
java
public void removeCartItem(Long userId, Long skuId) {
String cartKey = "cart:user:" + userId;
redisTemplate.opsForHash().delete(cartKey, skuId.toString());
}
命令:HDEL cart:user:1001 123456
3.4 设置选中/取消选中(Update selected flag)
需要读取当前 JSON,修改 selected 字段后写回。
java
public void selectCartItem(Long userId, Long skuId, boolean selected) {
String cartKey = "cart:user:" + userId;
String field = skuId.toString();
String oldJson = (String) redisTemplate.opsForHash().get(cartKey, field);
if (oldJson != null) {
CartItem item = JSON.parseObject(oldJson, CartItem.class);
item.setSelected(selected ? 1 : 0);
redisTemplate.opsForHash().put(cartKey, field, JSON.toJSONString(item));
}
}
3.5 查询购物车列表(Query)
java
public List<CartItem> getCartList(Long userId) {
String cartKey = "cart:user:" + userId;
Map<Object, Object> entries = redisTemplate.opsForHash().entries(cartKey);
List<CartItem> cartItems = new ArrayList<>();
for (Object value : entries.values()) {
cartItems.add(JSON.parseObject((String) value, CartItem.class));
}
// 可按添加时间排序(若需顺序,可额外维护 ZSet)
return cartItems;
}
命令:HGETALL cart:user:1001
3.6 清空购物车(Clear)
java
public void clearCart(Long userId) {
String cartKey = "cart:user:" + userId;
redisTemplate.delete(cartKey);
}
命令:DEL cart:user:1001
四、添加与查询购物车的详细流程图
4.1 添加购物车流程图
存在
不存在
用户请求添加商品
从 ThreadLocal 获取当前用户 ID
构建 cart:user:userId
Redis Hash 中
是否存在该 skuId?
HINCRBY 增加数量
更新数量字段并写回 Hash
返回成功
远程调用商品服务获取 SKU 详情
组装 CartItem JSON
HSET 写入 Hash
4.2 查询购物车流程图
是
否
用户查看购物车
获取用户 ID
执行 HGETALL 命令
解析 JSON 字符串为 CartItem 对象
需要实时验价/验库存?
批量调用商品服务获取最新价格/库存
合并到购物车数据
直接返回
返回购物车列表
五、关键问题与最佳实践
5.1 大 Key 问题
问题 :一个用户的购物车可能包含数百个 SKU。HGETALL 会一次性拉取所有 Field,占用网络带宽和内存,甚至导致 Redis 阻塞(单线程模型)。
解决方案:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 分页查询 | 使用 HSCAN 命令或维护一个辅助 ZSet(按时间排序)分页获取 Field |
购物车商品较多(>100) |
| 限制最大容量 | 限制单个购物车最多 50 件商品,超出提示 | 大部分电商采用 |
| 异步加载 | 前端先展示骨架,后台分批加载 | 体验优先 |
| 压缩 Value | 使用 Protobuf 等压缩格式减小 Value 体积 | 节省内存 |
推荐做法:在添加时检查当前购物车商品数量,超过阈值(如 99)后拒绝新增并给出提示。
5.2 数量增减的并发安全
HINCRBY 是原子操作,天然解决并发增减的数量覆盖问题。但注意:首次添加时,如果并发两个请求同时执行 HINCRBY(field 不存在),Redis 会正确累加,但两个请求都可能触发远程调用并 HSET 覆盖,导致商品详情被重复写入(不会错,但浪费资源)。可通过分布式锁或检查 newQuantity == delta 时再次确认进行优化。
5.3 购物车过期时间
用户登录后,购物车应持久保存,但未登录用户的临时购物车可以设置过期时间(如 30 天)。对于已登录用户,不推荐设置 TTL(用户可能数月后再次购买)。可借助 Redis 的 EXPIRE 命令设置非活跃淘汰:
java
redisTemplate.expire(cartKey, Duration.ofDays(30));
5.4 未登录购物车与登录后合并
常见流程:
- 未登录时,购物车存储在 前端 LocalStorage 或 后端临时 Key(基于设备 ID 或 Token)。
- 用户登录后,后端将临时购物车与 Redis 中的登录购物车合并(按 SKU 累加数量,删除临时车)。
- 合并使用 Lua 脚本保证原子性。
5.5 商品信息变更(价格、上下架)
商品价格变动后,购物车中显示的价格应如何处理?有两种策略:
- 实时拉取:每次查询购物车时,批量请求商品服务获取最新价格(增加服务调用压力,但保证准确)。
- 异步更新:商品服务通过 MQ 发布价格变更消息,购物车服务监听后,遍历所有用户购物车中该 SKU 的 field,更新价格(大规模更新代价高,可采用延迟批量更新)。
推荐折中方案:购物车中存储商品快照价格,同时在展示时提供"价格已更新"提示,并允许用户点击刷新。实际企业多采用实时拉取(配合多级缓存)。
六、完整代码示例(Spring Boot + RedisTemplate)
java
@Service
public class CartService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ProductServiceClient productServiceClient;
private static final String CART_KEY_PREFIX = "cart:user:";
public void addItem(Long userId, Long skuId, Integer delta) {
String key = CART_KEY_PREFIX + userId;
String field = String.valueOf(skuId);
Long newQty = redisTemplate.opsForHash().increment(key, field, delta);
if (newQty <= 0) {
redisTemplate.opsForHash().delete(key, field);
return;
}
// 首次添加需要补全信息
if (newQty == delta) {
SkuDTO sku = productServiceClient.getSku(skuId);
CartItem item = new CartItem(skuId, sku.getTitle(), sku.getPrice(),
newQty.intValue(), 1, sku.getImage());
redisTemplate.opsForHash().put(key, field, JsonUtils.toJson(item));
} else {
// 更新数量
String json = (String) redisTemplate.opsForHash().get(key, field);
CartItem item = JsonUtils.fromJson(json, CartItem.class);
item.setQuantity(newQty.intValue());
redisTemplate.opsForHash().put(key, field, JsonUtils.toJson(item));
}
}
public List<CartItem> getCartList(Long userId) {
String key = CART_KEY_PREFIX + userId;
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
List<CartItem> list = new ArrayList<>();
for (Object v : entries.values()) {
list.add(JsonUtils.fromJson((String) v, CartItem.class));
}
// 可选:批量填充最新价格
batchFillRealTimePrice(list);
return list;
}
private void batchFillRealTimePrice(List<CartItem> items) {
// 调用商品服务批量获取价格
}
}
七、总结
| 要素 | 推荐方案 | 关键命令/技术 |
|---|---|---|
| 数据结构 | Redis Hash | HSET, HGET, HINCRBY, HDEL, HGETALL |
| 添加商品 | 原子增减 + 首次补全详情 | HINCRBY, HSET |
| 修改数量 | HINCRBY 支持正负数 |
无需额外查询 |
| 查询购物车 | HGETALL 注意大 Key 问题 |
加数量限制或分页 |
| 数量并发安全 | Redis 原子操作 | HINCRBY |
| 登录合并 | 临时车 + 永久车合并 | Lua 脚本遍历累加 |
| 商品信息同步 | 实时批量查询 + 本地缓存 | 多级缓存 + 消息队列 |
基于 Redis Hash 的购物车实现,简单高效,满足了电商购物车高并发、低延迟的核心要求。在实际生产环境中,还需结合监控(大 Key 检测)、降级(商品服务超时走快照数据)、分页查询等优化手段,才能支撑大规模用户稳定运行。