购物车是电商小程序里访问频率最高、对延迟最敏感的模块之一。用户每点一次"加入购物车"都直接写数据库,高峰期数据库行锁竞争明显,接口耗时会被拉长;完全依赖缓存,又要面对缓存宕机、数据不一致的风险。本文记录我们在生产环境落地购物车模块时,采用"缓存为主、数据库兜底"双写方案的完整过程,包括数据结构选型、双写一致性处理,以及三个踩过的真实坑点。
一、需求拆解与基本约束
先明确购物车要支持的核心能力:
- 加入商品、修改数量、删除商品、勾选结算;
- 商品有多个 SKU(规格),同一 SKU 在购物车中只能有一条记录;
- 用户多端登录(小程序、H5)时购物车数据要一致;
- 商品下架、价格变动后,购物车里的展示要能感知。
基本约束有两个:一是读写比极高,读远多于写;二是购物车数据允许短暂不一致,但不允许丢失------用户明明加了货,结算时却找不到,是不可接受的事故。
二、数据结构设计
2.1 缓存层:Hash 结构
缓存选用 Redis,购物车用 Hash 存储,Key 为 cart:{userId},field 为 SKU 标识,value 为购物车项序列化内容:
java
public void addToCart(Long userId, CartItemDTO item) {
String key = "cart:" + userId;
String field = "sku:" + item.getSkuId();
// 先查缓存里是否已存在该SKU
Object exist = redisTemplate.opsForHash().get(key, field);
if (exist != null) {
CartItem old = parse(exist);
item.setQuantity(old.getQuantity() + item.getQuantity());
}
item.setUpdateTime(System.currentTimeMillis());
redisTemplate.opsForHash().put(key, field, toJson(item));
redisTemplate.expire(key, Duration.ofDays(30));
}
用 Hash 而不是把整个购物车序列化成一个 String,原因是修改单个 SKU 时不用读改写整个大对象,并发修改不同 SKU 也不会互相覆盖。
购物车项内容包含:SKU ID、SPU ID、数量、加入时快照价格、勾选状态、加入时间。注意数量在服务端合并,而不是信任小程序端传上来的最终数量------前端传"加几件",服务端取原值累加,可以挡住客户端重放导致的数量异常。
2.2 数据库层
数据库保留一张购物车表做兜底,结构很朴素:
sql
CREATE TABLE cart_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
spu_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
quantity INT NOT NULL,
checked TINYINT NOT NULL DEFAULT 1,
snapshot_price DECIMAL(10,2),
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_user_sku (user_id, sku_id),
KEY idx_user (user_id)
);
uk_user_sku 唯一约束是兜底防线,配合 INSERT ... ON DUPLICATE KEY UPDATE 保证同一用户同一 SKU 只有一行。
三、双写一致性:先更缓存,再异步落库
我们采用的策略是:写请求先更新缓存保证用户立刻看到,再通过消息队列异步写数据库,而不是同步双写。同步双写在缓存写成功、数据库写失败时会出现不一致,且数据库抖动会直接拖慢购物车接口。
java
public void addToCart(Long userId, CartItemDTO item) {
String key = "cart:" + userId;
String field = "sku:" + item.getSkuId();
// 1. 更新缓存(Lua脚本保证读合并写原子)
cartRedisService.mergeAndPut(key, field, item);
// 2. 发变更事件,由消费者写库
CartChangeEvent event = new CartChangeEvent(userId, item.getSkuId(),
item.getQuantity(), ChangeType.ADD);
mqProducer.send("cart-change", event);
}
消费者侧写库做幂等处理,同一条消息重复投递不会产生脏数据:
java
public void onMessage(CartChangeEvent event) {
int rows = cartMapper.upsert(event.getUserId(), event.getSkuId(),
event.getQuantity(), event.getSnapshotPrice());
if (rows == 0) {
log.warn("cart upsert affected 0 rows, uid={} sku={}",
event.getUserId(), event.getSkuId());
}
}
对应的 SQL:
sql
INSERT INTO cart_item (user_id, sku_id, spu_id, quantity, snapshot_price, create_time, update_time)
VALUES (#{userId}, #{skuId}, #{spuId}, #{quantity}, #{price}, NOW(), NOW())
ON DUPLICATE KEY UPDATE
quantity = VALUES(quantity),
snapshot_price = VALUES(snapshot_price),
update_time = NOW();
3.1 读路径:缓存未命中才回源
正常情况下购物车从缓存读;缓存未命中(比如 Redis 故障恢复后冷数据丢失)再查数据库并回填:
java
public List<CartItem> getCart(Long userId) {
String key = "cart:" + userId;
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
if (!entries.isEmpty()) {
return entries.values().stream().map(this::parse).toList();
}
// 回源数据库
List<CartItem> dbItems = cartMapper.selectByUser(userId);
if (!dbItems.isEmpty()) {
Map<String, String> reload = dbItems.stream()
.collect(Collectors.toMap(
i -> "sku:" + i.getSkuId(),
this::toJson));
redisTemplate.opsForHash().putAll(key, reload);
redisTemplate.expire(key, Duration.ofDays(30));
}
return dbItems;
}
四、三个实战踩坑点
坑1:并发加购同 SKU,数量被覆盖
最初实现是"先 get 再 put"两步走。压测时发现,两个请求几乎同时加入同一 SKU,各自读到旧值再写回,后写覆盖先写,数量少算。根因是读和写不是原子操作。
解决办法是把"读合并写"放进一段 Lua 脚本,在 Redis 单线程内一次执行完:
lua
local key = KEYS[1]
local field = ARGV[1]
local payload = ARGV[2]
local addQty = tonumber(ARGV[3])
local old = redis.call('HGET', key, field)
if old then
local decoded = cjson.decode(old)
decoded['quantity'] = decoded['quantity'] + addQty
decoded['updateTime'] = tonumber(ARGV[4])
redis.call('HSET', key, field, cjson.encode(decoded))
else
redis.call('HSET', key, field, payload)
end
redis.call('EXPIRE', key, 2592000)
这里还有个细节:cjson.encode 在某些环境会把中文转成 Unicode 转义序列,回端后需要统一解码,否则商品名显示异常。
坑2:消息消费失败,缓存与数据库长期漂移
异步落库依赖 MQ,如果消费者报错且没有重试,缓存里的新数据永远进不了库,一旦下次缓存未命中回源,用户会看到"旧购物车"。
我们的处理是三层保障:消费失败走消息中间件的重试;超过重试次数进入死信队列,由定时任务扫描死信并告警;另外加一个每日凌晨的对账任务,抽样比对缓存与数据库的数量差异,输出差异清单。实践中对账任务抓出过两起因历史代码异常导致的零星漂移,没有让它流到用户侧。
坑3:商品价格、下架状态在快照里"凝固"
购物车里存的是加入时的快照价格,如果商品后来调价或下架,购物车页面仍然显示旧信息,结算时才暴露问题,用户体验很差。
我们的做法是购物车查询返回前,批量拿 SKU ID 集合去查一次商品中心的实时数据(走本地缓存,TTL 设得很短),在响应里标记出三类状态:价格变动(展示现价与划线价)、已下架(置灰禁选)、库存不足(限制可结算数量)。购物车表中的快照价只用于后台追溯,不直接作为前台展示依据。批量查询而不是循环单个查,是为了避免几十条购物车项把商品接口打成 N 次调用。
五、结算勾选状态的一个补充设计
勾选状态存在购物车项里,但结算接口不能信任前端回传的勾选列表。正确做法是前端只传选中的 SKU ID,服务端重新从购物车缓存取数量、从商品中心取现价和库存,重算一遍金额。这样即使用户改了本地请求参数,结算金额也以服务端实时计算为准,防篡改、防超卖。
六、小结
购物车模块的设计可以归纳成三句话:
- 缓存用 Hash 承载高频读写,单 SKU 改动用 Lua 保证原子;
- 数据库通过唯一约束 + upsert 做兜底,异步双写配合重试、死信和对账守住一致性;
- 价格与库存状态在读取时实时校准,结算金额永远由服务端重算。
这套方案在我们的压测环境中,单节点购物车写入可以稳定支撑到数千 QPS,缓存与数据库的差异通过每日对账始终保持在可发现、可修复的范围内,上线后没有再出现过"加购丢失"类客诉。