上一篇我们深度拆解了Redis过期策略与内存淘汰机制,守住了内存安全底线,让Redis在高并发下不会因内存溢出宕机。但在分布式、多线程场景中,多个请求同时操作Redis数据时,很容易出现并发修改、数据错乱、超卖/超扣等问题,这时候就需要依靠事务和锁机制来保证操作原子性、维护数据一致性。
很多人会混淆Redis事务和传统数据库事务,也分不清分布式锁的选型与坑点。本篇就从Redis事务原理、命令实操、局限性,到分布式锁实现、优化方案、避坑指南,彻底讲透如何用Redis保证原子操作,解决各类并发冲突问题。
核心定位:Redis事务保证单客户端命令批量执行的原子性,分布式锁解决多客户端/多服务的并发竞争问题,二者配合覆盖绝大多数Redis并发场景。
一、Redis事务:批量命令的原子性执行
1. 核心原理:与传统DB事务的区别
Redis事务是批量命令的队列执行,通过MULTI、EXEC等命令把一组命令打包,一次性执行,具备隔离性、一次性,但和MySQL等关系型数据库事务有本质区别:
- 无回滚特性:某条命令执行失败,其余命令仍会继续执行,不会回滚(Redis设计取舍,保证高性能)
- 不保证完全ACID:满足隔离性、一致性,但不支持回滚(原子性残缺),持久性依赖持久化配置
- 执行期间独占:事务队列执行时,不会被其他客户端命令打断,保证隔离性
2. 事务核心命令(4个)
| 命令 | 作用 | 说明 |
|---|---|---|
MULTI | 开启事务 | 标记事务开始,后续命令进入队列,不立即执行 |
EXEC | 执行事务 | 批量执行队列中所有命令,返回每条命令结果 |
DISCARD | 放弃事务 | 清空事务队列,取消事务执行 |
WATCH | 监听Key | 监控Key是否被修改,若修改则事务执行失败(乐观锁) |
3. 事务实操:标准执行流程
# 1. 开启事务
MULTI
# 2. 命令入队(依次执行读写命令)
SET user:1001 "zhangsan"
INCR user:count
HSET user:info:1001 age 25 sex 1
# 3. 执行事务,返回批量结果
EXEC
# 放弃事务示例
MULTI
SET key1 value1
DISCARD # 清空队列,事务取消
4. WATCH乐观锁:解决事务并发竞争
Redis事务默认不处理并发修改,通过WATCH监听关键Key,可实现乐观锁效果:
- 执行
MULTI前先用WATCH key监听目标Key - 若事务执行前,Key被其他客户端修改,
EXEC会返回nil,事务执行失败 - 业务层可重试事务,保证数据一致性
# 监听库存Key,防止超扣
WATCH goods:stock:1001
MULTI
DECRBY goods:stock:1001 1
EXEC
# 若EXEC返回nil,说明库存被修改,需重试
5. Redis事务局限性
- 不支持命令回滚,单条失败不影响整体执行
- 不支持跨Key事务,集群环境下无法操作多个Slot的Key
- 仅适用于单客户端、简单批量操作,分布式场景无能为力
关键结论:Redis事务适合单服务、批量命令、无强一致性场景;分布式高并发场景,必须依靠分布式锁保证原子性。
二、Redis分布式锁:解决跨服务并发冲突
1. 为什么需要分布式锁?
单机Java锁(synchronized、Lock)仅适用于单JVM进程,分布式架构下多服务、多节点部署时,单机锁失效,会出现:
- 商品超卖、优惠券重复领取
- 接口重复提交、数据重复修改
- 库存超扣、余额错乱
Redis分布式锁利用单线程执行特性,实现跨服务的互斥访问,是生产最常用的分布式锁方案。
2. 基础版分布式锁:SETNX实现
核心原理
利用SETNX key value(SET if Not eXists)命令,Key不存在时才设置成功,代表加锁成功;已存在则加锁失败,其他线程等待。
实操命令
# 加锁:设置锁Key,同时加过期时间(防止死锁)
SET lock:goods:1001 1 EX 10 NX
# 解锁:删除锁Key
DEL lock:goods:1001
避坑点
- ❌ 禁止单独用SETNX+EXPIRE:两条命令非原子,加锁后宕机会导致死锁
- ✅ 必须用SET key value EX 过期时间 NX:原子加锁+设超时
3. 进阶版:防误删、可重入分布式锁
问题1:锁被其他线程误删
线程A加锁后执行业务超时,锁自动过期,线程B加锁成功,线程A执行完删除锁,会删掉线程B的锁。
解决方案:锁Value设为唯一标识(UUID+线程ID),解锁前先校验归属,仅自己能删锁。
// 加锁:存入唯一标识
String lockKey = "lock:goods:" + goodsId;
String requestId = UUID.randomUUID().toString();
boolean lockSuccess = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);
// 解锁:先校验再删除(Lua脚本保证原子性)
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class), Collections.singletonList(lockKey), requestId);
问题2:锁不可重入,同一线程重复加锁死锁
解决方案:基于Redis Hash实现可重入锁,记录加锁次数,重入时计数+1,解锁时计数-1,计数为0再删除锁(Redisson框架已封装)。
4. 生产级方案:Redisson分布式锁
基础手写锁存在锁超时、主从切换丢锁、不可重入等问题,生产环境直接用Redisson框架,封装了完善的分布式锁实现:
- 支持可重入、公平锁、红锁(解决集群丢锁)
- 自动续期(看门狗机制),避免业务未执行完锁过期
- 解锁原子化,防误删,兼容单机、哨兵、集群架构
// Redisson锁实操
@Autowired
private RedissonClient redissonClient;
public void deductStock(Long goodsId) {
String lockKey = "lock:goods:" + goodsId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 加锁:等待时间10s,锁超时30s
boolean tryLock = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (tryLock) {
// 执行业务:扣减库存
doDeductStock(goodsId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 解锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
5. 分布式锁避坑终极清单
- 必须设置锁超时时间,杜绝死锁
- 加锁+设超时必须是原子操作
- 解锁前校验锁归属,防止误删
- 集群环境用Redisson红锁,避免主从切换丢锁
- 避免锁粒度太粗,降低并发效率
三、Redis事务 vs 分布式锁:场景选型
| 维度 | Redis事务 | 分布式锁 |
|---|---|---|
| 适用范围 | 单客户端、批量命令执行 | 分布式、多服务、多线程并发竞争 |
| 原子性 | 批量命令执行原子,无回滚 | 代码块互斥执行,保证业务原子性 |
| 复杂度 | 低,命令简单 | 高,需考虑超时、误删、重入等 |
| 典型场景 | 批量读写、简单计数 | 秒杀扣库存、防重复提交、分布式限流 |
四、总结与核心要点
- Redis事务是命令队列批量执行,无回滚、适合单机简单场景,配合WATCH实现乐观锁
- 分布式锁解决跨服务并发冲突,基础版用SETNX原子命令,生产直接用Redisson
- 保证Redis原子操作的核心:单命令天然原子,多命令用事务,分布式用锁
生产建议:优先用分布式锁处理并发冲突,Redis事务仅用于无依赖的批量命令执行,复杂业务务必结合Lua脚本进一步保证原子性。