4、Redis核心应用:缓存策略、分布式锁、计数器、队列

Redis 在抢单系统里,可以说是「万能工具箱」。

我做了这么多年高并发系统,Redis 几乎参与了每个环节。缓存热点数据、锁住共享资源、统计实时数据、异步处理请求…… 说白了,没有 Redis,抢单系统根本扛不住。

这一章,我把 Redis 最核心的四个应用场景拆开讲。每个场景我都踩过坑,也总结了一套「最佳实践」。你跟着走一遍,基本不会出大问题。

4.1 缓存策略:别让数据库被打穿

抢单系统里,商品详情、库存数量、活动配置…… 这些数据读多写少。如果每次都查数据库,MySQL 瞬间就跪了。

我的习惯是:能缓存就缓存,但缓存要有策略

4.1.1 缓存什么?

  • 商品基本信息:名称、价格、图片 URL —— 几乎不变,缓存时间可以很长(比如 1 小时)。
  • 库存数量:变化频繁,但读请求极高 —— 用 Redis 原子操作,缓存时间短(比如 5 秒)。
  • 活动配置:开始时间、结束时间、限购规则 —— 活动期间不变,缓存到活动结束。

4.1.2 缓存模式:Cache-Aside

我个人最推荐 Cache-Aside 模式。流程很简单:

  1. 读请求先查 Redis。
  2. 命中则直接返回。
  3. 未命中则查数据库,然后写入 Redis,再返回。

写请求呢?先更新数据库,再删除缓存。注意,是「删除」不是「更新」。为什么?因为并发写时,更新缓存容易产生脏数据。删除缓存,下次读的时候自然会把新数据拉进去。

核心原则:缓存是数据库的「影子」,不是「副本」。影子脏了,扔掉重做;副本脏了,越改越乱。

4.1.3 避坑指南:缓存穿透与雪崩

缓存穿透:请求的数据在数据库里也不存在。每次请求都穿透 Redis 打到数据库。

我曾经遇到过一个案例:恶意用户用不存在的商品 ID 疯狂请求,数据库连接池瞬间被打满。解决方案很简单:布隆过滤器。把所有合法 ID 提前加载到布隆过滤器里,请求来了先过过滤器,不存在的直接返回 null。

缓存雪崩:大量缓存同时过期,请求全部打到数据库。

我的做法是:过期时间加随机值。比如基础过期时间 1 小时,再随机加 0~300 秒。这样缓存不会同时失效。

// 伪代码:设置缓存过期时间加随机值
int baseExpire = 3600;
int randomExpire = new Random().nextInt(300);
redis.set(key, value, baseExpire + randomExpire);

4.2 分布式锁:别让两个人抢到同一单

抢单系统里,最怕的就是「超卖」。两个人同时抢最后一个库存,如果不用锁,两个人都能成功下单。

分布式锁,就是解决这个问题的。

4.2.1 基于 Redis 的分布式锁

我最早用 Redis 做分布式锁时,直接用 SETNX 加锁,然后 EXPIRE 设置过期时间。但有个问题:如果 SETNX 成功,程序挂了,EXPIRE 没执行,锁就永远不释放。

后来 Redis 官方推荐用 SET key value NX EX seconds 一条命令搞定。原子操作,不会出问题。

// 加锁:一条命令搞定
String lockKey = "lock:product:1001";
String requestId = UUID.randomUUID().toString();
Boolean locked = redis.set(lockKey, requestId, "NX", "EX", 10);

// 解锁:用 Lua 脚本保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

小技巧:解锁时一定要用 Lua 脚本。因为「判断锁持有者」和「删除锁」是两个操作,不原子的话,可能删掉别人的锁。

4.2.2 锁的粒度

锁的粒度越细,并发越高。我习惯按「商品 ID」加锁,而不是按「活动 ID」。比如商品 1001 和 1002 的抢购互不影响。

4.2.3 避坑指南:锁超时与续期

锁的过期时间设短了,业务没执行完锁就释放了;设长了,万一程序挂了,锁要等很久才释放。

我的做法是:使用 Redisson 的看门狗机制。它会自动给锁续期,默认每 10 秒续一次,直到业务完成。你想想看,这比自己手动续期靠谱多了。

4.3 计数器:实时统计不丢数据

抢单系统里,计数器无处不在:

  • 商品已售数量
  • 用户抢购次数
  • 活动参与人数

用 MySQL 做计数器?并发一高,行锁就炸了。Redis 的 INCR 命令,原子操作,性能极高。

4.3.1 库存扣减

库存扣减是计数器最典型的应用。我习惯用 DECR 命令:

// 扣减库存,返回剩余数量
Long stock = redis.decr("stock:product:1001");
if (stock >= 0) {
    // 扣减成功,生成订单
} else {
    // 库存不足,回滚
    redis.incr("stock:product:1001");
}

注意DECR 返回负数时,一定要回滚。否则库存会变成负数,用户看到「-1 件」就尴尬了。

4.3.2 限流计数器

防止用户刷单,可以用计数器做限流。比如每个用户每秒最多抢 3 次:

// 用户限流:key 为 user:limit:userId:timestamp
String key = "user:limit:" + userId + ":" + System.currentTimeMillis() / 1000;
Long count = redis.incr(key);
if (count == 1) {
    redis.expire(key, 1); // 设置 1 秒过期
}
if (count > 3) {
    return "请求太频繁";
}

4.4 队列:异步处理,削峰填谷

抢单瞬间,请求量可能是平时的几百倍。如果所有请求都同步处理,系统扛不住。

我的策略是:用 Redis List 做消息队列,异步处理订单

4.4.1 生产者:入队

// 抢单成功,将订单信息推入队列
String orderJson = JSON.toJSONString(order);
redis.lpush("queue:order", orderJson);

4.4.2 消费者:出队

// 后台线程不断从队列中取数据
while (true) {
    String orderJson = redis.brpop("queue:order", 5);
    if (orderJson != null) {
        // 处理订单:校验、入库、通知
        processOrder(orderJson);
    }
}

为什么用 brpop 而不是 rpop? brpop 是阻塞式读取,队列为空时线程休眠,不浪费 CPU。我习惯设置超时时间 5 秒,避免线程一直阻塞。

4.4.3 队列的可靠性

Redis List 做队列有个问题:如果消费者处理失败,数据就丢了。我的解决方案是:使用 Redis Stream。它支持消费者组、消息确认、消息持久化。说白了,就是 Redis 版的 Kafka。

// 生产者:添加消息到 Stream
redis.xadd("stream:order", Map.of("orderId", "12345", "userId", "67890"));

// 消费者:读取消息并确认
List<MapRecord<String, Object, Object>> messages = redis.xreadGroup(
    "group1", "consumer1", 
    ReadOffset.lastConsumed(), 
    Count.of(10), 
    BlockDuration.of(5000)
);
for (MapRecord<String, Object, Object> msg : messages) {
    processOrder(msg.getValue());
    redis.xack("stream:order", "group1", msg.getId()); // 确认消息
}

知识体系总览

下面这张图,是我梳理的 Redis 在抢单系统中的核心应用逻辑。你看一眼,基本就明白整体架构了。

Redis 在抢单系统中的核心应用 Redis 核心应用 缓存策略 分布式锁 计数器 队列 Cache-Aside 布隆过滤器 SET NX EX Lua 解锁 INCR/DECR 限流计数器 List + brpop Stream + 确认 缓存扛读 · 锁扛写 · 计数器扛统计 · 队列扛异步

嗯,Redis 这四个核心应用,基本覆盖了抢单系统 90% 的并发场景。你只要把缓存策略、分布式锁、计数器、队列这四块吃透,系统性能至少提升一个数量级。

一句话总结:缓存扛读,锁扛写,计数器扛统计,队列扛异步。各司其职,系统才能稳如泰山。


公众号:蓝海资料掘金营,微信deep3321