购物车是电商系统里最基础的功能之一,看似简单,实际上藏着不少设计细节。其中最典型的两个问题就是:游客身份与登录身份的购物车如何合并,以及闲置购物车如何做过期清理。很多团队第一版方案直接用MySQL存购物车,结果发现读写压力全落在数据库上,后来切换到Redis方案。本文把一套经过验证的Redis购物车方案完整拆解,包括存储结构设计、登录合并流程、过期处理策略以及常见的坑。

购物车的Redis存储结构设计
推荐使用Hash结构存储购物车,key为购物车标识,field为商品SKU ID,value存储一个JSON对象,包含数量、勾选状态、加入时间等信息。这种结构的好处是可以针对单个商品做增删改查,而不需要把整个购物车读出来再写回去。
游客购物车与用户购物车要分开存储,通常用不同的key前缀区分。游客用设备ID或临时token标识,登录用户用用户ID标识。这样在用户登录时,只需要处理两个key的合并,逻辑边界非常清晰。
// 购物车key设计
// 游客购物车:cart:guest:{deviceId}
// 用户购物车:cart:user:{userId}
// Hash的field为skuId,value为JSON
// 例如:{"count":2,"checked":true,"addTime":1718000000000}
public String cartKey(boolean isLogin, String identity) {
return isLogin ? "cart:user:" + identity : "cart:guest:" + identity;
}
public void addToCart(String key, long skuId, int count) {
String raw = redisTemplate.opsForHash().get(key, String.valueOf(skuId));
int newCount = count;
if (raw != null) {
CartItem item = JSON.parseObject(raw, CartItem.class);
newCount = item.getCount() + count; // 数量累加
}
CartItem item = new CartItem();
item.setCount(newCount);
item.setChecked(true);
item.setAddTime(System.currentTimeMillis());
redisTemplate.opsForHash().put(key, String.valueOf(skuId), JSON.toJSONString(item));
}
需要注意value的JSON不要塞太多字段,特别是不要把商品快照信息(标题、图片、价格)全部存进去。商品信息应该在下单或展示时实时查询,购物车里只存SKU ID和数量等必要信息,否则商品改价或改图后购物车里的快照就是过期数据,还容易把Hash撑成大key。
登录后的购物车合并策略
合并发生在用户登录成功的时刻。整体流程是:检查是否存在游客购物车,如果不存在直接结束;如果存在且用户购物车为空,直接把游客key重命名为用户key,这是最高效的情况;如果两边都有数据,则需要逐个SKU合并,处理数量累加和覆盖策略。
public void mergeCart(String deviceId, long userId) {
String guestKey = "cart:guest:" + deviceId;
String userKey = "cart:user:" + userId;
Map<Object, Object> guestCart = redisTemplate.opsForHash().entries(guestKey);
if (guestCart.isEmpty()) {
return; // 游客购物车为空,无需合并
}
Long userSize = redisTemplate.opsForHash().size(userKey);
if (userSize == null || userSize == 0) {
// 用户购物车为空,直接改key,性能最优
redisTemplate.rename(guestKey, userKey);
return;
}
// 两边都有数据,逐项合并:用户侧数量优先或累加
for (Map.Entry<Object, Object> entry : guestCart.entrySet()) {
String skuId = (String) entry.getKey();
CartItem guestItem = JSON.parseObject((String) entry.getValue(), CartItem.class);
String userRaw = (String) redisTemplate.opsForHash().get(userKey, skuId);
if (userRaw == null) {
redisTemplate.opsForHash().put(userKey, skuId, JSON.toJSONString(guestItem));
} else {
CartItem userItem = JSON.parseObject(userRaw, CartItem.class);
// 策略一:数量累加;策略二:以游客侧为准覆盖
userItem.setCount(userItem.getCount() + guestItem.getCount());
userItem.setAddTime(System.currentTimeMillis());
redisTemplate.opsForHash().put(userKey, skuId, JSON.toJSONString(userItem));
}
}
redisTemplate.delete(guestKey); // 合并完成,删除游客购物车
}
合并时有一个必须处理的细节:合并完成后一定要删除游客key,否则用户退出登录后又会看到旧的游客购物车,出现数据错乱。另外合并操作建议加分布式锁或者放入消息队列异步执行,防止用户短时间内多次登录登出触发并发合并,导致数量重复累加。一个简单做法是以userId为锁键,合并期间持有锁,重试失败的请求直接放弃合并。
合并后还应该做一次库存校验。游客加购时可能没有做严格的库存判断,合并到用户购物车后,如果数量超过库存上限,需要在返回给前端的购物车列表里标记出来,引导用户修改数量,而不是等到下单时才报错。
购物车过期处理的三种方案
方案一:key级别TTL
最直接的做法是给整个购物车key设置过期时间,比如30天。用户每次操作购物车时刷新TTL,实现只要用户有活跃就不过期,长期不操作自动清理。缺点是粒度太粗,用户加了一个新商品,其它闲置商品的过期时间也被刷新了。对于一般电商场景这个缺点可以接受,实现成本最低。
public void refreshExpire(String key) {
// 每次操作后刷新过期时间为30天
redisTemplate.expire(key, 30, TimeUnit.DAYS);
}
方案二:字段级别过期模拟
Redis的Hash结构不支持对单个field设置TTL,如果需要做到商品级别的过期,就要自己模拟。常见做法是在每个商品的value里存一个lastActiveTime字段,然后通过定时任务扫描购物车,把超过阈值的field删除。扫描时要用HSCAN增量遍历,严禁使用HGETALL一次性读取,避免大key阻塞Redis。
public void cleanExpiredItems(String key, long maxIdleMillis) {
long now = System.currentTimeMillis();
ScanOptions options = ScanOptions.scanOptions().count(100).build();
try (Cursor<Map.Entry<Object, Object> cursor = redisTemplate.opsForHash().scan(key, options)) {
List<Object> toDelete = new ArrayList<>();
while (cursor.hasNext()) {
Map.Entry<Object, Object> entry = cursor.next();
CartItem item = JSON.parseObject((String) entry.getValue(), CartItem.class);
if (now - item.getLastActiveTime() > maxIdleMillis) {
toDelete.add(entry.getKey());
}
}
if (!toDelete.isEmpty()) {
redisTemplate.opsForHash().delete(key, toDelete.toArray());
}
}
}
这种方案还能顺带清理下架商品:扫描时调用商品服务查询SKU状态,把已下架的商品一并从购物车移除,一举两得。缺点是需要维护定时任务,且扫描期间商品如果正在被并发修改,要注意HDEL操作只会删除field本身,不会误伤新写入的数据。
方案三:惰性删除结合有序集合
更精细的做法是用ZSet维护一个过期索引,member为购物车key加skuId的组合,score为最后活跃时间戳。定时任务只需要ZRANGEBYSCORE取出过期的成员,再去对应的Hash里执行HDEL,避免全量扫描。这种方案适合购物车规模很大的系统,代价是每次操作购物车都要多写一个ZSet,写放大略有增加。
落地时的常见坑
第一个坑是大key问题。如果用户是批发型买家,购物车里塞了几千个SKU,Hash体积会很大,HGETALL和删除操作都可能造成Redis阻塞。预防措施包括限制单个购物车最大商品数(比如200个),超限提示用户清理;删除大key时使用UNLINK代替DEL,让删除操作异步执行。
第二个坑是数据丢失兜底。Redis是内存存储,一旦故障可能导致购物车数据丢失,对用户感知很强。建议开启AOF持久化,并定期把购物车数据异步落库到MySQL作为兜底,用户登录后如果Redis无数据,可以从数据库恢复。
第三个坑是购物车数据与价格的一致性。购物车里存的数量只代表用户意愿,展示价格必须实时获取。下单时要以订单时刻的价格和库存为准做最终校验,购物车数据永远不能作为价格的信任来源。
总结一下,Redis购物车方案的核心在于三点:用Hash配合合理的key设计支撑高效读写,用重命名加逐项合并处理登录场景,用TTL加扫描任务兜底过期清理。按这套思路实现,能覆盖绝大多数电商业务的购物车需求,同时保持良好的性能和可维护性。