Skip to content

Redis 并发读写、一致性与高并发场景实战

很多人一提到 Redis 并发读写,第一反应是:Redis 不是单线程吗,为什么还会有并发问题?

这个问题问得很典型,因为它正好卡在 Redis 最容易被误解的地方。

Redis 并发读写真正要讨论的,不是:Redis 内部会不会像多线程程序那样频繁加锁。

而是:

  1. 多个客户端同时读写同一个 Key 或同一批 Key,会不会出现竞态
  2. 缓存、数据库、消息队列这些组件交错时,状态还能不能保持一致
  3. 高并发流量打过来时,Redis 能不能既扛住压力,又不把后端系统拖垮

所以这篇文章会按下面这条主线展开:

  1. Redis 并发读写到底在说什么
  2. Redis 为什么看起来“不容易冲突”
  3. Redis 的并发问题真正出在哪里
  4. 常见场景为什么会出错
  5. 工程上通常怎么治理

1. Redis 并发读写到底在说什么

这里的“并发读写”,不是只指 Redis 自己内部的一条命令如何执行,而是指:多个请求、多个线程、多个服务实例,在时间上重叠地访问同一份 Redis 状态。

例如:

  1. 两个请求同时扣减同一个商品库存
  2. 大量请求同时读取一个刚过期的热点 Key
  3. 一个服务刚更新数据库,另一个服务正在从缓存读取旧值
  4. 多个应用实例同时竞争同一把分布式锁

所以 Redis 并发读写的核心问题,通常不是“命令能不能执行成功”,而是:

  1. 结果对不对
  2. 顺序稳不稳
  3. 时序交错后会不会留下脏状态

要注意的是:Redis 经常处在系统的高频访问入口,所以并发问题一旦出现,放大的速度会非常快。


2. Redis 为什么看起来不容易出并发问题

2.1 单条命令通常是原子的

原子 可以理解成“要么完整执行,要么根本不执行,中间不会被别的命令插进来”。

例如:

  1. INCR
  2. DECR
  3. HINCRBY
  4. SET key value NX EX 30

这类单条命令在 Redis 执行路径里通常是原子的。

这意味着:如果你的业务动作刚好能被一条 Redis 命令表达出来,它通常就不容易在 Redis 内部被并发打乱。

2.2 命令执行模型相对简单

Redis 之所以常被认为“并发问题少”,一个重要原因是它的命令执行模型比较简单。

很多高频命令不会像传统多线程共享内存程序那样,在应用代码层面到处争抢锁。

所以大家会产生一种直觉:Redis 天生就很稳。

这个直觉只对了一半。

2.3 真正的边界:原子的是单条命令,不是你的业务流程

这是必须单独拎出来强调的一点。

Redis 能保证的,通常是:单条命令的执行原子性。

它不能自动替你保证:

  1. 客户端先读再写的多步流程是原子的
  2. 两个 Key 之间的复杂业务关系天然一致
  3. Redis 和 MySQL 双写一定没有时序问题

所以不要把下面两句话混成一件事:

  1. Redis 单条命令通常是原子的
  2. 我的业务读写流程天然线程安全

这两者不是一个概念。


3. Redis 的并发问题真正出在哪里

3.1 客户端多步操作

最典型的问题是:

  1. 先读一个值
  2. 在应用里做判断
  3. 再写回 Redis

只要这 3 步不是一次性在 Redis 侧完成,中间就会暴露并发窗口。

例如库存是 1

  1. 线程 A 读到 1
  2. 线程 B 也读到 1
  3. A 判断还能扣,准备写回 0
  4. B 也判断还能扣,准备写回 0

结果看起来都“执行成功”了,但业务已经超卖。

3.2 多个 Key 之间的关系

有些业务不是改一个 Key 就完事,而是同时涉及:

  1. 库存 Key
  2. 用户资格 Key
  3. 订单去重 Key
  4. 限流计数 Key

这时真正难的不是某个 Key 能不能改,而是:这些 Key 之间的状态变更,能不能按照同一个业务规则一起推进。

3.3 Redis 和外部系统交错

Redis 很少孤立存在,它通常会和:

  1. MySQL
  2. 消息队列
  3. 本地缓存
  4. 多个应用实例

一起工作。

这时最常见的问题就变成:Redis 自己没错,但跨组件的时序打架了。

例如数据库已经更新了,缓存还没删;或者缓存刚失效,大量请求一起回源;或者 Redis 预扣成功了,但数据库落单失败了。

3.4 热点放大效应

Redis 常处在高并发入口,一旦某个 Key 特别热,问题会被迅速放大:

  1. 一个错误的回填逻辑,会被放大成大量脏缓存
  2. 一个失效的热点 Key,会带来瞬时回源洪峰
  3. 一个设计不好的大 Key,会把单线程执行路径拖慢

所以 Redis 的并发问题往往不是“偶尔错一下”,而是:一旦出错,传播速度和影响范围都很大。


4. 🌟 最容易混淆的一个点:Redis 单线程,不等于业务天然无并发问题

这句话值得单独记住:Redis 的单线程命令执行模型,解决的是服务端命令调度复杂度问题;它不等于自动解决客户端业务层面的竞态。

可以把它拆成两层理解:

4.1 Redis 内部视角

在 Redis 内部,单条命令执行时通常不会被另一条命令打断,所以像 INCR 这种操作天然比较稳。

4.2 业务系统视角

但在业务侧,常常是很多服务实例、很多线程、很多请求同时发命令。

如果你把业务流程拆成:

  1. GET
  2. 本地判断
  3. SET

那么并发问题依然会出现,因为两个请求都可能在读到旧值后,做出同样的判断。

所以 Redis 的单线程模型,更适合帮助我们得出一个结论:尽量把关键读写逻辑压缩成单条命令,或者放到 Lua 脚本里一次完成。

而不是得出这个错误结论:反正 Redis 是单线程,我在客户端怎么拆步骤都安全。


5. 🌟 缓存和数据库双写为什么会不一致

这是 Redis 并发读写里最常见的一类问题。

典型场景是:

  1. 先更新数据库
  2. 再删除缓存
  3. 但删除缓存前,另一个请求刚好读到了旧缓存

或者:

  1. 缓存刚好失效
  2. 读请求回源数据库,拿到旧值
  3. 写请求更新数据库
  4. 旧值又被回填进缓存

这类问题的本质不是 Redis 自己“写错了”,而是:缓存和数据库是两套状态源,读写交错时一定会出现时序窗口。

常见治理思路

  1. 更新数据库后删除缓存
  2. 延迟双删
  3. 异步重建缓存
  4. 对强一致读绕过缓存

更稳妥的理解

更新数据库后删除缓存 通常是最常见的基础方案,因为数据库才是主数据源。

但它也不是绝对零窗口。

如果业务对一致性特别敏感,就不能只停留在“删缓存”这一层,还要继续考虑:

  1. 关键读是否回主库
  2. 是否需要消息驱动的异步订正
  3. 是否需要版本号或时间戳控制旧值回填

6. 延迟双删为什么不是万能方案

延迟双删通常是:

  1. 更新数据库
  2. 删除缓存
  3. 过一小段时间再删一次缓存

它主要在补救下面这种情况:

  1. 第一次删缓存之后
  2. 并发读请求又把旧值写回来了
  3. 第二次删除把这份脏缓存再清掉

这个思路有工程价值,但它不是严格一致性的保证,原因主要有:

  1. 延迟时间很难完全贴合真实业务时序
  2. 并发模型可能比预想更复杂
  3. 第二次删除本身也可能失败
  4. 旧值回填不一定刚好发生在预设窗口内

所以更稳妥的表述是:延迟双删是在降低脏缓存残留概率,而不是彻底消灭所有不一致窗口。


7. 🌟 热点 Key 失效后为什么会把数据库打穿

这就是典型的缓存击穿问题。

场景通常像这样:

  1. 某个商品详情或首页配置是热点 Key
  2. 它刚好过期
  3. 大量请求同时进来
  4. 大家一起回源数据库

真正危险的地方不是“某一次缓存没命中”,而是:所有请求在同一时刻一起回源。

常见治理思路

  1. 热点 Key 预热
  2. 互斥锁或单飞控制
  3. 逻辑过期
  4. 本地缓存 + Redis 多级缓存

工程上更自然的理解

如果一个 Key 特别热,就不应该按普通缓存那种“到点一起过期”的方式去管理。

更常见的做法是:

  1. 让热点数据平滑刷新
  2. 控制同一时刻只有少数请求参与重建
  3. 其他请求先读旧值、兜底值,或者直接降级

8. 秒杀、抢购、扣库存为什么会超卖

这类问题本质上就是典型的并发读写竞态。

最容易出错的写法通常是:

  1. 先查库存
  2. 判断库存大于 0
  3. 再扣减库存

如果这些步骤在客户端分多次完成,就会出现:

  1. 请求 A 读到库存为 1
  2. 请求 B 也读到库存为 1
  3. 两边都认为“还能卖”
  4. 最终库存被重复扣减

常见治理思路

  1. 用 Lua 脚本把“判断 + 扣减”合成一次服务端执行
  2. 用 Redis 先做预扣,再异步落库
  3. 对订单创建、支付链路增加幂等和补偿

为什么 Lua 更适合这一类场景

Lua 可以理解成“在 Redis 服务端执行的一小段脚本”。

它的价值在于:

  1. 把多步操作放到 Redis 一次执行
  2. 减少客户端多次往返
  3. 缩小竞态窗口

所以秒杀这种场景里,真正稳的不是“多发几次 GET/SET”,而是:把关键判断和写入合并成一次原子执行。

一个更贴近工程的 Lua 扣库存示例

下面这个例子只做一件事:只有库存大于等于本次扣减数量时,才真正扣减库存。

lua
-- KEYS[1]: 库存 Key,例如 stock:1001
-- ARGV[1]: 本次要扣减的数量,例如 1

local stock = redis.call('GET', KEYS[1])

-- 库存 Key 不存在
if not stock then
    return -1
end

stock = tonumber(stock)
local deduct = tonumber(ARGV[1])

-- 扣减数量非法
if not deduct or deduct <= 0 then
    return -2
end

-- 库存不足
if stock < deduct then
    return 0
end

-- 库存足够,执行扣减
redis.call('DECRBY', KEYS[1], deduct)
return 1

这个脚本里的几个知识点值得单独记住:

  1. KEYS 用来接收脚本里要操作的 Redis Key
  2. ARGV 用来接收普通参数,例如扣减数量
  3. redis.call(...) 表示在脚本内部继续执行 Redis 命令
  4. 整个脚本会在 Redis 服务端一次执行完,所以中间不会被别的命令插进来

如果约定返回值:

  1. 1 表示扣减成功
  2. 0 表示库存不足
  3. -1 表示库存 Key 不存在
  4. -2 表示参数非法

那么业务层就能比较清楚地分辨失败原因,而不是只得到一个模糊的 true/false。

Spring Boot 里通常怎么调用这段脚本

在 Java 项目里,更常见的做法不是手动拼 EVAL 字符串,而是通过 StringRedisTemplate 执行 DefaultRedisScript

java
/**
 * 库存服务示例,负责调用 Lua 脚本执行原子扣减。
 */
@Service
@RequiredArgsConstructor
public class StockService {

    private final StringRedisTemplate stringRedisTemplate;

    /**
     * 在 Redis 服务端原子完成“查库存 -> 判断 -> 扣减”的 Lua 脚本定义。
     */
    private static final DefaultRedisScript<Long> STOCK_DEDUCT_SCRIPT = createStockDeductScript();

    /**
     * 构造库存扣减 Lua 脚本对象。
     *
     * @return 返回值为 `Long` 的 Lua 脚本定义
     */
    private static DefaultRedisScript<Long> createStockDeductScript() {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setResultType(Long.class);
        script.setScriptText("""
            local stock = redis.call('GET', KEYS[1])
            if not stock then
                return -1
            end

            stock = tonumber(stock)
            local deduct = tonumber(ARGV[1])

            if not deduct or deduct <= 0 then
                return -2
            end

            if stock < deduct then
                return 0
            end

            redis.call('DECRBY', KEYS[1], deduct)
            return 1
            """);
        return script;
    }

    /**
     * 扣减指定商品库存。
     *
     * @param productId 商品 ID
     * @param count 本次扣减数量
     * @return `true` 表示扣减成功;`false` 表示扣减失败
     */
    public boolean deductStock(Long productId, int count) {
        String stockKey = "stock:" + productId;

        Long result = stringRedisTemplate.execute(
            STOCK_DEDUCT_SCRIPT,
            Collections.singletonList(stockKey),
            String.valueOf(count)
        );

        return Long.valueOf(1L).equals(result);
    }
}

这段代码真正想说明的是:

  1. 业务侧不要自己先 GETDECRBY
  2. 应该把“判断 + 写入”一起收口进 Lua
  3. Java 代码只负责传 Key、传参数、解释返回值

但这类脚本也有边界

Lua 脚本很适合做:

  1. 库存扣减
  2. 限流计数
  3. 分布式锁里的校验和释放
  4. 多步原子校验与更新

但不适合把整段复杂业务流程都塞进去。

要注意的是:

  1. Redis 命令主路径本来就怕单次执行过重
  2. Lua 逻辑太长,同样会阻塞后续请求
  3. 它适合处理临界区,不适合承载完整业务编排

所以一个更稳妥的工程原则是:让 Lua 只负责最关键、最短、最需要原子性的那一小段逻辑。


9. 🌟 分布式锁为什么经常“看起来加上了,其实没加稳”

很多人第一次做 Redis 分布式锁,只会写:

  1. SETNX
  2. 设置成功就认为锁拿到了

但真实系统里还会遇到这些问题:

  1. 没设置过期时间,导致死锁
  2. 锁过期后,另一个线程已经拿到了新锁
  3. 第一个线程执行完,又把第二个线程的锁删掉了
  4. 业务执行时间超过锁时长,锁提前失效

更稳妥的治理方式

  1. 原子加锁
  2. 写入唯一标识
  3. 释放锁时做“校验 + 删除”的原子操作
  4. 业务很长时考虑自动续期

真正的难点在哪里

分布式锁真正难的地方不是“怎么加上一把锁”,而是:在超时、重试、网络抖动和多实例交错时,怎么避免误删别人的锁。


10. 多线程并发更新同一个 Key,为什么客户端多步命令容易出问题

典型场景包括:

  1. 记录用户当天请求次数
  2. 先读当前值
  3. 判断是否超过阈值
  4. 再决定是否写回

如果这些步骤拆成多条客户端命令,在并发下就很容易出现:

  1. 多个线程读到同样的旧值
  2. 都认为“还没超限”
  3. 最终一起写回,导致阈值控制失效

常见治理思路

  1. 能用 Redis 原子命令就优先用原子命令
  2. 多步判断放进 Lua
  3. 必要时用 WATCH + MULTI + EXEC 做乐观事务控制
  4. 对关键业务再补上幂等和补偿

这里顺便把 WATCH 解释一下。

WATCH 可以理解成“乐观监听”。

它的意思是:

  1. 先监听某个 Key
  2. 如果事务提交前这个 Key 被别人改了
  3. 当前事务提交就失败

它适合一部分冲突检测场景,但并不等于万能方案。

因为它更像“发现冲突再重试”,不是“天然没有冲突”。


11. 幂等控制为什么也经常和 Redis 绑在一起

典型场景包括:

  1. 防止重复下单
  2. 防止支付回调重复执行
  3. 防止用户重复提交同一请求

这类问题本质上不是“缓存问题”,而是:分布式高并发下,同一业务动作可能被执行多次。

常见治理思路

  1. 给请求生成唯一业务标识
  2. Redis 里记录“是否处理过”
  3. 首次处理成功后写入状态
  4. 后续重复请求直接拦截或返回已有结果

这种方案很适合短时间窗口内的幂等控制。

但如果业务状态更复杂,仍然要结合:

  1. 数据库唯一约束
  2. 消息消费幂等
  3. 状态机设计

一起看。


12. 🌟 HotKey 和 BigKey 为什么会把并发问题放大

12.1 HotKey

HotKey 可以理解成“访问极度集中的热点 Key”。

它常带来这些问题:

  1. 某个 Redis 节点压力异常高
  2. 一旦过期,引发集中回源
  3. 小问题迅速放大成全链路问题

12.2 BigKey

BigKey 可以理解成“体量特别大的 Key”。

它常带来这些问题:

  1. 单次读写耗时变长
  2. 删除、迁移、序列化成本更高
  3. Redis 的单线程执行路径被拖慢

常见治理思路

  1. 拆分大 Key
  2. 给超热点数据做多级缓存
  3. 对热点数据做预热、异步刷新和限流
  4. 避免让一个聚合大结构承接所有读写

很多线上 Redis 并发问题,表面上看像“请求量太大”,根因其实是:某个 Key 的访问模式或体量设计得不合理。


13. 缓存一致性和分布式并发问题,为什么往往不能只靠 Redis 一层解决

Redis 很适合做:

  1. 热点承接
  2. 原子操作
  3. 轻量级协调
  4. 幂等短状态控制

但如果问题已经升级到下面这些层面:

  1. 最终订单状态一致性
  2. 数据库主存储正确性
  3. 消息投递可靠性
  4. 跨系统事务补偿

那就不能只靠 Redis 一层兜底。

更完整的工程思路通常是:

  1. Redis 负责高频并发面的削峰、快速判定和短状态承接
  2. 数据库负责主存储正确性
  3. 消息队列负责异步解耦
  4. 幂等、补偿和对账负责最终收敛

更贴近工程现实的说法是:Redis 很适合做高并发前置控制,但它通常不是最终一致性的唯一裁判。


14. 一份更实用的 Redis 并发读写检查清单

如果要排查或设计 Redis 并发读写场景,可以优先检查这些问题:

  1. 关键业务流程是不是还在客户端拆成“先读再写”的多步命令
  2. 缓存更新和数据库更新之间是否存在明显时序窗口
  3. 热点 Key 失效后是否会造成集中回源
  4. 秒杀、扣库存、限流这类逻辑是否已经用 Lua 或原子命令收口
  5. 分布式锁是否考虑了唯一标识、超时、续期和安全释放
  6. 同一业务动作是否可能被重复执行,是否有幂等设计
  7. 是否已经识别并治理 HotKey 和 BigKey
  8. 关键一致性问题是否错误地只依赖 Redis 单层解决

15. 总结

把 Redis 并发读写压缩成最核心的几句话,就是:

  1. Redis 单条命令通常是原子的,但你的业务流程不一定是原子的
  2. Redis 的并发问题,常常出在客户端多步操作、跨 Key 协作和跨系统时序交错
  3. 缓存双写不一致、热点击穿、库存超卖、锁误删、重复提交,本质上都和并发时序有关
  4. 真正稳的方案,通常不是“多写几条命令”,而是把关键判断收口成原子命令、Lua、幂等和补偿
  5. Redis 很适合承接高并发前置控制,但数据库、消息队列和对账机制仍然要一起设计

如果把这条主线真正讲顺,Redis 这一块就不会再只剩下“单线程、很快、能做锁”这种零散印象,而是能落到真实工程问题上。

基于 VitePress 构建的个人技术笔记。