Redis进阶

Redis 事务

相关内容

Redis的事务和 MySQL 的本质上是差不多的,都是保证一组命令要么都执行要么都不执行

Redis 是将多个命令打包,主要用于解决需要批量操作且保证操作完整性的场景。

Redis 事务的设计基于 “命令队列” 和 “单线程执行” 特性,核心原理可以概括为:将一组命令一次性放入队列,在执行时按顺序原子化执行,中间不会被其他命令插入。

Redis 事务的执行分为 3 个阶段:开始事务→命令入队→执行 / 放弃事务,流程如下:

  1. 开始事务:执行MULTI命令,Redis 进入事务状态。
  2. 命令入队:后续输入的命令(如SET、INCR等)不会立即执行,而是被放入事务队列,并返回 “QUEUED” 表示入队成功。
    • 若命令存在语法错误(如参数个数不对),入队时会直接报错,此时事务队列无效,后续EXEC会被忽略。
  3. 执行 / 放弃事务:
    • 执行EXEC:Redis 按顺序执行队列中所有命令,返回各命令的结果列表。
    • 执行DISCARD:清空队列,放弃事务,Redis 退出事务状态。

与传统关系型数据库(如 MySQL)的事务不同,Redis 事务的特性有其特殊性:

  • 原子性(Atomicity):事务中的命令要么全部执行,要么全部不执行(但运行时错误不会导致回滚,这点与传统事务不同)。
  • 一致性(Consistency):事务执行前后,Redis 的数据状态始终符合预期规则(如数据类型约束、业务逻辑约束)。
  • 隔离性(Isolation):事务执行期间,不会被其他客户端的命令干扰(因 Redis 单线程执行,天然隔离)。
  • 持久性(Durability):是否持久化取决于 Redis 的持久化配置(RDB/AOF),事务本身不保证持久性。

与传统事务的关键区别

  • 无回滚机制:Redis 事务在执行过程中若某条命令发生运行时错误(如对字符串执行自增操作),后续命令仍会继续执行,不会回滚已执行的命令。
  • 轻量设计:Redis 事务不支持复杂的隔离级别(如读已提交、可重复读),仅通过单线程特性保证隔离性。

Redis 事务的核心命令如下,Redis 提供了 4 个核心命令用于操作事务:

命令 作用
MULTI 标记事务开始,后续命令会被放入事务队列(不会立即执行)。
EXEC 执行事务队列中的所有命令,返回各命令的执行结果(事务结束)。
DISCARD 放弃事务,清空队列中所有命令(事务结束)。
WATCH 监控一个或多个键,若事务执行前被监控的键被修改,则事务会被打断(取消执行)。

其中,WATCH命令是 Redis 实现并发控制的核心,它通过 “乐观锁” 机制解决事务中的竞态条件(多个客户端同时修改同一数据)。

工作原理也比较清楚

  • WATCH key1 key2 ...:监控指定键,记录其当前版本(Redis 内部通过数据的 “修改次数” 实现)。
  • 当执行EXEC时,Redis 会检查被监控的键是否被其他客户端修改过:
    • 若未被修改,正常执行事务。
    • 若被修改,事务被打断(EXEC返回nil),客户端需重新尝试。

因为 Redis 的事务不能回滚,所以用 Redis 的事务不多,运行时错误需手动处理,而且事务执行时会独占 Redis 线程,若队列命令过多,会阻塞其他请求。

还记得我们之前讲的 Redis 脚本吗,这个可以啊,Redis 会将整个 Lua 脚本作为原子操作执行,支持条件判断,可替代复杂事务。

示例(用 Lua 实现库存扣减):

1
2
3
4
5
6
7
-- 若库存>0则扣减,返回1;否则返回0
local stock = tonumber(redis.call('get', KEYS[1]) or "0")
if stock > 0 then
redis.call('decr', KEYS[1])
return 1
end
return 0

实践示例

下面通过具体案例演示 Redis 事务的使用。

示例 1:基本事务操作

假设需求:批量设置两个键值对,并获取结果。那么可以这样输入命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 1. 开始事务
127.0.0.1:6379> MULTI
OK

# 2. 命令入队(不会立即执行)
127.0.0.1:6379> SET name "redis"
QUEUED
127.0.0.1:6379> SET version "6.2"
QUEUED

# 3. 执行事务(返回各命令结果)
127.0.0.1:6379> EXEC
1) OK
2) OK

# 验证结果
127.0.0.1:6379> GET name
"redis"
127.0.0.1:6379> GET version
"6.2"

示例 2:放弃事务(DISCARD)

需求:入队命令后,放弃执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET a 10
QUEUED
127.0.0.1:6379> SET b 20
QUEUED

# 放弃事务,队列清空
127.0.0.1:6379> DISCARD
OK

# 验证:a和b未被设置
127.0.0.1:6379> GET a
(nil)

示例 3:WATCH 实现乐观锁(库存扣减)

需求:某商品库存为 10,两个客户端同时尝试扣减 1,保证最终库存正确(避免超卖)。

客户端 A 操作

1
2
3
4
5
6
7
8
9
10
11
# 监控库存键
127.0.0.1:6379> WATCH stock
OK

# 开始事务
127.0.0.1:6379> MULTI
OK

# 扣减库存(此时未执行)
127.0.0.1:6379> DECR stock
QUEUED

客户端 B 操作(在 A 执行EXEC前):

1
2
3
# 修改被监控的stock键
127.0.0.1:6379> DECR stock
(integer) 9 # 库存从10变为9

客户端 A 继续执行

1
2
3
4
5
6
7
# 执行事务,因stock被B修改,事务被打断
127.0.0.1:6379> EXEC
(nil) # 返回nil表示事务未执行

# 验证:A的扣减未生效,库存为9(B修改的结果)
127.0.0.1:6379> GET stock
"9"

此时客户端 A 需重新执行事务(重新WATCHMULTIDECREXEC)。

示例 4:运行时错误的处理(无回滚)

需求:事务中某命令执行失败,观察后续命令是否继续执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
127.0.0.1:6379> MULTI
OK
# 对字符串执行自增(运行时错误)
127.0.0.1:6379> INCR name # name是字符串"redis",无法自增
QUEUED
# 正常设置键
127.0.0.1:6379> SET c 30
QUEUED

# 执行事务
127.0.0.1:6379> EXEC
1) (error) ERR value is not an integer or out of range # 第一条命令失败
2) OK # 第二条命令正常执行

# 验证:c被成功设置
127.0.0.1:6379> GET c
"30"

运行时错误不会导致事务回滚,需通过程序逻辑处理(如检查EXEC返回结果)。

Redis之生存时间

相关内容

因为 Redis 通常都是缓存,所以说,我们在进行 Redis 存缓存都需要一个过期功能,所以就需要设置 TTL

Redis 的生存时间(Time To Live,TTL)是一项重要功能,允许为键设置过期时间,当时间到期后,Redis 会自动删除这些键。这一机制广泛用于缓存过期、临时数据存储、会话管理等场景。下面从原理到实践详细讲解:

生存时间是为 Redis 键设置的 “过期倒计时”,当倒计时结束,键会被自动删除。其核心特点包括:

  • 时效性:键在指定时间后自动失效,无需手动删除。
  • 原子性:设置和过期删除操作都是原子性的,不会出现部分执行的情况。
  • 灵活性:支持设置秒级或毫秒级精度的过期时间,也可动态修改或取消。

Redis 提供了 5 个核心命令用于管理键的生存时间:

命令格式 作用说明
EXPIRE key seconds 为键设置秒级过期时间(倒计时seconds秒后删除)。
PEXPIRE key milliseconds 为键设置毫秒级过期时间(倒计时milliseconds毫秒后删除)。
EXPIREAT key timestamp 为键设置秒级过期时间戳(到达timestamp秒数时删除,基于 Unix 时间戳)。
PEXPIREAT key timestamp 为键设置毫秒级过期时间戳(到达timestamp毫秒数时删除)。
TTL key 查看键的剩余生存时间(秒级,返回 - 1 表示永不过期,-2 表示已过期 / 不存在)。
PTTL key 查看键的剩余生存时间(毫秒级,返回值含义同TTL)。
PERSIST key 移除键的过期时间,使其永不过期(若键存在且有过期时间,返回 1;否则返回 0)。

使用的时候需要注意以下内容:

  1. 精度问题:Redis 的过期时间依赖服务器时间,若服务器时间被修改,可能导致过期异常。
  2. 内存占用:大量过期键未被及时删除可能导致内存溢出,建议合理配置maxmemory-policy(如volatile-lru优先删除过期键)。
  3. 持久化影响:
    • RDB 文件:生成时会过滤已过期的键,加载时也会忽略过期键。
    • AOF 文件:过期键被删除时,会追加DEL命令到 AOF 文件,保证重放时数据一致。
  4. 集群环境:在 Redis 集群中,过期键的删除由所在节点独立处理,不影响其他节点。

生存时间的工作原理

过期键的存储:

  • Redis 会将所有设置了过期时间的键单独存储在一个 “过期字典” 中(类似哈希表),字典的键是 Redis 中的实际键,值是该键的过期时间戳(秒或毫秒)。

过期键的删除策略:

Redis 采用三种混合策略删除过期键,平衡性能和内存占用:

  • 惰性删除:访问键时才检查是否过期,若过期则删除。

    优点:不消耗额外 CPU 资源;缺点:可能导致过期键长期占用内存。

  • 定期删除:每隔一段时间(默认 100ms),随机检查部分过期键并删除。

    优点:定期释放内存;缺点:可能漏删部分过期键(但会被惰性删除兜底)。

  • 内存淘汰:当内存达到maxmemory限制时,根据配置的淘汰策略(如allkeys-lru)删除键,包括过期键。

特殊注意事项

  • 对键执行 SET、GETSET 等覆盖操作时,会清除原有的过期时间(键变为永不过期)。

    1
    2
    3
    4
    5
    6
    7
    8
    127.0.0.1:6379> SET name "redis"
    OK
    127.0.0.1:6379> EXPIRE name 100 # 设置100秒过期
    (integer) 1
    127.0.0.1:6379> SET name "new-redis" # 覆盖键,过期时间被清除
    OK
    127.0.0.1:6379> TTL name
    (integer) -1 # 永不过期
  • 对键执行INCRHSET等非覆盖操作时,不会影响其过期时间。

  • 重命名键(RENAME)时,原键的过期时间会转移到新键上。

  • 复制键(COPY)时,默认新键不会继承原键的过期时间(可通过REPLACE参数控制)。

实践示例

示例 1:基本过期时间设置与查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 设置键并指定过期时间(10秒后删除)
127.0.0.1:6379> SET session "user123" EX 10
OK

# 立即查看剩余时间(约10秒)
127.0.0.1:6379> TTL session
(integer) 8

# 5秒后再次查看(剩余约5秒)
127.0.0.1:6379> TTL session
(integer) 3

# 10秒后查看(键已被删除)
127.0.0.1:6379> TTL session
(integer) -2
127.0.0.1:6379> GET session
(nil)

示例 2:毫秒级精度控制

1
2
3
4
5
6
7
# 设置毫秒级过期时间(5000毫秒=5秒)
127.0.0.1:6379> PEXPIRE temp 5000
(integer) 1

# 查看毫秒级剩余时间
127.0.0.1:6379> PTTL temp
(integer) 4892 # 约4892毫秒

示例 3:取消过期时间,设置为永久

1
2
3
4
5
6
7
8
9
10
11
12
127.0.0.1:6379> SET cache "data" EX 300
OK
127.0.0.1:6379> TTL cache
(integer) 295

# 取消过期时间
127.0.0.1:6379> PERSIST cache
(integer) 1

# 验证:变为永不过期
127.0.0.1:6379> TTL cache
(integer) -1

示例 4:通过时间戳设置过期时间

1
2
3
4
5
6
7
8
9
10
11
12
# 获取当前Unix时间戳(秒)
127.0.0.1:6379> TIME
1) "1695000000" # 秒
2) "123456" # 微秒

# 设置键在1695000060秒(当前时间+60秒)过期
127.0.0.1:6379> EXPIREAT event 1695000060
(integer) 1

# 验证剩余时间(约60秒)
127.0.0.1:6379> TTL event
(integer) 58

常见使用场景

  1. 缓存过期管理:为缓存数据设置过期时间,自动淘汰旧数据(如商品详情缓存 1 小时)。
  2. 会话管理:用户登录会话设置过期时间(如 30 分钟无操作自动登出)。
  3. 限时活动:秒杀活动倒计时、优惠券有效期等场景。
  4. 限流控制:记录接口访问频率,设置短期过期时间(如 1 分钟内最多 100 次请求)。
  5. 临时数据存储:验证码(5 分钟有效)、临时令牌等。

SpringBoot集成Redis

下面我们写一个我正在开发的例子,结合 Spring Security,进行 Redis 存储邮箱验证码的实际使用例子

使用 spring-boot-starter-mail

首先引入依赖

1
2
3
4
5
6
7
8
9
10
<!-- mail -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

然后进行如下的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
data:
# Redis配置
redis:
# Redis服务器地址,默认本地,端口,默认6379
host: localhost
port: 6379
password: ${REDIS_PASSWORD}
timeout: 2000
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 2

来都来了,也就把邮件的服务配置一下吧,注意,如果你使用的是 QQ 邮箱,需要申请SMTP服务,登陆QQ邮箱「设置」 - 「账户」找到SMTP选项,选择开启服务,生成授权码。

1
2
3
4
5
6
7
8
9
10
11
spring:
#mail
mail:
host: smtp.qq.com
# 端口号
port: 587
# 发送邮件的邮箱地址
username: xxxxxxxx
# QQ邮箱获得的授权码
password: xxxxxxxx

接下来我们定义操作邮件的 Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import cn.hutool.core.util.StrUtil;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

import javax.mail.internet.MimeMessage;
import java.io.File;

@Service
public class EmailService {

@Autowired
private JavaMailSender mailSender;

@Value("${spring.mail.username}")
private String sendFrom;

/**
* 不带附件邮件
* @param subject 主题
* @param content 内容
* @param sendTo 定义可变参数 实现邮件发送多个邮箱
*/
@SneakyThrows(Exception.class)
public void sendSimpleMail(String subject, String content,String... sendTo) {

MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper messageHelper = new MimeMessageHelper(message, true);
messageHelper.setFrom(sendFrom);
messageHelper.setTo(sendTo);
messageHelper.setSubject(subject);
messageHelper.setText(content,true);
mailSender.send(message);
}

/**
* 带附件邮件
* @param subject 主题
* @param content 内容
*
* @param filePath 附件路径
* @param sendTo 定义可变参数 实现邮件发送多个邮箱
*/
@SneakyThrows(Exception.class)
public void sendAttachmentsMail(String subject, String content,String attachmentName, String filePath, String... sendTo){

MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper messageHelper = new MimeMessageHelper(message, true);
messageHelper.setFrom(sendFrom);
messageHelper.setTo(sendTo);
messageHelper.setSubject(subject);
messageHelper.setText(content,true);
//判断附件
if(StrUtil.isNotBlank(filePath)){
FileSystemResource file = new FileSystemResource(new File(filePath));
//没有传递附件名称则默认使用文件名
attachmentName = StrUtil.isNotBlank(filePath)?attachmentName:filePath.substring(filePath.lastIndexOf(File.separator));
messageHelper.addAttachment(attachmentName, file);
}
mailSender.send(message);
}
}

把你的User相关的实体类写好

1
2
3
4
5
6
7
8
9
10
11
12
import lombok.Data;

@Data
public class User {
private Long id;
private String username;
private String password;
private String email;
private boolean isActivated;
// 其他用户相关字段...
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import lombok.Data;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;

@Data
public class UserRegisterDTO {
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;

@NotBlank(message = "用户名不能为空")
@Size(min = 4, max = 20, message = "用户名长度必须在4-20之间")
private String username;

@NotBlank(message = "密码不能为空")
@Size(min = 6, message = "密码长度不能少于6位")
private String password;

@NotBlank(message = "验证码不能为空")
private String verificationCode;
}

创建一个控制器处理用户注册和账户激活请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import org.springframework.web.bind.annotation.*;

@RestController
public class UserController {

@Resource
private UserMapper userMapper;

@Resource
private EmailService emailService;

@Resource
private VerificationCodeService verificationCodeService;

/**
* 发送验证码到邮箱
*/
@PostMapping("/send-verification-code")
public String sendVerificationCode(@RequestParam String email) {
// 检查邮箱是否已注册
User existingUser = userMapper.getByEmail(email);
if (existingUser != null) {
return "该邮箱已被注册";
}

// 生成验证码
String code = verificationCodeService.generateCode();

// 存储验证码到Redis
verificationCodeService.storeCode(email, code);

// 发送验证码邮件
String subject = "注册验证码";
String content = "您的注册验证码是: <strong>" + code + "</strong>,有效期5分钟,请尽快完成验证。";
emailService.sendSimpleMail(subject, content, email);

return "验证码已发送到您的邮箱,请查收";
}

/**
* 使用验证码完成注册
*/
@PostMapping("/register")
public String registerUser(@RequestBody UserRegisterDTO user) {
// 验证验证码
boolean isCodeValid = verificationCodeService.verifyCode(user.getEmail(), user.getVerificationCode());
if (!isCodeValid) {
return "验证码无效或已过期";
}

// 验证码验证成功,删除验证码
verificationCodeService.removeCode(user.getEmail());

// 创建用户
User newUser = new User();
newUser.setEmail(user.getEmail());
newUser.setUsername(user.getUsername());
newUser.setPassword(user.getPassword()); // 实际项目中应该加密存储密码
newUser.setActivated(true); // 验证成功直接激活

userMapper.insert(newUser);

return "注册成功!";
}

/**
* 忘记密码 - 发送重置密码验证码
*/
@PostMapping("/forgot-password")
public String forgotPassword(@RequestParam String email) {
// 检查邮箱是否已注册
User user = userMapper.getByEmail(email);
if (user == null) {
return "该邮箱未注册";
}

// 生成验证码
String code = verificationCodeService.generateCode();

// 存储验证码到Redis
verificationCodeService.storeCode("reset:" + email, code);

// 发送验证码邮件
String subject = "密码重置验证码";
String content = "您的密码重置验证码是: <strong>" + code + "</strong>,有效期5分钟,请尽快完成验证。";
emailService.sendSimpleMail(subject, content, email);

return "密码重置验证码已发送到您的邮箱,请查收";
}

/**
* 重置密码
*/
@PostMapping("/reset-password")
public String resetPassword(@RequestParam String email,
@RequestParam String verificationCode,
@RequestParam String newPassword) {
// 验证验证码
boolean isCodeValid = verificationCodeService.verifyCode("reset:" + email, verificationCode);
if (!isCodeValid) {
return "验证码无效或已过期";
}

// 更新密码
User user = userMapper.getByEmail(email);
user.setPassword(newPassword); // 实际项目中应该加密存储密码
userMapper.updatePassword(user);

// 删除验证码
verificationCodeService.removeCode("reset:" + email);

return "密码重置成功";
}
}

欸,是不是差了点什么,没错,到这里,Spring Boot 结合 邮件发送的开发完成了,接下来,我们编写一个验证码相关的服务,来体现 redis 在其中的作用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Random;
import java.util.concurrent.TimeUnit;

@Service
public class VerificationCodeService {

private final StringRedisTemplate redisTemplate;

// 验证码过期时间(分钟)
private static final int CODE_EXPIRATION_MINUTES = 5;

// Redis中存储验证码的键前缀
private static final String CODE_KEY_PREFIX = "verify:code:";

public VerificationCodeService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}

/**
* 生成6位数字验证码
*/
public String generateCode() {
Random random = new Random();
StringBuilder code = new StringBuilder();
for (int i = 0; i < 6; i++) {
code.append(random.nextInt(10));
}
return code.toString();
}

/**
* 存储验证码到Redis并设置过期时间
*/
public void storeCode(String email, String code) {
String key = CODE_KEY_PREFIX + email;
redisTemplate.opsForValue().set(key, code, CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES);
}

/**
* 从Redis获取验证码
*/
public String getCode(String email) {
String key = CODE_KEY_PREFIX + email;
return redisTemplate.opsForValue().get(key);
}

/**
* 验证验证码是否正确
*/
public boolean verifyCode(String email, String inputCode) {
String storedCode = getCode(email);
if (storedCode == null) {
return false; // 验证码已过期
}
return storedCode.equals(inputCode);
}

/**
* 验证成功后删除验证码
*/
public void removeCode(String email) {
String key = CODE_KEY_PREFIX + email;
redisTemplate.delete(key);
}
}

可以看到,最后验证码具有实际开发使用的优势,在于 redis 给的

  1. 自动过期:无需手动清理过期验证码,Redis 会自动删除过期数据
  2. 分布式支持:在集群环境下,Redis 可以作为中央存储,确保验证码在多个服务实例间共享
  3. 高性能:Redis 的读写性能远高于数据库,适合存储这类高频访问的临时数据
  4. 减轻数据库负担:避免使用数据库存储临时验证码数据

基于Redis的分布式锁

基于 Redis 的分布式锁是解决分布式系统中资源竞争问题的常用方案,尤其适用于多个服务实例需要共享资源(如你案例中的邮件验证码发送限制、用户注册并发控制等场景)。

在单机应用中,我们可以用synchronizedLock实现线程同步,但在分布式系统(多服务实例)中,这些本地锁会失效。Redis 分布式锁的核心思路是:

  1. 多个服务实例通过 Redis 的原子操作竞争同一个 “锁键”
  2. 谁能成功创建这个锁键,谁就获得锁,执行临界操作
  3. 操作完成后释放锁,其他实例才能竞争

核心要求:

  • 互斥性:同一时间只有一个实例能获得锁
  • 安全性:锁必须被持有它的实例释放,防止误释放
  • 可用性:锁最终必须能被释放(如设置过期时间)

Redis 分布式锁的实现:

  1. 获取锁:使用SET key value NX PX timeout命令

    • NX:只有键不存在时才设置成功(保证互斥)
    • PX timeout:自动过期时间(防止死锁)
    • value:建议用 UUID,作为释放锁的唯一标识(防止误释放)
  2. 释放锁:必须校验 value 是否匹配,再删除键(需用 Lua 脚本保证原子性)

    1
    2
    3
    4
    5
    if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
    else
    return 0
    end

我们改造上述的例子,来体现结合邮件验证码案例如何实现分布式锁

以 “限制同一邮箱 1 分钟内最多发送 3 次验证码” 为例,创建分布式锁工具类(RedisDistributedLock)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Component
public class RedisDistributedLock {

private final StringRedisTemplate redisTemplate;
// 释放锁的Lua脚本
private static final String UNLOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

public RedisDistributedLock(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}

/**
* 获取分布式锁
* @param lockKey 锁的键
* @param expireTime 过期时间(秒)
* @return 锁的唯一标识(释放时需要),null表示获取失败
*/
public String tryLock(String lockKey, long expireTime) {
String lockValue = UUID.randomUUID().toString();
// 使用SET NX PX命令获取锁
Boolean success = redisTemplate.opsForValue().setIfAbsent(
lockKey,
lockValue,
expireTime,
TimeUnit.SECONDS
);
return success != null && success ? lockValue : null;
}

/**
* 释放分布式锁
* @param lockKey 锁的键
* @param lockValue 获取锁时返回的唯一标识
* @return 是否释放成功
*/
public boolean unlock(String lockKey, String lockValue) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(
script,
Collections.singletonList(lockKey),
lockValue
);
return result != null && result > 0;
}
}

  • tryLock:通过setIfAbsent实现原子性加锁,返回唯一标识
  • unlock:通过 Lua 脚本保证 “校验 + 删除” 的原子性,防止误释放其他实例的锁

按照上面的redis分布式锁,改造我们的验证码发送服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Random;
import java.util.concurrent.TimeUnit;

@Service
public class VerificationCodeService {

private final StringRedisTemplate redisTemplate;
private final RedisDistributedLock distributedLock;
// 验证码前缀(区分不同业务)
private static final String CODE_PREFIX = "verify:code:";
// 发送次数限制前缀
private static final String SEND_COUNT_PREFIX = "verify:count:";
// 分布式锁前缀
private static final String LOCK_PREFIX = "lock:verify:";

public VerificationCodeService(StringRedisTemplate redisTemplate, RedisDistributedLock distributedLock) {
this.redisTemplate = redisTemplate;
this.distributedLock = distributedLock;
}

/**
* 生成并发送验证码(带频率限制)
*/
public String generateAndSendCode(String email, EmailService emailService) {
// 1. 构建锁键(按邮箱粒度加锁)
String lockKey = LOCK_PREFIX + email;
String lockValue = null;

try {
// 2. 获取锁(10秒过期,防止死锁)
lockValue = distributedLock.tryLock(lockKey, 10);
if (lockValue == null) {
return "操作太频繁,请稍后再试";
}

// 3. 检查发送频率(1分钟内最多3次)
String countKey = SEND_COUNT_PREFIX + email;
String countStr = redisTemplate.opsForValue().get(countKey);
int count = countStr == null ? 0 : Integer.parseInt(countStr);

if (count >= 3) {
return "1分钟内最多发送3次验证码";
}

// 4. 生成6位验证码
String code = generateCode();

// 5. 存储验证码(5分钟过期)
String codeKey = CODE_PREFIX + email;
redisTemplate.opsForValue().set(codeKey, code, 5, TimeUnit.MINUTES);

// 6. 累加发送次数(1分钟过期)
redisTemplate.opsForValue().increment(countKey);
redisTemplate.expire(countKey, 1, TimeUnit.MINUTES);

// 7. 发送邮件
emailService.sendSimpleMail("验证码", "您的验证码是:" + code, email);
return "验证码发送成功";

} finally {
// 8. 释放锁(必须在finally中执行)
if (lockValue != null) {
distributedLock.unlock(lockKey, lockValue);
}
}
}

// 生成6位数字验证码
private String generateCode() {
return String.format("%06d", new Random().nextInt(999999));
}

// 验证验证码
public boolean verifyCode(String email, String code) {
String codeKey = CODE_PREFIX + email;
String storedCode = redisTemplate.opsForValue().get(codeKey);
if (code.equals(storedCode)) {
redisTemplate.delete(codeKey); // 验证成功后删除
return true;
}
return false;
}
}

我们优化了验证码服务

  • 同一邮箱加锁,防止并发请求绕过频率限制
  • 用 Redis 计数器限制 1 分钟内最多发送 3 次验证码
  • 锁的过期时间(10 秒)远大于业务执行时间(发送邮件通常 < 3 秒),避免死锁

实际上,Redisson 框架,自带分布式所,而且还是很牛逼的锁,真正在 redis 中执著锁的不是很多,把锁从数据库层面改造到代码层面往往更加靠谱和更加清楚