用 Hash 加版本号实现购物车增减的并发安全

购物车是电商系统里最容易出现并发写冲突的数据结构之一:同一个用户可能在 App、小程序、Web 三端同时操作,也可能因为网络重试导致同一个「加购 +1」请求被提交两次。如果直接对 Redis Hash 里的 quantity 字段做 HINCRBY,数字本身不会写坏,但业务规则会被绕过——比如「单 SKU 最多 99 件」的限购、「购物车最多 50 个 SK

购物车是电商系统里最容易出现并发写冲突的数据结构之一:同一个用户可能在 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 的原子性只保证「不写坏」,不保证调用方能判断「我这次写入是否基于最新状态」。有了版本号,应用层可以做三件事:

  1. 幂等:请求带 expectedVer,如果服务端当前 ver 已经大于它,说明该请求已处理过或已过期,直接返回当前状态。
  2. 乐观锁:需要「读—算—写」的复杂逻辑(如按促销规则重算整单)可以携带版本号提交,版本不匹配就重试。
  3. 变更溯源:下游按 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 商品快照,校验通过后再执行脚本。极端情况下仍可能加进去一个刚下架的商品,由下单预校验兜底拦截,这比把状态塞进购物车脚本更简单也更可靠。