Appearance
Redis 并发读写、一致性与高并发场景实战
很多人一提到 Redis 并发读写,第一反应是:Redis 不是单线程吗,为什么还会有并发问题?
这个问题问得很典型,因为它正好卡在 Redis 最容易被误解的地方。
Redis 并发读写真正要讨论的,不是:Redis 内部会不会像多线程程序那样频繁加锁。
而是:
- 多个客户端同时读写同一个 Key 或同一批 Key,会不会出现竞态
- 缓存、数据库、消息队列这些组件交错时,状态还能不能保持一致
- 高并发流量打过来时,Redis 能不能既扛住压力,又不把后端系统拖垮
所以这篇文章会按下面这条主线展开:
- Redis 并发读写到底在说什么
- Redis 为什么看起来“不容易冲突”
- Redis 的并发问题真正出在哪里
- 常见场景为什么会出错
- 工程上通常怎么治理
1. Redis 并发读写到底在说什么
这里的“并发读写”,不是只指 Redis 自己内部的一条命令如何执行,而是指:多个请求、多个线程、多个服务实例,在时间上重叠地访问同一份 Redis 状态。
例如:
- 两个请求同时扣减同一个商品库存
- 大量请求同时读取一个刚过期的热点 Key
- 一个服务刚更新数据库,另一个服务正在从缓存读取旧值
- 多个应用实例同时竞争同一把分布式锁
所以 Redis 并发读写的核心问题,通常不是“命令能不能执行成功”,而是:
- 结果对不对
- 顺序稳不稳
- 时序交错后会不会留下脏状态
要注意的是:Redis 经常处在系统的高频访问入口,所以并发问题一旦出现,放大的速度会非常快。
2. Redis 为什么看起来不容易出并发问题
2.1 单条命令通常是原子的
原子 可以理解成“要么完整执行,要么根本不执行,中间不会被别的命令插进来”。
例如:
INCRDECRHINCRBYSET key value NX EX 30
这类单条命令在 Redis 执行路径里通常是原子的。
这意味着:如果你的业务动作刚好能被一条 Redis 命令表达出来,它通常就不容易在 Redis 内部被并发打乱。
2.2 命令执行模型相对简单
Redis 之所以常被认为“并发问题少”,一个重要原因是它的命令执行模型比较简单。
很多高频命令不会像传统多线程共享内存程序那样,在应用代码层面到处争抢锁。
所以大家会产生一种直觉:Redis 天生就很稳。
这个直觉只对了一半。
2.3 真正的边界:原子的是单条命令,不是你的业务流程
这是必须单独拎出来强调的一点。
Redis 能保证的,通常是:单条命令的执行原子性。
它不能自动替你保证:
- 客户端先读再写的多步流程是原子的
- 两个 Key 之间的复杂业务关系天然一致
- Redis 和 MySQL 双写一定没有时序问题
所以不要把下面两句话混成一件事:
Redis 单条命令通常是原子的我的业务读写流程天然线程安全
这两者不是一个概念。
3. Redis 的并发问题真正出在哪里
3.1 客户端多步操作
最典型的问题是:
- 先读一个值
- 在应用里做判断
- 再写回 Redis
只要这 3 步不是一次性在 Redis 侧完成,中间就会暴露并发窗口。
例如库存是 1:
- 线程 A 读到
1 - 线程 B 也读到
1 - A 判断还能扣,准备写回
0 - B 也判断还能扣,准备写回
0
结果看起来都“执行成功”了,但业务已经超卖。
3.2 多个 Key 之间的关系
有些业务不是改一个 Key 就完事,而是同时涉及:
- 库存 Key
- 用户资格 Key
- 订单去重 Key
- 限流计数 Key
这时真正难的不是某个 Key 能不能改,而是:这些 Key 之间的状态变更,能不能按照同一个业务规则一起推进。
3.3 Redis 和外部系统交错
Redis 很少孤立存在,它通常会和:
- MySQL
- 消息队列
- 本地缓存
- 多个应用实例
一起工作。
这时最常见的问题就变成:Redis 自己没错,但跨组件的时序打架了。
例如数据库已经更新了,缓存还没删;或者缓存刚失效,大量请求一起回源;或者 Redis 预扣成功了,但数据库落单失败了。
3.4 热点放大效应
Redis 常处在高并发入口,一旦某个 Key 特别热,问题会被迅速放大:
- 一个错误的回填逻辑,会被放大成大量脏缓存
- 一个失效的热点 Key,会带来瞬时回源洪峰
- 一个设计不好的大 Key,会把单线程执行路径拖慢
所以 Redis 的并发问题往往不是“偶尔错一下”,而是:一旦出错,传播速度和影响范围都很大。
4. 🌟 最容易混淆的一个点:Redis 单线程,不等于业务天然无并发问题
这句话值得单独记住:Redis 的单线程命令执行模型,解决的是服务端命令调度复杂度问题;它不等于自动解决客户端业务层面的竞态。
可以把它拆成两层理解:
4.1 Redis 内部视角
在 Redis 内部,单条命令执行时通常不会被另一条命令打断,所以像 INCR 这种操作天然比较稳。
4.2 业务系统视角
但在业务侧,常常是很多服务实例、很多线程、很多请求同时发命令。
如果你把业务流程拆成:
GET- 本地判断
SET
那么并发问题依然会出现,因为两个请求都可能在读到旧值后,做出同样的判断。
所以 Redis 的单线程模型,更适合帮助我们得出一个结论:尽量把关键读写逻辑压缩成单条命令,或者放到 Lua 脚本里一次完成。
而不是得出这个错误结论:反正 Redis 是单线程,我在客户端怎么拆步骤都安全。
5. 🌟 缓存和数据库双写为什么会不一致
这是 Redis 并发读写里最常见的一类问题。
典型场景是:
- 先更新数据库
- 再删除缓存
- 但删除缓存前,另一个请求刚好读到了旧缓存
或者:
- 缓存刚好失效
- 读请求回源数据库,拿到旧值
- 写请求更新数据库
- 旧值又被回填进缓存
这类问题的本质不是 Redis 自己“写错了”,而是:缓存和数据库是两套状态源,读写交错时一定会出现时序窗口。
常见治理思路
- 更新数据库后删除缓存
- 延迟双删
- 异步重建缓存
- 对强一致读绕过缓存
更稳妥的理解
更新数据库后删除缓存 通常是最常见的基础方案,因为数据库才是主数据源。
但它也不是绝对零窗口。
如果业务对一致性特别敏感,就不能只停留在“删缓存”这一层,还要继续考虑:
- 关键读是否回主库
- 是否需要消息驱动的异步订正
- 是否需要版本号或时间戳控制旧值回填
6. 延迟双删为什么不是万能方案
延迟双删通常是:
- 更新数据库
- 删除缓存
- 过一小段时间再删一次缓存
它主要在补救下面这种情况:
- 第一次删缓存之后
- 并发读请求又把旧值写回来了
- 第二次删除把这份脏缓存再清掉
这个思路有工程价值,但它不是严格一致性的保证,原因主要有:
- 延迟时间很难完全贴合真实业务时序
- 并发模型可能比预想更复杂
- 第二次删除本身也可能失败
- 旧值回填不一定刚好发生在预设窗口内
所以更稳妥的表述是:延迟双删是在降低脏缓存残留概率,而不是彻底消灭所有不一致窗口。
7. 🌟 热点 Key 失效后为什么会把数据库打穿
这就是典型的缓存击穿问题。
场景通常像这样:
- 某个商品详情或首页配置是热点 Key
- 它刚好过期
- 大量请求同时进来
- 大家一起回源数据库
真正危险的地方不是“某一次缓存没命中”,而是:所有请求在同一时刻一起回源。
常见治理思路
- 热点 Key 预热
- 互斥锁或单飞控制
- 逻辑过期
- 本地缓存 + Redis 多级缓存
工程上更自然的理解
如果一个 Key 特别热,就不应该按普通缓存那种“到点一起过期”的方式去管理。
更常见的做法是:
- 让热点数据平滑刷新
- 控制同一时刻只有少数请求参与重建
- 其他请求先读旧值、兜底值,或者直接降级
8. 秒杀、抢购、扣库存为什么会超卖
这类问题本质上就是典型的并发读写竞态。
最容易出错的写法通常是:
- 先查库存
- 判断库存大于 0
- 再扣减库存
如果这些步骤在客户端分多次完成,就会出现:
- 请求 A 读到库存为
1 - 请求 B 也读到库存为
1 - 两边都认为“还能卖”
- 最终库存被重复扣减
常见治理思路
- 用 Lua 脚本把“判断 + 扣减”合成一次服务端执行
- 用 Redis 先做预扣,再异步落库
- 对订单创建、支付链路增加幂等和补偿
为什么 Lua 更适合这一类场景
Lua 可以理解成“在 Redis 服务端执行的一小段脚本”。
它的价值在于:
- 把多步操作放到 Redis 一次执行
- 减少客户端多次往返
- 缩小竞态窗口
所以秒杀这种场景里,真正稳的不是“多发几次 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这个脚本里的几个知识点值得单独记住:
KEYS用来接收脚本里要操作的 Redis KeyARGV用来接收普通参数,例如扣减数量redis.call(...)表示在脚本内部继续执行 Redis 命令- 整个脚本会在 Redis 服务端一次执行完,所以中间不会被别的命令插进来
如果约定返回值:
1表示扣减成功0表示库存不足-1表示库存 Key 不存在-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);
}
}这段代码真正想说明的是:
- 业务侧不要自己先
GET再DECRBY - 应该把“判断 + 写入”一起收口进 Lua
- Java 代码只负责传 Key、传参数、解释返回值
但这类脚本也有边界
Lua 脚本很适合做:
- 库存扣减
- 限流计数
- 分布式锁里的校验和释放
- 多步原子校验与更新
但不适合把整段复杂业务流程都塞进去。
要注意的是:
- Redis 命令主路径本来就怕单次执行过重
- Lua 逻辑太长,同样会阻塞后续请求
- 它适合处理临界区,不适合承载完整业务编排
所以一个更稳妥的工程原则是:让 Lua 只负责最关键、最短、最需要原子性的那一小段逻辑。
9. 🌟 分布式锁为什么经常“看起来加上了,其实没加稳”
很多人第一次做 Redis 分布式锁,只会写:
SETNX- 设置成功就认为锁拿到了
但真实系统里还会遇到这些问题:
- 没设置过期时间,导致死锁
- 锁过期后,另一个线程已经拿到了新锁
- 第一个线程执行完,又把第二个线程的锁删掉了
- 业务执行时间超过锁时长,锁提前失效
更稳妥的治理方式
- 原子加锁
- 写入唯一标识
- 释放锁时做“校验 + 删除”的原子操作
- 业务很长时考虑自动续期
真正的难点在哪里
分布式锁真正难的地方不是“怎么加上一把锁”,而是:在超时、重试、网络抖动和多实例交错时,怎么避免误删别人的锁。
10. 多线程并发更新同一个 Key,为什么客户端多步命令容易出问题
典型场景包括:
- 记录用户当天请求次数
- 先读当前值
- 判断是否超过阈值
- 再决定是否写回
如果这些步骤拆成多条客户端命令,在并发下就很容易出现:
- 多个线程读到同样的旧值
- 都认为“还没超限”
- 最终一起写回,导致阈值控制失效
常见治理思路
- 能用 Redis 原子命令就优先用原子命令
- 多步判断放进 Lua
- 必要时用
WATCH+MULTI+EXEC做乐观事务控制 - 对关键业务再补上幂等和补偿
这里顺便把 WATCH 解释一下。
WATCH 可以理解成“乐观监听”。
它的意思是:
- 先监听某个 Key
- 如果事务提交前这个 Key 被别人改了
- 当前事务提交就失败
它适合一部分冲突检测场景,但并不等于万能方案。
因为它更像“发现冲突再重试”,不是“天然没有冲突”。
11. 幂等控制为什么也经常和 Redis 绑在一起
典型场景包括:
- 防止重复下单
- 防止支付回调重复执行
- 防止用户重复提交同一请求
这类问题本质上不是“缓存问题”,而是:分布式高并发下,同一业务动作可能被执行多次。
常见治理思路
- 给请求生成唯一业务标识
- Redis 里记录“是否处理过”
- 首次处理成功后写入状态
- 后续重复请求直接拦截或返回已有结果
这种方案很适合短时间窗口内的幂等控制。
但如果业务状态更复杂,仍然要结合:
- 数据库唯一约束
- 消息消费幂等
- 状态机设计
一起看。
12. 🌟 HotKey 和 BigKey 为什么会把并发问题放大
12.1 HotKey
HotKey 可以理解成“访问极度集中的热点 Key”。
它常带来这些问题:
- 某个 Redis 节点压力异常高
- 一旦过期,引发集中回源
- 小问题迅速放大成全链路问题
12.2 BigKey
BigKey 可以理解成“体量特别大的 Key”。
它常带来这些问题:
- 单次读写耗时变长
- 删除、迁移、序列化成本更高
- Redis 的单线程执行路径被拖慢
常见治理思路
- 拆分大 Key
- 给超热点数据做多级缓存
- 对热点数据做预热、异步刷新和限流
- 避免让一个聚合大结构承接所有读写
很多线上 Redis 并发问题,表面上看像“请求量太大”,根因其实是:某个 Key 的访问模式或体量设计得不合理。
13. 缓存一致性和分布式并发问题,为什么往往不能只靠 Redis 一层解决
Redis 很适合做:
- 热点承接
- 原子操作
- 轻量级协调
- 幂等短状态控制
但如果问题已经升级到下面这些层面:
- 最终订单状态一致性
- 数据库主存储正确性
- 消息投递可靠性
- 跨系统事务补偿
那就不能只靠 Redis 一层兜底。
更完整的工程思路通常是:
- Redis 负责高频并发面的削峰、快速判定和短状态承接
- 数据库负责主存储正确性
- 消息队列负责异步解耦
- 幂等、补偿和对账负责最终收敛
更贴近工程现实的说法是:Redis 很适合做高并发前置控制,但它通常不是最终一致性的唯一裁判。
14. 一份更实用的 Redis 并发读写检查清单
如果要排查或设计 Redis 并发读写场景,可以优先检查这些问题:
- 关键业务流程是不是还在客户端拆成“先读再写”的多步命令
- 缓存更新和数据库更新之间是否存在明显时序窗口
- 热点 Key 失效后是否会造成集中回源
- 秒杀、扣库存、限流这类逻辑是否已经用 Lua 或原子命令收口
- 分布式锁是否考虑了唯一标识、超时、续期和安全释放
- 同一业务动作是否可能被重复执行,是否有幂等设计
- 是否已经识别并治理 HotKey 和 BigKey
- 关键一致性问题是否错误地只依赖 Redis 单层解决
15. 总结
把 Redis 并发读写压缩成最核心的几句话,就是:
- Redis 单条命令通常是原子的,但你的业务流程不一定是原子的
- Redis 的并发问题,常常出在客户端多步操作、跨 Key 协作和跨系统时序交错
- 缓存双写不一致、热点击穿、库存超卖、锁误删、重复提交,本质上都和并发时序有关
- 真正稳的方案,通常不是“多写几条命令”,而是把关键判断收口成原子命令、Lua、幂等和补偿
- Redis 很适合承接高并发前置控制,但数据库、消息队列和对账机制仍然要一起设计
如果把这条主线真正讲顺,Redis 这一块就不会再只剩下“单线程、很快、能做锁”这种零散印象,而是能落到真实工程问题上。