Redis实战篇:深入探索分布式锁的应用
Redis实战篇:深入探索分布式锁的应用
在微服务与分布式系统中,多个进程或节点需要协调访问共享资源。如何保证在并发环境下,只有一个客户端能够对某个资源进行操作,是架构设计的一项重要挑战。分布式锁正是为了解决这一类问题而存在。作为一个高性能的内存数据库,Redis 以其原子命令和丰富的数据结构,天然适合用于实现分布式锁。本文将通过代码示例、图解与详细说明,帮助你从零开始掌握 Redis 分布式锁的原理与最佳实践。
目录
- 分布式锁概述
- 2.1 SETNX 原理与语义
- 2.2 Java 代码示例(Jedis)
- 2.3 存在的问题:死锁与误删
- 3.1 Lua 脚本原理解析
- 3.2 Java 调用 Lua 脚本示例(Spring Data Redis)
- 3.3 流程图解:加锁与解锁的时序
- 4.1 Redisson 简介
- 4.2 Java 示例:使用 Redisson 实现公平锁与可重入锁
- 5.1 限流与排队
- 5.2 分布式任务调度
- 5.3 资源抢购与秒杀系统
- 6.1 锁粒度与加锁时长控制
- 6.2 避免单点故障:哨兵与集群模式
- 6.3 看门狗(Watchdog)机制与续期
- 7.1 需求描述与设计思路
- 7.2 Lua 脚本实现原子库存扣减
- 7.3 Java 端集成与高并发测试
- 总结与最佳实践
分布式锁概述
在单机程序中,我们常常使用操作系统提供的互斥锁(如 Java 中的 synchronized
或 ReentrantLock
)来保证同一 JVM 内线程对共享资源的互斥访问。但是在微服务架构下,往往多个服务实例部署在不同的机器或容器上,进程间无法直接使用 JVM 锁机制。此时,需要借助外部组件来协调——这就是分布式锁的用途。
分布式锁的核心目标
- 互斥(Mutual Exclusion)
任意时刻,只有一个客户端持有锁,其他客户端无法同时获得锁。 - 可重入(Reentrancy,可选)
如果同一客户端在持有锁的情况下再次请求锁,应当允许(可重入锁);否则可能陷入死锁。 阻塞与非阻塞
- 阻塞式:若获取锁失败,客户端会阻塞、等待;
- 非阻塞式:若获取锁失败,直接返回失败,让客户端决定重试或退出。
- 防止死锁
若客户端在持有锁后崩溃或网络抖动导致无法释放锁,必须有过期机制自动释放,以避免其他客户端永远无法获取。 - 高可用与性能
分布式锁的实现需要具备高可用性,不能成为系统瓶颈;在并发量非常高的场景下,需要保证性能足够好。
Redis 为分布式锁提供了天然支持:
- 原子性命令(如
SETNX
、DEL
等)可用作加锁与解锁; - 内置过期时间(TTL),可避免死锁;
- Lua 脚本可以将多步操作封装为原子执行;
- 有成熟的客户端库(如 Redisson)封装了可靠的分布式锁机制。
接下来,我们将一步步深入,从最简单的 SETNX
实现,到 Lua 脚本优化,再到生产级 Redisson 应用,全面掌握 Redis 分布式锁的实践方法。
基于 SETNX 的简易分布式锁
最基础的分布式锁思路是:客户端使用 Redis 命令 SETNX key value
(SET if Not eXists)尝试创建一个锁标识。当 SETNX
返回 1 时,表示锁成功获取;当返回 0 时,表示锁已被其他客户端持有,需要重试或直接失败。
2.1 SETNX 原理与语义
语法:
SETNX lock_key client_id
lock_key
:锁对应的 Redis 键;client_id
:唯一标识当前客户端或线程(通常使用 UUID 或 IP+线程ID)。
返回值:
- 如果
lock_key
不存在,Redis 会将其设置为client_id
,并返回1
; - 如果
lock_key
已存在,什么都不做,返回0
。
- 如果
加锁示例:
> SETNX my_lock "client_123" 1 # 表示加锁成功 > SETNX my_lock "client_456" 0 # 表示加锁失败,my_lock 已被 "client_123" 持有
由于 SETNX
具有原子性,多客户端并发执行时只有一个会成功,满足最基本的互斥需求。
但是,光用 SETNX
还不足够。假设客户端 A 成功设置了锁,但在执行业务逻辑前崩溃或网络中断,锁永远不会被删除,导致后续客户端一直阻塞或失败,出现“死锁”问题。为了解决这一点,需要为锁设置过期时间(TTL),在客户端未正常释放时,由 Redis 自动删除锁键。
Redis 2.6.12 之后推荐使用 SET
命令带上参数 NX
(只在键不存在时设置)和 PX
(设置过期时间,毫秒级),以原子方式完成“加锁+设置过期”两步操作:
SET lock_key client_id NX PX 5000
NX
:当且仅当lock_key
不存在时,才执行设置;PX 5000
:将lock_key
的过期时间设为 5000 毫秒(即 5 秒)。
这种写法避免了先 SETNX
后 EXPIRE
可能出现的竞态问题(在 SETNX
与 EXPIRE
之间 Redis 异常导致锁没有过期时间)。
2.2 Java 代码示例(Jedis)
下面用 Jedis 客户端演示基于 SET NX PX
的简易分布式锁:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import java.util.UUID;
public class SimpleRedisLock {
private Jedis jedis;
private String lockKey;
private String clientId; // 唯一标识,确保解锁安全
private int expireTimeMillis; // 锁超时时间(毫秒)
public SimpleRedisLock(Jedis jedis, String lockKey, int expireTimeMillis) {
this.jedis = jedis;
this.lockKey = lockKey;
this.clientId = UUID.randomUUID().toString();
this.expireTimeMillis = expireTimeMillis;
}
/**
* 尝试获取锁
*
* @return true 表示加锁成功;false 表示加锁失败
*/
public boolean tryLock() {
SetParams params = new SetParams();
params.nx().px(expireTimeMillis);
String result = jedis.set(lockKey, clientId, params);
return "OK".equals(result);
}
/**
* 释放锁(非安全方式:直接 DEL)
*/
public void unlockUnsafe() {
jedis.del(lockKey);
}
/**
* 释放锁(安全方式:检查 value 再删除)
*
* @return true 表示释放成功;false 表示未释放(可能锁已过期或非自己的锁)
*/
public boolean unlockSafe() {
String value = jedis.get(lockKey);
if (clientId.equals(value)) {
jedis.del(lockKey);
return true;
}
return false;
}
}
- 构造函数中,为当前客户端生成唯一的
clientId
,用来在解锁时验证自身持有锁的合法性。 tryLock()
方法使用jedis.set(lockKey, clientId, nx, px)
原子地完成“加锁 + 过期设置”。unlockUnsafe()
直接DEL
,无法防止客户端误删其他客户端的锁。unlockSafe()
先GET
判断值是否与clientId
相同,只有相同时才DEL
,避免误删他人锁。但这段逻辑并非原子,存在并发风险:- A 客户端执行
GET
,发现和自身clientId
相同; - 在 A 调用
DEL
之前,锁意外过期,B 客户端重新获得锁并设置了新的clientId
; - A 继续执行
DEL
,将 B 加的锁错误删除,导致锁失效。
- A 客户端执行
2.3 存在的问题:死锁与误删
基于上面示例,我们可以总结简易锁实现中常见的两个风险:
死锁风险
- 如果客户端在持锁期间崩溃或网络抖动,导致无法主动释放锁,但使用了带过期时间的
SET NX PX
,锁会在到期后自动释放,从而避免死锁。但如果不设过期,或者业务时间超过过期时间,又没有续期机制,会造成后续客户端加锁失败。
- 如果客户端在持锁期间崩溃或网络抖动,导致无法主动释放锁,但使用了带过期时间的
误删他人锁
- 在非原子 “检查再删除” 逻辑中,客户端有可能在检查到锁属于自己但在调用
DEL
之前发生超时或运行延迟,造成误删了后来获得锁的其他客户端的锁。 - 因此,必须用 Lua 脚本将“比对 value + 删除 key”两步操作封装为原子命令。
- 在非原子 “检查再删除” 逻辑中,客户端有可能在检查到锁属于自己但在调用
为保证安全释放,我们需要借助 Lua 脚本。下面详细演示如何在 Redis 端使用 Lua 脚本,确保原子执行。
使用 Lua 脚本保证原子性与安全释放
Redis 内置的 Lua 引擎允许我们将多条命令组合为单个原子操作。借助 Lua 脚本,可以在解锁时进行“判断 value 是否匹配”与“删除 key”两步的原子化,从而完全杜绝误删他人锁的问题。
3.1 Lua 脚本原理解析
3.1.1 加锁脚本
我们使用更通用的 SET
命令带参数实现“加锁 + 过期”,无需额外的 Lua 脚本。示例:
EVAL "return redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])" 1 lock_key client_id 5000
KEYS[1]
:锁键(lock_key
)ARGV[1]
:客户端标识(client_id
)ARGV[2]
:过期时间(5000
毫秒)返回值:
"OK"
表示加锁成功;nil
表示加锁失败。
不过,因为 SET NX PX
本身就是原子命令,没有必要用 Lua 包装。我们直接在客户端用 jedis.set(key, value, nx, px)
即可。
3.1.2 解锁脚本
下面是一段完整的 Lua 脚本 unlock.lua
,用于安全释放分布式锁:
-- unlock.lua
-- KEYS[1] = lock_key
-- ARGV[1] = client_id
if redis.call("GET", KEYS[1]) == ARGV[1] then
-- 只有当锁的持有者与传入 client_id 一致时,才删除锁
return redis.call("DEL", KEYS[1])
else
return 0
end
逻辑解析:
redis.call("GET", KEYS[1])
:获取锁键存储的client_id
;- 如果与
ARGV[1]
相同,说明当前客户端确实持有锁,于是执行redis.call("DEL", KEYS[1])
删除锁,返回值为1
(表示删除成功); - 否则返回
0
,表示未执行删除(可能锁已过期或锁持有者不是当前客户端)。
- 原子性保证:
整段脚本在 Redis 端一次性加载并执行,期间不会被其他客户端命令打断,保证“比对+删除”操作的原子性,从根本上避免了在“GET”与“DEL”之间的竞态条件。
3.2 Java 调用 Lua 脚本示例(Spring Data Redis)
假设你在 Spring Boot 项目中使用 Spring Data Redis,可以这样加载并执行 Lua 脚本:
3.2.1 将 unlock.lua
放到 resources/scripts/
目录下
src
└── main
└── resources
└── scripts
└── unlock.lua
3.2.2 定义 Spring Bean 加载 Lua 脚本
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
@Configuration
public class RedisScriptConfig {
/**
* 将 unlock.lua 脚本加载为 DefaultRedisScript
*/
@Bean
public DefaultRedisScript<Long> unlockScript() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
// 指定脚本路径 相对于 classpath
script.setLocation(new ClassPathResource("scripts/unlock.lua"));
// 返回值类型
script.setResultType(Long.class);
return script;
}
}
3.2.3 在分布式锁工具类中执行脚本
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class RedisDistributedLock {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private DefaultRedisScript<Long> unlockScript;
private static final long DEFAULT_EXPIRE_MILLIS = 5000; // 默认锁过期 5 秒
/**
* 获取分布式锁
*
* @param lockKey 锁 Key
* @return clientId 用于之后解锁时比对;如果返回 null 表示获取锁失败
*/
public String tryLock(String lockKey) {
String clientId = UUID.randomUUID().toString();
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, clientId, DEFAULT_EXPIRE_MILLIS, TimeUnit.MILLISECONDS);
if (Boolean.TRUE.equals(success)) {
return clientId;
}
return null;
}
/**
* 释放锁:使用 Lua 脚本保证原子性
*
* @param lockKey 锁 Key
* @param clientId 获取锁时返回的 clientId
*/
public boolean unlock(String lockKey, String clientId) {
// KEYS[1] = lockKey; ARGV[1] = clientId
Long result = redisTemplate.execute(
unlockScript,
Collections.singletonList(lockKey),
clientId
);
return result != null && result > 0;
}
}
tryLock
方法:- 通过
setIfAbsent(key, value, timeout, unit)
相当于SET key value NX PX timeout
,如果返回true
,表示加锁成功并设置过期时间。 - 返回随机
clientId
,用于后续安全解锁。若返回null
,表示加锁失败(已被占用)。
- 通过
unlock
方法:- 通过
redisTemplate.execute(unlockScript, keys, args)
将unlock.lua
脚本在 Redis 端执行,原子地完成判断与删除。
- 通过
3.3 流程图解:加锁与解锁的时序
下面用一个简化的 ASCII 图,帮助理解 Redis 分布式锁在加锁与解锁时的各个步骤:
┌──────────────────────────────────┐
│ Redis Server │
└──────────────────────────────────┘
▲ ▲
│ │
1. tryLock("my_lock") │ │ 4. unlock("my_lock", clientId)
SET my_lock clientId NX PX expireTime │
│ │
▼ │
┌───────────────────────┐ ┌──────────────────────────────────┐
│ 应用 A(客户端) │ │ 1. Redis 端执行 SETNX + EXPIRE │
│ │ │ 原子完成后返回 OK │
│ clientId = uuid-A │ └──────────────────────────────────┘
│ 加锁成功 │ │
│ 业务逻辑执行中... │ ▼
│ │ ┌──────────────────────────────────┐
│ │ │ /Lock Keys │
│ │ │ my_lock -> uuid-A (TTL: expire) │
└───────────────────────┘ └──────────────────────────────────┘
▲
│
2. 其他客户端 B │ 3. A 调用 unlock 前锁过期?
tryLock │
SET my_lock uuid-B?│
返回 null │
│
│
┌───────────────────────┐ │ ┌───────────────────────┐
│ 应用 B(客户端) │ │ │ 应用 A 调用 unlock │
│ 加锁失败,返回 null │ │ │(执行 Lua 脚本) │
└───────────────────────┘ │ └───────────────────────┘
│ │
│ 4.1 Redis 接收 Lua 脚本 │
│ if GET(key)==clientId │
│ then DEL(key) │
│ else return 0 │
│
▼
┌──────────────────────────────────┐
│ Lock Key 可能已过期或被 B 获得 │
│ - 若 my_lock 值 == uuid-A: DEL 成功 │
│ - 否则返回 0,不删除任何数据 │
└──────────────────────────────────┘
- 步骤 1:客户端 A 通过
SET key value NX PX expire
成功加锁; - 步骤 2:锁过期前,客户端 B 反复尝试
SET key
均失败; - 步骤 3:客户端 A 业务逻辑执行完毕,调用
unlock
方法,在 Redis 端运行unlock.lua
脚本; - 步骤 4:Lua 脚本比对
GET(key)
与clientId
,如果一致则DEL(key)
,否则不做任何操作,保证安全释放。
通过上述方式,我们既保证了锁在超时后自动释放,也避免了误删他人锁的风险。
Redisson 生产级分布式锁方案
虽然自己动手实现分布式锁可以帮助理解原理,但在生产环境中有以下挑战:
- 需要处理锁续期、锁失效、锁可重入、可重试、超时控制等复杂逻辑;
- 要考虑 Redis 单点故障,需要使用 Redis Sentinel 或 Cluster 模式保证高可用;
- 如果自己实现的代码不够健壮,在极端并发情况下可能出现竞态或性能瓶颈。
为此,Redisson(基于 Jedis/Lettuce 封装的 Redis 客户端工具包)提供了一套成熟的分布式锁方案,功能丰富、易用且可靠。Redisson 内部会自动完成续期看门狗、超时回退等机制,支持多种锁类型(可重入锁、公平锁、读写锁、信号量等)。
4.1 Redisson 简介
- 起源:由 Redisson 团队开发,是一个基于 Netty 的 Redis Java 客户端,封装了众多 Redis 功能。
核心特性:
- 可重入锁(Reentrant Lock)
- 公平锁(Fair Lock)
- 读写锁(ReadWrite Lock)
- 信号量(Semaphore)、Latch
- 分布式队列、集合、映射 等。
- 支持单机、Sentinel、Cluster 模式。
- 内置看门狗(Watchdog)机制,自动续期锁,防止锁误释放。
maven 依赖:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.25.0</version> </dependency>
也可以只引入
redisson
核心包,根据需要自行配置。
4.2 Java 示例:使用 Redisson 实现公平锁与可重入锁
下面演示如何在 Spring Boot 中,通过 Redisson 快速实现分布式锁。
4.2.1 配置 Redisson Client
在 application.yml
中配置 Redis 地址(以单机模式为例):
spring:
redis:
host: 127.0.0.1
port: 6379
redisson:
# 可以将 Redisson 配置都放在 config 文件中,也可以使用 spring-boot-starter 默认自动配置
# 这里使用简单模式,指向单个 Redis 节点
address: redis://127.0.0.1:6379
lockWatchdogTimeout: 30000 # 看门狗超时时间(ms),Redisson 会自动续期直到 30 秒
如果希望使用 Sentinel 或 Cluster,只需将 address
、sentinelAddresses
、clusterNodes
等配置项配置好即可。
4.2.2 注入 RedissonClient 并获取锁
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class RedissonLockService {
@Autowired
private RedissonClient redissonClient;
/**
* 获取可重入锁并执行业务
*
* @param lockKey 锁名称
* @param leaseTime 锁过期时间(秒)
* @return 返回业务执行结果
*/
public String doBusinessWithReentrantLock(String lockKey, long leaseTime) {
RLock lock = redissonClient.getLock(lockKey);
boolean acquired = false;
try {
// 尝试加锁:等待时间 3 秒,锁超时时间由 leaseTime 决定
acquired = lock.tryLock(3, leaseTime, TimeUnit.SECONDS);
if (!acquired) {
return "无法获取锁,业务拒绝执行";
}
// 模拟业务逻辑
Thread.sleep(2000);
return "业务执行完成,锁自动续期或定时释放";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "业务执行被打断";
} finally {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 公平锁示例:保证先请求锁的线程先获得锁
*/
public String doBusinessWithFairLock(String lockKey) {
RLock fairLock = redissonClient.getFairLock(lockKey + ":fair");
boolean acquired = false;
try {
acquired = fairLock.tryLock(5, 10, TimeUnit.SECONDS);
if (!acquired) {
return "无法获取公平锁,业务拒绝执行";
}
// 模拟业务
Thread.sleep(1000);
return "公平锁业务执行完成";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "业务执行被打断";
} finally {
if (acquired && fairLock.isHeldByCurrentThread()) {
fairLock.unlock();
}
}
}
}
getLock(lockKey)
返回一个常规的可重入锁(非公平),Redisson 会在内部创建并维护一个有序的临时节点队列,结合看门狗机制自动续期。getFairLock(lockKey)
返回一个公平锁,会严格按照请求顺序分配锁,适用于对公平性要求高的场景。lock.tryLock(waitTime, leaseTime, unit)
:waitTime
:尝试获取锁的最长等待时间,超过则返回false
;leaseTime
:加锁成功后,锁的自动过期时间;如果 leaseTime 为 0,则会启用看门狗模式,Redisson 会在锁快到过期时自动续期(续期周期为过期时间的 1/3)。
4.2.3 在 Controller 中使用
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class RedissonLockController {
@Autowired
private RedissonLockService lockService;
@GetMapping("/redisson/reentrant")
public String testReentrantLock() {
return lockService.doBusinessWithReentrantLock("myReentrantLock", 10);
}
@GetMapping("/redisson/fair")
public String testFairLock() {
return lockService.doBusinessWithFairLock("myLock");
}
}
- 并发访问
/redisson/reentrant
或/redisson/fair
即可看到锁的排队与互斥执行效果。
分布式锁常见应用场景
分布式锁广泛应用于多实例系统中对共享资源或关键业务的互斥保护,以下列举常见场景:
5.1 限流与排队
- 流量突发保护:当某个接口或资源承受高并发请求时,可先通过获取锁(或令牌桶、信号量)来限制同时访问人数。
- 排队处理:对一批请求,串行化顺序执行,例如限购系统中,先获取锁的用户方可继续扣库存、下单,其他用户需排队等待或返回 “系统繁忙”。
5.2 分布式任务调度
- 定时任务去重:在多台机器上同时部署定时任务,为了避免同一个任务被多次执行,可以在执行前获取一把分布式锁,只有持有锁的实例才执行任务。
- Leader 选举:多个调度节点中,只有 Leader(获得锁的节点)执行特定任务,其他节点处于候选或 standby 状态。
5.3 资源抢购与秒杀系统
- 库存扣减:当大批量用户同时抢购某个商品时,需要保证库存只被扣减一次。加锁可让一个用户在扣减库存期间,其他并发请求暂时阻塞或失败。
- 支付与退款:对于同一订单多次支付或退款操作,需要使用分布式锁保证只能有一个线程对该订单进行状态变更。
分布式锁的性能与注意事项
在生产环境使用 Redis 分布式锁,需要注意以下性能和可靠性细节:
6.1 锁粒度与加锁时长控制
- 锁粒度:不要为了简单而把全局资源都用同一个锁。应尽可能缩小锁粒度,例如对同一个“用户 ID”加锁,而非对整个“商品库存”加锁。
- 加锁时长:合理设置过期时间,既要足够长以完成业务,又不能过度冗余,避免长时间持有锁阻塞其他请求。对于无法预估业务耗时场景,推荐使用看门狗模式(Redisson 自动续期),或定时手动续期。
- 超时退避:当获取锁失败时,可采用指数退避(Exponential Backoff)策略,避免大量客户端瞬间重试造成雪崩。
6.2 避免单点故障:哨兵与集群模式
- 单机模式:若 Redis 单节点出现故障,锁服务不可用。生产环境应避免使用单机模式。
- 哨兵模式(Sentinel):可配置多个 Redis 实例组成哨兵集群,实现主从切换与自动故障转移。Redisson 与 Jedis 都支持哨兵模式的连接。
- 集群模式(Cluster):Redis Cluster 将数据分片到多台节点,可实现更高的可用与可扩展。Redisson 也支持 Cluster 模式下的分布式锁。需注意:在 Cluster 模式下,使用分布式锁时要保证加锁与解锁操作发送到同一主节点,否则由于网络分片机制造成一致性问题。
6.3 看门狗(Watchdog)机制与续期
- 看门狗概念:一些客户端(如 Redisson)会在加锁时启动一个“看门狗”线程,不断向 Redis 发送
PEXPIRE
延长过期时间,防止锁在持有过程中因过期而被其他客户端误获取。 - 实现原理:Redisson 在
lock()
或tryLock()
成功后,会根据锁的 leaseTime 或默认值,启动一个后台定时任务,周期性地续期。例如默认 leaseTime=30 秒时,每隔 10 秒(默认 1/3)向 Redis 发送延时续命令,直到调用unlock()
或看门狗检测到应用宕机。 - 注意:如果使用自己手撰的
SET NX PX
方案,需要自行实现续期逻辑,否则锁在超时时间到达后,Redis 会自动删除,可能导致持锁客户端仍在执行业务时锁被误释放。
完整实战示例:秒杀场景下的库存扣减
下面通过一个典型的“秒杀系统”案例,将前文所述技术串联起来,演示如何在高并发场景下,利用 Redis 分布式锁与 Lua 脚本实现原子库存扣减并防止超卖。
7.1 需求描述与设计思路
- 场景:假设某电商平台对某款热门商品发起秒杀活动,初始库存为 100 件。短时间内可能有上万用户并发请求秒杀。
核心挑战:
- 防止超卖:在高度并发下,只允许库存 > 0 时才能扣减,扣减后库存减 1,并录入订单信息。
- 保证原子性:库存检查与扣减必须在 Redis 端原子执行,防止出现并发竞态造成库存负数(即超卖)。
- 分布式锁保护:在订单生成和库存扣减的代码区域,需保证同一件商品只有一个线程能操作库存。
解决方案思路:
- 使用 Redis Lua 脚本,将“检查库存 + 扣减库存 + 记录订单”三步操作打包为一次原子执行,保证不会中途被其他客户端打断。
- 使用分布式锁(Redisson 或原生
SET NX PX
+ Lua 解锁脚本)保护下单流程,避免在库存扣减与订单写库之间发生并发冲突。 - 结合本地缓存或消息队列做削峰,进一步减轻 Redis 压力,此处主要聚焦 Redis 分布式锁与 Lua 脚本实现,不展开队列削峰。
7.2 Lua 脚本实现原子库存扣减
7.2.1 脚本逻辑
将以下 Lua 脚本保存为 seckill.lua
,放置在项目资源目录(如 resources/scripts/seckill.lua
):
-- seckill.lua
-- KEYS[1] = 库存 key,例如 "seckill:stock:1001"
-- KEYS[2] = 订单 key,例如 "seckill:order:userId"
-- ARGV[1] = 当前用户 ID (用户标识)
-- ARGV[2] = 秒杀订单流水号 (唯一 ID)
-- 查询当前库存
local stock = tonumber(redis.call("GET", KEYS[1]) or "-1")
if stock <= 0 then
-- 库存不足,直接返回 0 表示秒杀失败
return 0
else
-- 库存充足,扣减库存
redis.call("DECR", KEYS[1])
-- 生成用户订单,可以把订单流水号存入一个 Set 或者按需存储
-- 这里示例为将订单记录到 HASH 结构中,key 为 KEYS[2], field 为 用户ID, value 为 订单流水号
redis.call("HSET", KEYS[2], ARGV[1], ARGV[2])
-- 返回 1 表示秒杀成功
return 1
end
参数说明:
KEYS[1]
:当前商品的库存键,初始值为库存数量
。KEYS[2]
:用于存储所有成功秒杀订单的键(HASH 结构),键名格式可自定义,如seckill:order:1001
表示商品 ID 为 1001 的订单集合。ARGV[1]
:秒杀用户 ID,用于作为HASH
的 field。ARGV[2]
:秒杀订单流水号,用于作为HASH
的 value。
执行逻辑:
- 通过
redis.call("GET", KEYS[1])
获取当前库存数,若<= 0
返回 0,秒杀失败; - 否则,执行
DECR
扣减库存; - 将该用户的订单流水号记录到
HSET KEYS[2] ARGV[1] ARGV[2]
,用于后续下游处理(如持久化到数据库)。 - 最后返回 1,表示秒杀成功。
- 通过
7.2.2 优势分析
- 由于整个脚本在 Redis 端以单次原子操作执行,不会被其他客户端命令插入,因此库存检查与扣减的逻辑绝对不会出现竞态,避免了“超卖”。
- 通过
HSET
记录订单,仅当扣减库存成功时才执行,保证库存与订单信息一致。 - Lua 脚本执行速度远快于客户端多次
GET
/DECR
/HSET
的网络往返,性能更高。
7.3 Java 端集成与高并发测试
下面以 Spring Boot + Spring Data Redis 为例,展示如何加载并执行 seckill.lua
脚本,并模拟高并发进行秒杀测试。
7.3.1 项目依赖(pom.xml
)
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Lettuce Client(Redis 客户端) -->
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</dependency>
<!-- Redisson,用于分布式锁 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.25.0</version>
</dependency>
</dependencies>
7.3.2 加载 Lua 脚本 Bean
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
@Configuration
public class SeckillScriptConfig {
@Bean
public DefaultRedisScript<Long> seckillScript() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setLocation(new ClassPathResource("scripts/seckill.lua"));
script.setResultType(Long.class);
return script;
}
}
7.3.3 秒杀服务实现
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
public class SeckillService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private DefaultRedisScript<Long> seckillScript;
@Autowired
private RedissonClient redissonClient;
// 模拟秒杀接口
public String seckill(String productId, String userId) {
String stockKey = "seckill:stock:" + productId;
String orderKey = "seckill:order:" + productId;
String orderId = UUID.randomUUID().toString();
// 1. 获取分布式锁,防止同一用户并发重复购买(可选)
String userLockKey = "seckill:userLock:" + userId;
RLock userLock = redissonClient.getLock(userLockKey);
boolean lockAcquired = false;
try {
lockAcquired = userLock.tryLock(3, 5, TimeUnit.SECONDS);
if (!lockAcquired) {
return "请勿重复请求";
}
// 2. 调用 Lua 脚本执行原子库存扣减 + 记录订单
Long result = redisTemplate.execute(
seckillScript,
Collections.singletonList(stockKey),
Collections.singletonList(orderKey),
userId,
orderId
);
if (result != null && result == 1) {
return "秒杀成功,订单ID=" + orderId;
} else {
return "秒杀失败,库存不足";
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "系统异常,请重试";
} finally {
if (lockAcquired && userLock.isHeldByCurrentThread()) {
userLock.unlock();
}
}
}
/**
* 初始化库存,用于测试
*/
public void initStock(String productId, int count) {
String stockKey = "seckill:stock:" + productId;
redisTemplate.opsForValue().set(stockKey, count);
}
}
步骤解析:
分布式锁保护
- 以
userLockKey = "seckill:userLock:" + userId
为锁的 Key,只允许同一个用户在并发场景下只有一把锁,避免重复请求。 - Redisson 的
tryLock
会自动续期(看门狗),锁过期后自动解锁,防止死锁。
- 以
调用 Lua 脚本
redisTemplate.execute(seckillScript, keys, args...)
会在 Redis 端原子执行seckill.lua
脚本,实现库存检查与扣减、订单记录。- 脚本返回
1
表示扣减成功,返回0
表示库存不足。
释放分布式锁
- 无论秒杀成功或失败,都要在
finally
中释放锁,避免锁泄漏。
- 无论秒杀成功或失败,都要在
7.3.4 Controller 暴露秒杀接口
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/seckill")
public class SeckillController {
@Autowired
private SeckillService seckillService;
/**
* 初始化库存,非真实场景仅用于测试
*/
@PostMapping("/init")
public String init(@RequestParam String productId, @RequestParam int count) {
seckillService.initStock(productId, count);
return "初始化库存成功,商品ID=" + productId + ",库存=" + count;
}
/**
* 秒杀接口
*/
@PostMapping("/buy")
public String buy(@RequestParam String productId, @RequestParam String userId) {
return seckillService.seckill(productId, userId);
}
}
7.3.5 高并发测试演示
- 启动 Redis(建议单机模式即可)
- 启动 Spring Boot 应用
初始化库存
curl -X POST "http://localhost:8080/seckill/init?productId=1001&count=100"
并发模拟用户抢购
编写一个简单的脚本或使用压测工具(如 ApacheBench、JMeter)发送并发
curl
请求:for i in {1..200}; do curl -X POST "http://localhost:8080/seckill/buy?productId=1001&userId=user_$i" & done wait
- 观察执行结果,大约有
100
条返回 “秒杀成功”,其余“秒杀失败,库存不足”。 - 可以从 Redis 中查看库存剩余为 0,订单记录存储成功。
Redis 中验证结果
redis-cli > GET seckill:stock:1001 "0" > HGETALL seckill:order:1001 1) "user_1" 2) "orderId-xxx" 3) "user_2" 4) "orderId-yyy" ...
HGETALL seckill:order:1001
列出了所有成功抢购的用户 ID 及订单流水号,确保没有超卖。
通过上述示例,我们利用 Redis Lua 脚本完成了关键的“检查库存 + 扣减库存 + 记录订单”原子操作,并结合分布式锁(Redisson)防止同一用户重复请求,达到了秒杀场景下的高并发安全保护。
总结与最佳实践
本文从最基础的 SETNX
实现,到使用 Lua 脚本保证原子性,再到 Redisson 生产级分布式锁 的使用,系统地讲解了 Redis 分布式锁的原理与实践。以下几点是实际项目中经常需要注意的最佳实践与总结:
Redis 单点要避免
- 生产环境请部署 Redis Sentinel 或 Cluster,保证分布式锁服务的高可用。
- Redisson 能够自动感知主从切换,并维护锁的续期与数据一致性。
加锁时长需合理
- 业务执行时间不可预估时,推荐使用 Redisson 的 Watchdog 机制,让锁自动续期,避免锁在业务执行过程中意外过期。
- 如果选择手动管理过期时间(
PX
参数),务必确保过期时间大于业务耗时,并考虑超时续期机制。
锁粒度需细化
- 避免使用过于粗糙的全局锁,合理拆分资源维度,按业务对象(如“商品ID+用户ID”或“订单ID”)加锁,减少锁冲突。
- 可以结合本地缓存、消息队列等方式,减少对 Redis 分布式锁的压力。
Lua 脚本封装关键逻辑
- 将“检查值 + 修改值”这种需要原子执行的操作都封装到 Lua 脚本中,避免客户端多次网络往返和中途竞态。
- Lua 脚本性能优异,几乎和普通 Redis 命令一样快,可放心在高并发场景下使用。
监控与日志
- 对于分布式锁的获取与释放,需要做好监控与日志记录,尤其是失败场景的告警与追踪,保证系统可观测性。
- 记录锁获取失败的次数和耗时,帮助调试性能瓶颈和锁等待问题。
竞态重试与退避策略
- 并发非常高时,大量客户端同时抢锁,可能造成 Redis 压力陡增。可在客户端实现重试次数与退避机制,避免“热点”锁雪崩。
- 例如:
tryLock
失败后,先 sleep10ms
,再重试;若再次失败,则根据指数退避逐渐延长重试间隔。
通过深入理解分布式锁的原理、常见风险以及成熟的解决方案(如 Redisson),你可以在实际场景中灵活应用 Redis 分布式锁,保证系统在高并发情况下仍能正确、稳定地完成关键业务逻辑。
评论已关闭