购物车是电商系统里最容易出现并发写冲突的数据结构之一:同一个用户可能在 App、小程序、Web 三端同时操作,也可能因为网络重试导致同一个「加购 +1」请求被提交两次。如果直接对 Redis Hash 里的 quantity 字段做 HINCRBY,数字本身不会写坏,但业务规则会被绕过——比如「单 SKU 最多 99 件」的限购、「购物车最多 50 个 SKU」的容量限制、「下架商品不允许加购」的状态校验,在高并发下都会失效。更麻烦的是购物车变更需要同步到下游(订单预校验、库存占用、推荐系统),如果变更事件乱序,下游看到的可能是「先 -1 再 +1」的错误序列。
本文给出一套可落地的方案:用 Redis Hash 存储购物车条目,并在同一个 Hash 内维护一个单调递增的 ver 版本号,所有写操作都通过 Lua 脚本在 Redis 单线程内完成「校验 — 变更 — 版本 +1 — 返回新版本」的原子过程。业务侧拿到版本号后可以做乐观并发控制、幂等去重和变更顺序判定。下面从数据结构设计讲到完整可运行的代码、压测验证和线上排错。
为什么单靠 HINCRBY 不够
先明确问题边界。假设购物车结构是 cart:{userId} 这个 Hash,field 为 sku:{skuId},value 为数量。最简单的实现是:
HINCRBY cart:1001 sku:8801 1
这条命令确实是原子的,但把业务校验放在应用层就立刻出问题:
- 限购穿透:两个请求同时读到
quantity=98,都判断「98+1 ≤ 99 通过」,然后各自HINCRBY,最终变成 100,超出限购。 - 容量超卖:
HLEN判断「当前 49 个 SKU,还能加」,两个请求同时通过,结果是 51 个。 - 重复加购:客户端超时重试,同一个
requestId被执行两次,数量多加 1。 - 删改竞争:用户在一端删除 SKU,另一端同时
HINCRBY,删除后又被凭空创建出一条数量为 1 的记录。
常见的补救办法是加分布式锁(SET lock:{userId} NX PX 3000),但它带来三个新成本:锁等待增加尾延迟;锁超时与业务执行时间难以对齐;释放锁需要 Lua 比对 token,实现复杂且容易写错。对于购物车这种「单 Key 内小范围修改」的场景,Lua 脚本是更轻的选择——Redis 执行 Lua 期间不会插入其他命令,天然串行化。
版本号则解决另一个维度的问题:Redis 的原子性只保证「不写坏」,不保证调用方能判断「我这次写入是否基于最新状态」。有了版本号,应用层可以做三件事:
- 幂等:请求带
expectedVer,如果服务端当前ver已经大于它,说明该请求已处理过或已过期,直接返回当前状态。 - 乐观锁:需要「读—算—写」的复杂逻辑(如按促销规则重算整单)可以携带版本号提交,版本不匹配就重试。
- 变更溯源:下游按
ver排序消费变更消息,避免乱序覆盖。
数据结构设计
版本号不能放在单独的 String Key 里,否则「改数量」和「改版本」就是两次操作,中间可能被打断(即使包在 Lua 里也需要多一次 Key 访问,且容易和其他脚本产生交叉)。正确做法是把版本号放进同一个 Hash,用一个不会与 SKU field 冲突的保留名。
| Key | 类型 | 说明 |
|---|---|---|
cart:{1001} |
Hash | 购物车主结构,{1001} 为 hash tag,保证集群下同一用户落在同一 slot |
__ver__ |
Hash field | 单调递增版本号,初值 0 |
__meta__ |
Hash field | JSON 字符串,存 updatedAt、lastOpId,用于幂等与审计 |
sku:8801 |
Hash field | SKU 条目,value 为纯数字数量字符串 |
几个关键取舍:
- value 只存数量。价格、标题、库存等易变信息不要冗余进购物车,否则一次调价要全量刷新购物车。需要展示时用
HMGET批量拿 SKU 快照,或走本地缓存。 - 保留 field 用双下划线包裹。SKU ID 是纯数字或
sku:前缀,不会撞名。读取时用HGETALL后在应用层过滤掉__开头的 field。 - 必须用 hash tag。
cart:{1001}的花括号让 CRC16 只计算1001,保证同一用户的 Hash 与后续可能用到的cart:lock:{1001}在同一 slot,否则集群模式下 Lua 脚本跨 slot 会直接报CROSSSLOT Keys in request don't hash to the same slot。 - 不设 TTL 或用惰性过期。购物车需要长期保留,用
EXPIRE在每次写操作时续期 30 天即可(HSET不会自动续期,必须在脚本里显式调用)。
Lua 脚本:校验、变更、版本 +1 一次完成
核心脚本接收操作类型、SKU、数量、限购上限、容量上限、期望版本号,返回 {code, newVer, currentQty}。所有分支都在脚本内判定,不产生「检查后失效」的窗口。
-- cart_op.lua
-- KEYS[1] = cart:{userId}
-- ARGV[1] = op : "add" | "set" | "del" | "get"
-- ARGV[2] = skuField : e.g. "sku:8801"
-- ARGV[3] = delta : 数量增量或目标值(del/get 时忽略)
-- ARGV[4] = maxPerSku : 单 SKU 限购上限,0 表示不限
-- ARGV[5] = maxSkuCount : 购物车 SKU 容量上限,0 表示不限
-- ARGV[6] = expectedVer : 期望版本号,-1 表示不做校验
-- ARGV[7] = ttlSeconds : 续期秒数,0 表示不续期
-- 返回 {code, newVer, currentQty}
-- code: 0 成功, 1 版本冲突, 2 超单 SKU 限购, 3 超容量, 4 条目不存在, 5 非法数量
local key = KEYS[1]
local op = ARGV[1]
local skuField = ARGV[2]
local delta = tonumber(ARGV[3])
local maxPerSku = tonumber(ARGV[4])
local maxSkuCnt = tonumber(ARGV[5])
local expectedVer= tonumber(ARGV[6])
local ttl = tonumber(ARGV[7])
local VER_FIELD = '__ver__'
local META_FIELD = '__meta__'
local curVer = tonumber(redis.call('HGET', key, VER_FIELD)) or 0
if op == 'get' then
local q = tonumber(redis.call('HGET', key, skuField)) or 0
return {0, curVer, q}
end
if expectedVer >= 0 and curVer ~= expectedVer then
local q = tonumber(redis.call('HGET', key, skuField)) or 0
return {1, curVer, q}
end
if delta ~= delta or delta == nil then
return {5, curVer, 0}
end
local curQty = tonumber(redis.call('HGET', key, skuField))
local newQty
if op == 'del' then
if curQty == nil then
return {4, curVer, 0}
end
redis.call('HDEL', key, skuField)
newQty = 0
elseif op == 'add' then
if delta <= 0 then
return {5, curVer, curQty or 0}
end
newQty = (curQty or 0) + delta
if curQty == nil then
-- 新增条目时需要校验容量(不含 __ver__ / __meta__)
if maxSkuCnt > 0 then
local total = redis.call('HLEN', key)
if redis.call('HEXISTS', key, VER_FIELD) == 1 then total = total - 1 end
if redis.call('HEXISTS', key, META_FIELD) == 1 then total = total - 1 end
if total >= maxSkuCnt then
return {3, curVer, 0}
end
end
end
if maxPerSku > 0 and newQty > maxPerSku then
return {2, curVer, curQty or 0}
end
redis.call('HSET', key, skuField, newQty)
elseif op == 'set' then
if delta < 0 then
return {5, curVer, curQty or 0}
end
if delta == 0 then
redis.call('HDEL', key, skuField)
newQty = 0
else
if curQty == nil and maxSkuCnt > 0 then
local total = redis.call('HLEN', key)
if redis.call('HEXISTS', key, VER_FIELD) == 1 then total = total - 1 end
if redis.call('HEXISTS', key, META_FIELD) == 1 then total = total - 1 end
if total >= maxSkuCnt then
return {3, curVer, 0}
end
end
if maxPerSku > 0 and delta > maxPerSku then
return {2, curVer, curQty or 0}
end
redis.call('HSET', key, skuField, delta)
newQty = delta
end
else
return {5, curVer, curQty or 0}
end
local newVer = redis.call('HINCRBY', key, VER_FIELD, 1)
redis.call('HSET', key, META_FIELD,
cjson.encode({updatedAt = redis.call('TIME')[1], op = op, sku = skuField}))
if ttl > 0 then
redis.call('EXPIRE', key, ttl)
end
return {0, newVer, newQty}
几个容易踩坑的实现细节:
- 不要用
redis.call('TIME')以外的随机源。Redis 5 之前的 Lua 环境里math.random需要math.randomseed,且脚本被要求确定性(虽然 5.0 起默认redis.replicate_commands()已放宽),用TIME最稳妥。 HLEN减 2 是容量校验的关键,忘了减会把版本号和 meta 当成两个 SKU,导致容量提前耗尽。用HEXISTS判断而不是硬编码减 2,是因为首次写入前这两个 field 可能不存在。- 返回 table 而非字符串。Redis 会把 Lua table 转成 RESP array,客户端拿到的就是整数数组,省去一次序列化。注意 Lua 数字返回时是整数,不要塞浮点。
- 脚本里不要
KEYS *或SCAN,会阻塞 Redis 主线程。
Java 客户端落地(Spring Data Redis + Lettuce)
把脚本注册成 DefaultRedisScript,通过 StringRedisTemplate 执行。注意 List 类型的返回需要显式指定 ReturnType.MULTI,否则会拿到 List<Long> 之外的奇怪类型。
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
@Component
public class CartRepository {
private static final RedisScript<List> CART_OP;
static {
DefaultRedisScript<List> script = new DefaultRedisScript<>();
script.setLocation(new org.springframework.core.io.ClassPathResource("lua/cart_op.lua"));
script.setResultType(List.class);
CART_OP = script;
}
private final StringRedisTemplate redis;
public CartRepository(StringRedisTemplate redis) {
this.redis = redis;
}
public static final class Result {
public final int code; // 0 成功 1 版本冲突 2 超限购 3 超容量 4 不存在 5 非法
public final long version;
public final long quantity;
Result(int code, long version, long quantity) {
this.code = code;
this.version = version;
this.quantity = quantity;
}
public boolean ok() { return code == 0; }
}
private Result exec(String userId, String op, String skuField,
long delta, long maxPerSku, long maxSkuCount,
long expectedVer, long ttlSeconds) {
String key = "cart:{" + userId + "}";
List<?> raw = redis.execute(CART_OP,
Arrays.asList(key),
op,
skuField,
String.valueOf(delta),
String.valueOf(maxPerSku),
String.valueOf(maxSkuCount),
String.valueOf(expectedVer),
String.valueOf(ttlSeconds));
if (raw == null || raw.size() < 3) {
throw new IllegalStateException("cart_op returned unexpected: " + raw);
}
return new Result(
((Number) raw.get(0)).intValue(),
((Number) raw.get(1)).longValue(),
((Number) raw.get(2)).longValue());
}
public Result add(String userId, long skuId, long delta,
long maxPerSku, long maxSkuCount, long expectedVer) {
return exec(userId, "add", "sku:" + skuId, delta,
maxPerSku, maxSkuCount, expectedVer, 30L * 24 * 3600);
}
public Result set(String userId, long skuId, long qty,
long maxPerSku, long maxSkuCount, long expectedVer) {
return exec(userId, "set", "sku:" + skuId, qty,
maxPerSku, maxSkuCount, expectedVer, 30L * 24 * 3600);
}
public Result remove(String userId, long skuId, long expectedVer) {
return exec(userId, "del", "sku:" + skuId, 0,
0, 0, expectedVer, 30L * 24 * 3600);
}
public Result get(String userId, long skuId) {
return exec(userId, "get", "sku:" + skuId, 0,
0, 0, -1, 0);
}
}
对应的 application.yml 关键配置(Lettuce 下脚本默认走 EVALSHA,首次失败自动回退 EVAL):
spring:
redis:
host: 10.20.30.40
port: 6379
password: ${REDIS_PASSWORD}
timeout: 2000ms
lettuce:
pool:
max-active: 64
max-idle: 32
min-idle: 8
max-wait: 500ms
shutdown-timeout: 200ms
client-name: cart-service
控制器层处理限购与版本冲突,把业务语义翻译成 HTTP 状态:
@PostMapping("/cart/{skuId}/add")
public ResponseEntity<?> add(@PathVariable long skuId,
@RequestParam(defaultValue = "1") long delta,
@RequestParam(defaultValue = "-1") long expectedVer) {
String userId = currentUserId();
CartRepository.Result r = cartRepository.add(userId, skuId, delta, 99, 50, expectedVer);
switch (r.code) {
case 0:
return ResponseEntity.ok(Map.of("ver", r.version, "qty", r.quantity));
case 1:
return ResponseEntity.status(409)
.body(Map.of("err", "VERSION_CONFLICT", "ver", r.version, "qty", r.quantity));
case 2:
return ResponseEntity.status(422).body(Map.of("err", "EXCEED_LIMIT_PER_SKU"));
case 3:
return ResponseEntity.status(422).body(Map.of("err", "CART_FULL"));
case 4:
return ResponseEntity.status(404).body(Map.of("err", "ITEM_NOT_FOUND"));
default:
return ResponseEntity.badRequest().body(Map.of("err", "INVALID_ARGUMENT"));
}
}
注意 expectedVer = -1 表示「不校验版本,纯原子累加」,适合绝大多数「+1 / -1」场景;只有当客户端基于旧快照做整单重算时才需要传具体版本号。
版本号带来的三个附加能力
1. 幂等去重
客户端每次加购带上自己的 requestId,服务端在 __meta__ 里记录 lastOpId。更严格的做法是单独用一个 SET cart:idem:{requestId} 1 NX EX 600,命中则直接返回当前购物车快照。版本号在这里的作用是:重试请求返回的 ver 与首次一致时,调用方能确认「状态没变」,可以安全地复用缓存结果。
2. 变更事件有序
每次写操作成功后,把 {userId, ver, op, sku, qty} 投递到消息队列。下游消费者维护「每个 userId 已处理的最大 ver」,收到 ver ≤ maxVer 的消息直接丢弃。这解决了一个真实的生产问题:加购与删除的消息因为重试被乱序投递,导致推荐系统把用户刚删掉的商品一直推荐。用 ver 做单调水位线比用时间戳可靠,因为 Redis 单实例内 HINCRBY 的单调性由单线程保证,不受各应用节点时钟漂移影响。
3. 乐观锁重算
促销活动需要「读取整个购物车 → 按规则重算 → 写回」。此时读出的 ver 就是 CAS 的期望值,写回时若返回 code=1,说明期间有其他端在改,重新读取重试即可。重试建议最多 3 次并加 10~30ms 退避,超过则返回「购物车繁忙,请重试」,避免自旋打爆 Redis。
public boolean recomputeWithRetry(String userId, int maxAttempts) {
for (int i = 0; i < maxAttempts; i++) {
CartSnapshot snap = loadSnapshot(userId); // 含 ver
List<Item> items = promoEngine.apply(snap.items);
boolean conflict = false;
for (Item it : items) {
CartRepository.Result r = cartRepository.set(
userId, it.skuId, it.qty, 99, 50, snap.version);
if (r.code == 1) { conflict = true; break; }
}
if (!conflict) return true;
try { Thread.sleep(10L + (long) (Math.random() * 20)); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; }
}
return false;
}
压测验证与线上排错
用 redis-benchmark 直接评估脚本开销(脚本需先 SCRIPT LOAD 拿到 SHA):
# 预热并拿到 sha
SHA=$(redis-cli SCRIPT LOAD "$(cat cart_op.lua)")
echo $SHA
# 100 并发、共 20 万次 add 操作,单 SKU 限购 99 会在超限后返回 2,这里用大限购值压测
redis-benchmark -h 10.20.30.40 -a "$REDIS_PASSWORD" -c 100 -n 200000 -P 1 \
EVALSHA $SHA 1 'cart:{bench}' add 'sku:1' 1 100000 100000 -1 2592000
本地单机(8C16G,Redis 7.2)实测该脚本在 100 并发下约 6~8 万 QPS,单次 P99 在 1.5ms 量级,与裸 HINCRBY 的差距主要来自 HLEN/HEXISTS 和 cjson.encode。如果 __meta__ 的 JSON 编码成为热点,可以降级为只写 updatedAt 数字,省掉 cjson。
并发正确性用一段 JUnit 验证(限购 99,200 个线程各 +1,最终必须是 99,且 ver 恰好等于成功次数):
@Test
void concurrentAddHonorsLimit() throws Exception {
String userId = "u" + System.nanoTime();
int threads = 200;
ExecutorService pool = Executors.newFixedThreadPool(32);
CountDownLatch start = new CountDownLatch(1);
AtomicInteger success = new AtomicInteger();
List<Future<?>> fs = new ArrayList<>();
for (int i = 0; i < threads; i++) {
fs.add(pool.submit(() -> {
start.await();
CartRepository.Result r = repo.add(userId, 1L, 1, 99, 50, -1);
if (r.ok()) success.incrementAndGet();
return null;
}));
}
start.countDown();
for (Future<?> f : fs) f.get(10, TimeUnit.SECONDS);
pool.shutdown();
CartRepository.Result q = repo.get(userId, 1L);
assertEquals(99, q.quantity);
assertEquals(99, success.get());
assertEquals(99, q.version);
}
线上常见的报错与处理:
| 报错信息 | 根因 | 处理 |
|---|---|---|
CROSSSLOT Keys in request don't hash to the same slot |
脚本传入多个 Key 且没有统一 hash tag | 所有相关 Key 统一写成 cart:{userId} 形式,只传一个 Key 时也必须带花括号以便后续扩展 |
NOSCRIPT No matching script. Please use EVAL. |
Redis 重启或 SCRIPT FLUSH 后脚本缓存丢失,客户端仍发 EVALSHA |
Spring Data Redis 会自动回退 EVAL;自研客户端需捕获 NOSCRIPT 后重新 SCRIPT LOAD 并重试一次 |
BUSY Redis is busy running a script |
脚本执行超过 lua-time-limit(默认 5000ms) |
检查脚本里是否误用了大范围遍历;确认后可用 SCRIPT KILL(仅限未写数据的脚本) |
ERR Error compiling script ...: user_script:12: attempt to compare nil with number |
ARGV 传了空串或非数字,tonumber 返回 nil |
应用层统一 String.valueOf(long),脚本内对 tonumber 结果做 nil 兜底 |
OOM command not allowed when used memory > 'maxmemory' |
购物车 Key 没有过期策略,内存打满 | 脚本内 EXPIRE 已续期,另需监控 used_memory 并配置 maxmemory-policy allkeys-lru(不能设 noeviction) |
还有两个容易忽略的点。其一,__meta__ 里的 updatedAt 用的是 Redis 服务器时间,多实例时钟不一致不影响它,但如果你打算用它做业务排序,记得和数据库时间对齐。其二,购物车合并(登录时把游客购物车并入账号购物车)需要对两个 Key 都加版本控制:先读游客车快照与 ver,再对目标车逐条 add 并带目标车 expectedVer,冲突则重读;合并完成后删除游客车,删除前用 WATCH 或再次比对 ver 防止合并期间又有新加购丢失。
小结
把版本号塞进购物车 Hash 的收益不只是「并发安全」四个字:所有业务校验(限购、容量、存在性)都被收进 Lua 的原子区间,应用层不再需要分布式锁;ver 同时充当幂等凭据、变更水位线和乐观锁令牌,一份数据支撑三种用途,额外存储成本只有一个 field。代价是脚本需要仔细写,尤其是 HLEN 的保留字段扣减和 tonumber 的 nil 处理。真正上线前,务必用并发单测验证限购边界,用 redis-benchmark 确认 P99 在可接受范围,并把 NOSCRIPT、CROSSSLOT 两类报错纳入告警。
FAQ
Q:为什么不直接用 RedisJSON 或 Redis 的 HSET + WATCH/MULTI?
WATCH 是乐观锁,冲突后需要客户端重试整个事务,高并发下重试率随冲突数上升而恶化,且 WATCH 期间连接被占用。Lua 脚本在服务端串行执行,无重试开销。RedisJSON 需要额外模块,且 JSON.NUMINCRBY 同样绕不过应用层校验的问题,仍要配 Lua。
Q:版本号会不会溢出?
HINCRBY 是 64 位有符号整数,即使每秒 100 万次写也要约 29 万年才溢出,实际不会遇到。但 Java 侧务必用 long 而不是 int 接收。
Q:多个 SKU 要一起改怎么办?
两种情况分开处理。批量加购(一次加 5 个 SKU)应该在同一个脚本里循环处理,任一 SKU 校验失败就整体回滚——但 Redis 脚本不支持回滚,所以做法是「先全量校验,全部通过再写入」,脚本内分两趟循环。整单重算则适合上面的乐观锁重试模式,不适合塞进单个脚本。
Q:集群模式下脚本里能访问多个 Key 吗?
可以,但所有 Key 必须落在同一 slot,即共享同一个 hash tag。这也是设计上坚持用 cart:{userId} 而不是 cart:1001 的原因。
Q:版本号需要暴露给前端吗?
加购、改数量这类增量操作不需要,传 -1 即可。只有当页面存在「用户编辑数量后提交」的表单场景,才把读取时拿到的 ver 一并带回,用于检测「提交期间购物车已被其他端修改」,此时返回 409 让前端提示刷新。
Q:商品下架了怎么保证不能被加购?
下架状态不适合放在购物车 Hash 里(要全量同步)。推荐在加购链路前加一层商品状态校验:本地缓存(Caffeine,TTL 1~3 秒)加 Redis 商品快照,校验通过后再执行脚本。极端情况下仍可能加进去一个刚下架的商品,由下单预校验兜底拦截,这比把状态塞进购物车脚本更简单也更可靠。