1. 分布式锁
1. 单实例条件下的分布式锁
1. 使用 lua 脚本
加锁代码
-- 加锁操作
-- KEYS[1]: 锁的键(lock_key)
-- ARGV[1]: 当前客户端的标识(client_id)
-- ARGV[2]: 锁的过期时间(毫秒)
if (redis.call('EXISTS', KEYS[1]) == 0) then
-- 如果锁不存在,则进行加锁
redis.call('SET', KEYS[1], ARGV[1])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
elseif (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 如果锁已存在且是当前客户端持有的,则续期
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
else
-- 如果锁已存在且不是当前客户端持有的,返回失败
return 0
end
解锁代码
-- 解锁操作
-- KEYS[1]: 锁的键(lock_key)
-- ARGV[1]: 当前客户端的标识(client_id)
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 如果当前客户端持有锁,则解锁
redis.call('DEL', KEYS[1])
return 1
else
-- 如果不是当前客户端持有锁,返回失败
return 0
end
使用 Redisson 执行 Lua 脚本
@Service
public class RedisLockService {
@Autowired
private RedissonClient redissonClient;
// 加锁操作
public boolean lock(String lockKey, String clientId, long expireTime) {
String script =
"if (redis.call('EXISTS', KEYS[1]) == 0) then " +
" redis.call('SET', KEYS[1], ARGV[1]); " +
" redis.call('PEXPIRE', KEYS[1], ARGV[2]); " +
" return 1; " +
"elseif (redis.call('GET', KEYS[1]) == ARGV[1]) then " +
" redis.call('PEXPIRE', KEYS[1], ARGV[2]); " +
" return 1; " +
"else " +
" return 0; " +
"end";
Long result = (Long) redissonClient.getScript().eval(
RScript.Mode.READ_WRITE,
script,
RScript.ReturnType.INTEGER,
java.util.Collections.singletonList(lockKey),
clientId,
String.valueOf(expireTime)
);
return result != null && result == 1;
}
// 解锁操作
public boolean unlock(String lockKey, String clientId) {
String script =
"if (redis.call('GET', KEYS[1]) == ARGV[1]) then " +
" redis.call('DEL', KEYS[1]); " +
" return 1; " +
"else " +
" return 0; " +
"end";
Long result = (Long) redissonClient.getScript().eval(
RScript.Mode.READ_WRITE,
script,
RScript.ReturnType.INTEGER,
java.util.Collections.singletonList(lockKey),
clientId
);
return result != null && result == 1;
}
}
2. 使用 lua 脚本实现可重入分布式锁
加锁代码
加锁脚本需要检测当前锁的持有者是否是同一个客户端。如果是同一个客户端,则增加计数器,否则返回失败。
-- 加锁操作
-- KEYS[1]: 锁的键(lock_key)
-- ARGV[1]: 当前客户端的标识(client_id)
-- ARGV[2]: 锁的过期时间(毫秒)
-- 检查锁的持有者
local lockOwner = redis.call('HGET', KEYS[1], 'owner')
if lockOwner == false then
-- 如果锁不存在,则初始化锁的持有者和计数器
redis.call('HSET', KEYS[1], 'owner', ARGV[1])
redis.call('HSET', KEYS[1], 'count', 1)
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
elseif lockOwner == ARGV[1] then
-- 如果锁已存在且是当前客户端持有的,则增加计数器
local count = redis.call('HINCRBY', KEYS[1], 'count', 1)
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return count
else
-- 如果锁已存在且不是当前客户端持有的,返回失败
return 0
end
解锁代码
解锁脚本需要检测当前锁的持有者是否是当前客户端。如果是,则减少计数器,当计数器减到 0 时,才释放锁。
-- 解锁操作
-- KEYS[1]: 锁的键(lock_key)
-- ARGV[1]: 当前客户端的标识(client_id)
-- 检查锁的持有者
local lockOwner = redis.call('HGET', KEYS[1], 'owner')
if lockOwner == ARGV[1] then
-- 如果是当前客户端持有锁,则减少计数器
local count = redis.call('HINCRBY', KEYS[1], 'count', -1)
if count == 0 then
-- 如果计数器为 0,则删除锁
redis.call('DEL', KEYS[1])
else
-- 如果计数器不为 0,则更新过期时间
redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return count
else
-- 如果不是当前客户端持有锁,返回失败
return -1
end
使用 Redisson 执行 Lua 脚本
@Service
public class ReentrantLockService {
@Autowired
private RedissonClient redissonClient;
// 加锁操作
public boolean lock(String lockKey, String clientId, long expireTime) {
String script =
"local lockOwner = redis.call('HGET', KEYS[1], 'owner') " +
"if lockOwner == false then " +
" redis.call('HSET', KEYS[1], 'owner', ARGV[1]) " +
" redis.call('HSET', KEYS[1], 'count', 1) " +
" redis.call('PEXPIRE', KEYS[1], ARGV[2]) " +
" return 1 " +
"elseif lockOwner == ARGV[1] then " +
" local count = redis.call('HINCRBY', KEYS[1], 'count', 1) " +
" redis.call('PEXPIRE', KEYS[1], ARGV[2]) " +
" return count " +
"else " +
" return 0 " +
"end";
Long result = (Long) redissonClient.getScript().eval(
RScript.Mode.READ_WRITE,
script,
RScript.ReturnType.INTEGER,
java.util.Collections.singletonList(lockKey),
clientId,
String.valueOf(expireTime)
);
return result != null && result > 0;
}
// 解锁操作
public boolean unlock(String lockKey, String clientId, long expireTime) {
String script =
"local lockOwner = redis.call('HGET', KEYS[1], 'owner') " +
"if lockOwner == ARGV[1] then " +
" local count = redis.call('HINCRBY', KEYS[1], 'count', -1) " +
" if count == 0 then " +
" redis.call('DEL', KEYS[1]) " +
" else " +
" redis.call('PEXPIRE', KEYS[1], ARGV[2]) " +
" end " +
" return count " +
"else " +
" return -1 " +
"end";
Long result = (Long) redissonClient.getScript().eval(
RScript.Mode.READ_WRITE,
script,
RScript.ReturnType.INTEGER,
java.util.Collections.singletonList(lockKey),
clientId,
String.valueOf(expireTime)
);
return result != null && result >= 0;
}
}
3. 使用 Redisson 库实现分布式锁
添加依赖 在 pom.xml 文件中添加 Redisson 的依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.21.1</version>
</dependency>
配置 Redisson 在 application.properties 或 application.yml 文件中配置 Redis 的连接信息:
spring:
redis:
host: localhost
port: 6379
创建 Redisson 配置类 创建一个配置类来初始化 RedissonClient:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379"); // Redis 单实例地址
return Redisson.create(config);
}
}
实现分布式锁服务 创建一个服务类来演示加锁和解锁操作:
@Service
public class DistributedLockService {
@Autowired
private RedissonClient redissonClient;
/**
* 加锁操作
*
* @param lockKey 锁的键
* @param leaseTime 锁的过期时间(秒)
* @return 是否加锁成功
*/
public boolean lock(String lockKey, long leaseTime) {
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,最多等待 10 秒,锁的租约时间为指定的 leaseTime
return lock.tryLock(10, leaseTime, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
/**
* 解锁操作
*
* @param lockKey 锁的键
*/
public void unlock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
加锁和解锁操作解释
- 加锁操作 (lock 方法)
- 通过 RedissonClient.getLock(lockKey) 获取一个分布式锁对象 RLock。
- 使用 lock.tryLock(10, leaseTime, TimeUnit.SECONDS) 尝试获取锁。
- 10 表示最大等待时间(秒),即在等待锁的过程中最大尝试时间。
- leaseTime 是锁的租约时间,即锁被持有的时间(秒)。超过该时间,锁会自动释放。
- 如果锁获取成功,返回 true,否则返回 false。
- 解锁操作 (unlock 方法)
- 检查当前线程是否持有该锁:lock.isHeldByCurrentThread()。
- 如果当前线程持有锁,则调用 lock.unlock() 释放锁。
使用示例
在 Controller 或 Service 中使用 DistributedLockService,演示如何调用加锁和解锁:
@RestController
@RequestMapping("/lock")
public class LockController {
@Autowired
private DistributedLockService distributedLockService;
@GetMapping("/doSomething")
public String doSomething() {
String lockKey = "myLockKey";
boolean isLocked = distributedLockService.lock(lockKey, 30); // 锁的租约时间 30 秒
if (isLocked) {
try {
// 业务逻辑操作
return "操作成功";
} finally {
// 解锁
distributedLockService.unlock(lockKey);
}
} else {
return "获取锁失败,稍后重试";
}
}
}
- 使用 Redisson 非常方便,它自动处理了分布式锁的各种边缘情况,例如锁的过期时间、可重入性和网络分区问题。
- RLock 默认是可重入锁,这意味着同一个客户端(同一线程)可以多次获取同一把锁,所以上面的代码也可以作为可重入锁的代码。
- 如果业务逻辑涉及多线程或长时间操作,Redisson 的内置锁机制能有效管理锁的状态并防止死锁。
4. 自动续期
- 在使用 Redis 设置分布式锁时,如果任务在锁的过期时间快到了但尚未完成,可以采取以下几种策略来处理:
- 设置合理的过期时间:在任务开始时,根据预估的处理时间合理设置锁的过期时间。如果任务的处理时间通常较长,可以考虑设置一个较长的过期时间,以减少续期的需要。
- 使用线程守护机制:在业务逻辑执行时,可以启动一个线程或使用定时器,在任务执行过程中,可以定期(如每隔一定时间)检查锁的状态,并在必要时延长锁的过期时间。这可以通过调用 PEXPIRE 命令实现。
2. 集群条件下的分布式锁
1. 使用 Redisson 对 redis 集群进行加锁
- Redisson 实现分布式锁的特点
- 高可靠性:Redisson 能够在 Redis 集群架构下使用主从复制模式,实现锁的高可靠性。
- 这里的高可靠性是限于同一个节点的主从节点范围内的,如果加锁的主从节点都挂了话,那么锁就没有了。
- 易用性:Redisson 封装了复杂的锁定机制,开发者只需要简单的 API 调用即可实现分布式锁。
- 内置支持:Redisson 具有内置的锁过期机制和防止死锁的机制,避免了由于异常导致的锁未释放问题。
- 可重入性:Redisson 对 Redis 集群添加的分布式锁是支持可重入性的。这意味着在同一个线程中,获取锁的线程可以多次获取同一个锁,而无需担心死锁或冲突。
- 这里可重入性的参考维度是客户端,而不是线程。
配置 Redisson
在 Redis 集群架构下,需要配置 Redisson 客户端连接 Redis 集群。可以在 Spring Boot 项目的配置文件中使用 application.yml 或 application.properties 进行配置。
spring:
redis:
cluster:
nodes:
- 127.0.0.1:6379
- 127.0.0.1:6380
- 127.0.0.1:6381
redisson:
config: |
clusterServersConfig:
idleConnectionTimeout: 10000
connectTimeout: 10000
timeout: 3000
retryAttempts: 3
retryInterval: 1500
scanInterval: 2000
nodeAddresses:
- redis://127.0.0.1:6379
- redis://127.0.0.1:6380
- redis://127.0.0.1:6381
password: null
subscriptionsPerConnection: 5
clientName: null
idleConnectionTimeout: 10000
pingTimeout: 1000
keepAlive: true
tcpNoDelay: true
dnsMonitoringInterval: 5000
配置 Redisson 客户端 Bean
创建一个配置类,初始化 RedissonClient:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
// 配置 Redis 集群节点
config.useClusterServers()
.addNodeAddress("redis://127.0.0.1:6379", "redis://127.0.0.1:6380", "redis://127.0.0.1:6381")
.setScanInterval(2000) // 集群节点扫描间隔
.setRetryAttempts(3) // 重试次数
.setRetryInterval(1500) // 重试间隔
.setConnectTimeout(10000) // 连接超时
.setTimeout(3000); // 命令等待响应超时
return Redisson.create(config);
}
}
使用 Redisson 分布式锁
@Service
public class MyService {
@Autowired
private RedissonClient redissonClient;
public void doSomethingWithLock() {
// 获取可重入锁
RLock lock = redisson.getLock("myReentrantLock");
try {
// 尝试获取锁,等待最多 10 秒,锁定时间为 5 秒
if (lock.tryLock(10, 5, TimeUnit.SECONDS)) {
try {
// 第一次加锁成功,执行业务逻辑
System.out.println("Lock acquired for the first time!");
// 再次获取同一把锁(可重入)
lock.lock();
System.out.println("Lock acquired again (reentrant)!");
// 业务逻辑处理
// ...
} finally {
// 释放锁(可重入锁的计数器递减)
lock.unlock();
System.out.println("Lock released once!");
// 再次释放锁
lock.unlock();
System.out.println("Lock fully released!");
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2. 使用 Lua 脚本对 redis 集群进行加锁
可以使用 Lua 脚本在 Redis 集群中实现分布式锁。Lua 脚本在 Redis 中是原子执行的,因此能够有效地确保分布式锁的原子性和一致性。通过 Lua 脚本,可以精确控制锁的获取和释放逻辑,从而避免在高并发环境下的竞态条件。
不过在 Redis 集群架构下,使用 Lua 脚本进行分布式锁需要特别注意一些事项,因为 Redis 集群的数据是分片存储的,每个键可能位于不同的分片节点上。这会导致以下几点挑战:
- Redis 集群不支持跨节点事务:在 Redis 集群模式下,如果 Lua 脚本涉及多个键,这些键必须位于同一个分片上才能保证 Lua 脚本的原子性执行。
- 哈希标签(Hash Tag)解决方案:为了确保多个键位于同一分片,可以使用 哈希标签。哈希标签是指在键名中使用 {} 包裹的部分,Redis 会对这个部分进行一致哈希计算,将具有相同哈希标签的键存储在同一个分片上。
Lua 脚本实现 Redis 分布式锁
以下是一个示例,展示了如何使用 Lua 脚本在 Redis 集群上实现分布式锁。
锁定脚本示例
-- Lua 脚本:尝试获取分布式锁
-- 参数:
-- KEYS[1] - 锁的键名
-- ARGV[1] - 锁的值(通常是唯一标识符,如客户端ID)
-- ARGV[2] - 锁的超时时间(以毫秒为单位)
if redis.call("EXISTS", KEYS[1]) == 0 then
-- 如果锁不存在,则设置锁并返回成功
redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2])
return 1
elseif redis.call("GET", KEYS[1]) == ARGV[1] then
-- 如果锁已存在且是当前客户端持有的,可以重新设置超时时间
redis.call("PEXPIRE", KEYS[1], ARGV[2])
return 1
else
-- 如果锁已存在且不是当前客户端持有,则返回失败
return 0
end
解锁脚本示例
-- Lua 脚本:尝试释放分布式锁
-- 参数:
-- KEYS[1] - 锁的键名
-- ARGV[1] - 锁的值(客户端唯一标识符)
if redis.call("GET", KEYS[1]) == ARGV[1] then
-- 如果锁存在且是当前客户端持有的,则删除锁
return redis.call("DEL", KEYS[1])
else
-- 如果锁不存在或不是当前客户端持有,则返回失败
return 0
end
使用哈希标签在 Redis 集群中实现 Lua 分布式锁
为了确保锁的键能够位于同一个分片,可以使用哈希标签。示例如下:
@Service
public class MyService {
@Autowired
private RedissonClient redisson;
public void doSomethingWithLock() {
// 锁的键,使用哈希标签使其位于同一分片
String lockKey = "{myLock}:lock";
String clientId = "unique-client-id";
long lockTimeout = 5000; // 5秒超时
// 加锁的 Lua 脚本
String lockScript = "if redis.call('EXISTS', KEYS[1]) == 0 then " +
"redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]); return 1; " +
"elseif redis.call('GET', KEYS[1]) == ARGV[1] then " +
"redis.call('PEXPIRE', KEYS[1], ARGV[2]); return 1; else return 0; end";
// 尝试执行 Lua 脚本加锁
Long result = redisson.getScript().eval(
RScript.Mode.READ_WRITE,
lockScript,
RScript.ReturnType.INTEGER,
Collections.singletonList(lockKey),
clientId,
String.valueOf(lockTimeout)
);
if (result == 1) {
System.out.println("Lock acquired!");
// 执行业务逻辑
// ...
// 释放锁的 Lua 脚本
String unlockScript = "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
"return redis.call('DEL', KEYS[1]); else return 0; end";
// 尝试执行 Lua 脚本解锁
Long unlockResult = redisson.getScript().eval(
RScript.Mode.READ_WRITE,
unlockScript,
RScript.ReturnType.INTEGER,
Collections.singletonList(lockKey),
clientId
);
if (unlockResult == 1) {
System.out.println("Lock released!");
} else {
System.out.println("Failed to release lock!");
}
} else {
System.out.println("Failed to acquire lock!");
}
}
}
如何通过 Lua 脚本实现可重入分布式锁
- 唯一客户端标识符:每个持有锁的客户端都有一个唯一的标识符(例如客户端 ID)。
- 在这里可重入性的参考维度是客户端,而不是线程。
- 锁的计数器:使用一个计数器来跟踪当前客户端获取锁的次数。每次相同客户端获取锁时计数器递增,释放锁时计数器递减,只有计数器降到 0 时,锁才会真正释放。
- 锁的结构:锁的数据可以存储在 Redis 中,比如使用键值对来记录客户端 ID 和计数器。
实现可重入锁的 Lua 脚本示例
加锁脚本
-- Lua 脚本:尝试获取可重入锁
-- 参数:
-- KEYS[1] - 锁的键名
-- ARGV[1] - 客户端唯一标识符
-- ARGV[2] - 锁的超时时间(以毫秒为单位)
local lockOwner = redis.call('HGET', KEYS[1], 'owner')
if lockOwner == false then
-- 如果锁不存在,则初始化锁的持有者和计数器
redis.call('HSET', KEYS[1], 'owner', ARGV[1])
redis.call('HSET', KEYS[1], 'count', 1)
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
elseif lockOwner == ARGV[1] then
-- 如果锁已存在且是当前客户端持有的,则增加计数器
local count = redis.call('HINCRBY', KEYS[1], 'count', 1)
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return count
else
-- 如果锁已存在且不是当前客户端持有的,则返回失败
return 0
end
解锁脚本
-- Lua 脚本:尝试释放可重入锁
-- 参数:
-- KEYS[1] - 锁的键名
-- ARGV[1] - 客户端唯一标识符
local lockOwner = redis.call('HGET', KEYS[1], 'owner')
if lockOwner == ARGV[1] then
-- 如果锁是当前客户端持有的,则递减计数器
local count = redis.call('HINCRBY', KEYS[1], 'count', -1)
if count == 0 then
-- 计数器归零时删除锁
redis.call('DEL', KEYS[1])
else
-- 更新锁的过期时间
redis.call('PEXPIRE', KEYS[1], 5000)
end
return count
else
-- 如果锁不是当前客户端持有的,则返回失败
return 0
end
示例代码
@Service
public class MyService {
@Autowired
private RedissonClient redisson;
public void doSomethingWithLock() {
// 锁的键,使用哈希标签使其位于同一分片
String lockKey = "{myReentrantLock}:lock";
String clientId = "unique-client-id";
long lockTimeout = 5000; // 5秒超时
// 加锁的 Lua 脚本
String lockScript = "local lockOwner = redis.call('HGET', KEYS[1], 'owner') " +
"if lockOwner == false then " +
"redis.call('HSET', KEYS[1], 'owner', ARGV[1]) " +
"redis.call('HSET', KEYS[1], 'count', 1) " +
"redis.call('PEXPIRE', KEYS[1], ARGV[2]) " +
"return 1 " +
"elseif lockOwner == ARGV[1] then " +
"local count = redis.call('HINCRBY', KEYS[1], 'count', 1) " +
"redis.call('PEXPIRE', KEYS[1], ARGV[2]) " +
"return count " +
"else " +
"return 0 end";
// 尝试执行 Lua 脚本加锁
Long result = redisson.getScript().eval(
RScript.Mode.READ_WRITE,
lockScript,
RScript.ReturnType.INTEGER,
Collections.singletonList(lockKey),
clientId,
String.valueOf(lockTimeout)
);
if (result != 0) {
System.out.println("Lock acquired with count: " + result);
// 执行业务逻辑
// ...
// 释放锁的 Lua 脚本
String unlockScript = "local lockOwner = redis.call('HGET', KEYS[1], 'owner') " +
"if lockOwner == ARGV[1] then " +
"local count = redis.call('HINCRBY', KEYS[1], 'count', -1) " +
"if count == 0 then " +
"redis.call('DEL', KEYS[1]) " +
"else " +
"redis.call('PEXPIRE', KEYS[1], 5000) end " +
"return count " +
"else return 0 end";
// 执行解锁 Lua 脚本
Long unlockResult = redisson.getScript().eval(
RScript.Mode.READ_WRITE,
unlockScript,
RScript.ReturnType.INTEGER,
Collections.singletonList(lockKey),
clientId
);
if (unlockResult == 0) {
System.out.println("Failed to release lock!");
} else {
System.out.println("Lock released with remaining count: " + unlockResult);
}
} else {
System.out.println("Failed to acquire lock!");
}
}
}
3. 可重入分布式锁的参考维度
Redis (单例或者集群)添加可重入性分布式锁时,参考维度通常是客户端,而不是线程。这是因为在 Redis 的上下文中,分布式锁是针对客户端的,而不是针对单个线程。原因如下:
1. Redis 的工作模型
- 单线程模型:Redis 是单线程的,所有的命令在一个线程中处理。因此,Redis 处理的所有操作都是在一个事件循环中执行的。即使在多线程的应用程序中,所有对 Redis 的请求实际上是通过同一个客户端连接发送的。
- 客户端标识:在分布式环境中,锁的持有者通常是一个客户端,而不是单个线程。一个客户端可以有多个线程在操作,但 Redis 只关心哪个客户端持有锁。
2. 锁的持有者
- 锁的设计:在实现可重入锁时,需要一个机制来跟踪锁的持有者。这个持有者应该是客户端的标识符(例如 UUID 或其他唯一标识符),以便确保同一个客户端可以多次获取锁,而不会出现死锁。
- 多线程和多进程:如果一个客户端的多个线程或进程尝试获取同一个锁,可以通过使用相同的客户端标识符来实现可重入性。这种设计方式允许同一客户端的不同线程安全地获取和释放锁。
2. redis 和 mysql 中的数据一致性
- 弱一致:
- 异步消息队列
- 更新数据库后,通过消息队列将更新操作(如新增、修改、删除)推送到 Redis 中。
- 消费者读取消息并更新缓存。
- 原理:数据库更新操作之后,发送一个异步消息到消息队列,通知缓存进行更新。消息队列可以通过重试机制来保证缓存的最终一致性。
- 缓存延时双删策略
- 在更新数据库之前,先删除缓存。
- 更新数据库。
- 以延时的方式(如 1 秒或更短)再删除一次缓存。
- 原理: 延时双删策略通过两次删除缓存来防止写操作过程中可能出现的脏数据。如果在第一次删除缓存和数据库更新操作之间,发生了并发的读请求,可能导致缓存重新被写入旧数据,因此需要延时再删除一次缓存。
- 异步消息队列
- 强一致
- 基于 Seata 的两阶段提交
1. 配置 Seata
- Seata 的配置需要两个关键部分:Seata Server 和 Seata Client。
- Seata Server 是一个独立的微服务,它负责协调分布式事务的管理。作为一个事务协调器,Seata Server 处理多个服务之间的事务逻辑,包括事务的开始、提交和回滚。它通常需要单独部署,并与使用它的微服务应用进行通信,以实现事务的协调和一致性。在生产环境中可以将其部署多个实例,以此来保证高可用性和稳定性。
- Seata Client 不是一个独立的微服务,而是嵌入到需要使用分布式事务的应用中。每个微服务应用在其代码中集成 Seata Client,负责向 Seata Server 注册和发送事务相关的请求。Seata Client 通过拦截应用的数据库操作(如 SQL 语句)和其他资源的操作,实现分布式事务的管理。因此,它与应用程序的生命周期紧密相关,并与 Seata Server 进行交互以完成事务的协调。
2.Seata Server 配置
- Seata Server中有两个配置文件:file.conf 和 registry.conf ,确保服务能够正常注册和运行。
- registry.conf 文件用于配置 Seata Server 的注册中心。
registry {
type = "nacos" # 使用 Nacos 作为注册中心
nacos {
serverAddr = "127.0.0.1:8848" # Nacos 服务器地址
namespace = "public" # Nacos 命名空间
cluster = "default" # Nacos 集群名
serviceName = "seata-server" # Seata Server 注册的服务名
}
}
- file.conf 文件用于配置 Seata Server 的存储方式。下面是一个使用 MySQL 数据库和 Redis 的示例配置:
store {
mode = "db" # 数据存储模式,这里选择数据库模式
db {
datasource {
driverClassName = "com.mysql.cj.jdbc.Driver" # MySQL 驱动
url = "jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&characterEncoding=utf8" # 数据库连接 URL
user = "root" # 数据库用户名
password = "password" # 数据库密码
}
}
# Redis 配置部分(示例)
redis {
mode = "single" # Redis 单实例模式
single {
address = "127.0.0.1:6379" # Redis 服务器地址
password = "" # Redis 密码(如有)
database = 0 # 使用的数据库索引
}
}
}
3. Seata Client 配置
Seata Client 的配置是在 application.yml 文件中的。
spring:
application:
name: spring-boot-seata-client # 应用程序名称
cloud:
alibaba:
seata:
tx-service-group: my_tx_group # 分布式事务服务组名称
seata:
enabled: true # 启用 Seata
application-id: spring-boot-seata-client # Seata Client 的应用 ID
tx-service-group: my_tx_group # 事务服务组,需与 Seata Server 配置一致
enable-auto-data-source-proxy: true # 启用自动数据源代理
client:
rm:
report-success-enable: true # 是否启用事务成功报告
transport:
type: "TCP" # 网络传输协议,通常为 TCP
group: "default" # 服务组名
thread-count: 8 # 线程数量
# 其他传输配置
4. Redis 和 MySQL 操作示例
创建一个服务类 OrderService 来演示分布式事务:
@Service
public class OrderService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
/**
* 使用 Seata 的 @GlobalTransactional 注解管理分布式事务
*/
@GlobalTransactional
@Transactional(rollbackFor = Exception.class)
public void createOrderAndUpdateStock(String orderId, String productId, int quantity) {
// Step 1: 更新 MySQL 中的订单
String insertOrderQuery = "INSERT INTO orders (order_id, product_id, quantity) VALUES (?, ?, ?)";
jdbcTemplate.update(insertOrderQuery, orderId, productId, quantity);
// Step 2: 更新 Redis 中的库存
String redisStockKey = "product_stock_" + productId;
Integer stock = redisTemplate.opsForValue().get(redisStockKey);
if (stock == null || stock < quantity) {
throw new RuntimeException("库存不足");
}
redisTemplate.opsForValue().set(redisStockKey, stock - quantity);
}
}
3. 数据类型
Redis 有五种主要数据类型:字符串(String)、哈希(Hash)、列表(List)、集合(Set)和有序集合(Sorted Set),其中:
- 字符串:用于用户会话管理、页面缓存和统计信息(如访问计数)。
@Autowired
private StringRedisTemplate stringRedisTemplate;
public void setSession() {
stringRedisTemplate.opsForValue().set("user:1000:session", "abc123");
}
- 哈希:适合存储用户资料、商品信息等复杂对象,便于修改和访问。
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void setUser() {
Map<String, Object> user = new HashMap<>();
user.put("name", "John");
user.put("age", 30);
user.put("email", "john@example.com");
redisTemplate.opsForHash().putAll("user:1000", user);
}
- 列表:用于任务队列、消息队列或时间序列数据(如聊天记录)。
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void addTask() {
redisTemplate.opsForList().leftPush("task_queue", "task1");
}
- 集合:适合实现社交网络中的好友关系、标签系统,处理去重。
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void addFriend() {
redisTemplate.opsForSet().add("user:1000:friends", "user:1001");
}
- 有序集合:用于排行榜、延迟队列或事件时间戳管理,能根据分数排序。
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void addScore() {
redisTemplate.opsForZSet().add("game_scores", "player1", 100);
}
4. 穿透、击穿、雪崩
- 缓存穿透:
- 定义:请求一个不存在的数据,导致请求直接到达数据库。
- 解决方案:对空值缓存,使用布隆过滤器,拦截不存在的请求,避免数据库查询。
- 缓存击穿:
- 定义:在高并发下,某个数据的缓存失效,导致大量请求同时访问数据库。
- 解决方案:对热点数据加锁(分布式锁),或使用互斥锁(分布式锁),确保在缓存更新时只允许一个请求访问数据库。
- 缓存雪崩:
- 定义:大量缓存同时过期,导致瞬间大量请求涌向数据库。
- 解决方案:设置不同的过期时间,随机化缓存的过期时间,避免集中失效。
5. 为什么redis这么快
- 内存存储:Redis 将数据存储在内存中,相比传统的磁盘存储,内存访问速度更快,响应时间低。
- 单线程模型:Redis 使用单线程处理请求,避免了多线程中的上下文切换和锁竞争,简化了并发处理。
- 数据结构优化:Redis 提供了多种高效的数据结构(如字符串、哈希、列表、集合和有序集合),并对这些数据结构进行了优化,能够高效执行各种操作。
- 高效的网络协议:Redis 使用自定义的协议(RESP),减少了网络通信的开销,提高了数据传输速度。
- 持久化选项:尽管主要运行在内存中,Redis 还提供了快照和追加日志等持久化方式,确保数据的安全性而不显著影响性能。
- 支持异步操作:Redis 允许客户端使用异步操作,进一步提高了并发性能。
6. 并发 key 问题
尽管 redis 是单线程的,但是它在高并发情况下依然会出现并发问题的,单线程特性确实使得在执行请求时每个操作是顺序进行的,但这并不能完全保证在高并发环境下客户端 A 和客户端 B 对同一键的操作会严格按照顺序执行。
顺序执行
-
Redis 会依次处理客户端的请求。例如,如果客户端 A 的请求先到达 Redis,然后是客户端 B 的请求,Redis 会先执行客户端 A 的操作。
-
然而,网络延迟和请求的到达顺序会影响执行的结果。在高并发情况下,如果客户端 B 的请求在客户端 A 的读操作之前到达 Redis,但由于客户端 A 的请求在处理时没有被打断,Redis 仍然会按照收到请求的顺序执行。
并发问题
- 并发读-写操作:如果客户端 A 和 B 同时发送请求,且客户端 A 依赖于读取的值进行后续写入,可能会出现问题。比如:
- 客户端 A 读取值为 10。
- 客户端 B 也读取值为 10。
- 客户端 A 修改并写入 11。
- 客户端 B 也基于读取的 10 修改并写入 15。
在这种情况下,最终的值会是 15,而不是预期的 16。
解决方案
- 乐观锁:使用 WATCH 命令监视特定键,在事务 (MULTI 和 EXEC) 中执行时,如果监视的键被修改,则事务会失败,可以重试。
- 悲观锁:使用 SETNX 命令实现分布式锁,确保在处理关键数据时只允许一个线程或进程访问。
- 原子操作:Redis 的一些命令(如 INCR、DECR 等)是原子性的,可以安全地在并发环境中使用。
- 使用 Lua 脚本:可以将多个命令打包在一个 Lua 脚本中执行,保证脚本中的操作是原子性的,避免中间状态被其他请求影响。
- 请求合并:对于频繁访问同一个 key 的请求,可以考虑请求合并,减少对数据库的压力。
不要求顺序的话,使用分布式锁,抢到后进行更新;要求顺序的话,设定时间戳进行更新;
7. 事务
Redis 中的事务可以保证原子性,但在特定的上下文中。
原子性保证
- MULTI 和 EXEC:
- 在事务中,客户端通过 MULTI 命令开始一个事务,然后可以排队多个命令。当执行 EXEC 命令时,所有排队的命令会作为一个整体被执行,确保它们在执行期间不被其他命令干扰。这种方式保证了排队命令的原子性。
- 不支持回滚:
- Redis 事务中的命令是原子的,但一旦 EXEC 被执行,所有命令都会执行,即使其中某个命令失败。Redis 不支持回滚操作,因此必须谨慎设计事务逻辑。
注意事项
- WATCH 命令:
- 如果使用 WATCH 命令监视某些键,在执行 EXEC 时,如果监视的键在事务开始后被其他客户端修改,事务将不会执行。这种机制可以避免在并发场景下出现数据不一致的情况。
- 局限性:
- 事务中的所有命令都是依次执行的,但如果某个命令的执行时间较长,其他客户端的请求仍然会被阻塞,可能会导致性能瓶颈。
8. 过期策略
- 定期删除(Passive Expiration):
- 当你访问某个键时,Redis 会检查该键是否已过期。如果过期,则返回 nil。
- 这种方法适用于访问频率较高的键。
- 惰性删除(Active Expiration):
- Redis 定期遍历一部分键,删除已经过期的键。
- 默认情况下,Redis 每隔 100 毫秒会随机检查一定数量的键,删除过期的键。
- 设置过期时间:
- 可以通过 EXPIRE 或 SETEX 命令设置键的过期时间。例如:EXPIRE mykey 60(60 秒后过期)。
- 惰性过期与定期过期结合:
- Redis 同时采用惰性和定期过期策略,确保高效利用内存,并减少对过期键的访问。
- 最大内存策略:
- 当 Redis 达到最大内存限制时,可以根据不同的策略(如 LRU、LFU 或随机)选择删除键,释放内存。
9. 为什么redis集群的最大槽数是16384个
Redis 集群的最大槽数为 16384 个,这是因为 Redis 使用了哈希槽(hash slots)机制来分配键到不同的节点。具体原因包括:
- 均匀分配:16384 个哈希槽能够有效地将数据均匀分配到多个节点,减少负载不均的问题。
- 性能考虑:每个键通过哈希函数映射到一个槽,这样可以快速确定数据所在的节点。16384 的槽数使得哈希冲突的概率较低,从而提高了查找性能。
- 兼容性:选择 16384 是为了兼容 Redis Cluster 的设计,使得每个节点能够在一定的范围内处理槽的分配和重分布。
- 易于扩展:在集群中添加或移除节点时,可以通过简单地重新分配槽来实现数据的迁移和负载均衡,16384 个槽使得这一过程相对简单和高效。
10. 渐进式扩容
Redis 中的渐进式扩容是一种用于在集群中平滑添加新节点并重新分配槽位的方法。具有以下特点:
-
逐步迁移:渐进式扩容允许在不影响集群整体性能的情况下,逐步迁移槽位和数据到新的节点上。
-
降低压力:在扩容过程中,Redis 会根据当前的负载逐步迁移一部分槽位,而不是一次性迁移所有槽位,减少对网络和数据库的压力。
-
在线扩容:用户可以在不停止服务的情况下扩展集群,确保高可用性。
-
操作步骤
- 添加新节点:首先,将新的 Redis 节点添加到集群中。
- 选择迁移槽位:使用 CLUSTER ADDSLOTS 命令,将新节点添加到需要的槽位中。
- 逐步迁移数据:通过 CLUSTER SETSLOT 命令将槽位迁移到新节点。可以使用 CLUSTER RELOCATE 命令逐步迁移每个槽位的数据。
- 监控和调整:在迁移过程中监控集群状态,确保系统负载均衡,并根据需要调整迁移速度。
- 完成迁移:一旦所有槽位成功迁移,新节点将成为集群的一部分,可以处理相应的数据请求。
-
优势
- 高可用性:在扩容时无需停机,保证了服务的连续性。
- 负载均衡:可以根据当前负载平衡地添加节点,优化资源利用率。
11. 主从复制
Redis 主从复制是一种数据复制机制,主要用于实现高可用性和数据备份。
- 原理
- 主节点(Master):负责处理所有的写入请求,并将数据更新发送给从节点。
- 从节点(Slave):通过复制主节点的数据来保持与主节点的数据一致性,通常用于读取请求以分担主节点的负载。
- 复制过程:
- 全量复制:从节点首次连接主节点时,会进行全量数据同步,主节点将所有数据传输给从节点。
- 增量复制:全量复制完成后,主节点会将后续的写操作以增量的形式发送给从节点,保持数据同步。
- 特点
- 高可用性:主从复制提供了数据冗余,提高了系统的可用性。在主节点故障时,可以将从节点提升为新的主节点。
- 负载均衡:通过将读取请求分散到多个从节点,可以减少主节点的压力,提高系统性能。
- 异步复制:默认情况下,主从复制是异步的,这意味着从节点可能会滞后于主节点。但可以配置为同步复制以确保数据一致性。
配置示例
在 Redis 配置文件中,可以通过以下方式配置主从关系:
- 在从节点的配置文件中添加:
replicaof <master_ip> <master_port>
这样,从节点将连接到指定的主节点并开始同步数据。
12. 持久化
Redis 提供了两种主要的持久化机制,以确保数据在重启后不会丢失:
-
RDB(Redis 数据库备份)
- 原理:通过定期快照将内存中的数据保存到磁盘上,生成一个 RDB 文件(通常为 dump.rdb)。
- 配置:可以通过 SAVE 命令手动触发,或通过 SAVE 和 CONFIG SET 指定时间间隔进行自动保存。
- 优点:
- 启动速度快,适合大数据量的快速恢复。
- 适合备份和迁移。
- 缺点:
- 数据在保存间隔内可能丢失(即最后一次保存后到崩溃之间的数据)。
-
AOF(追加文件)
- 原理:将每次写操作记录到一个日志文件中(通常为 appendonly.aof),在重启时通过重放这些操作恢复数据。
- 配置:可以通过设置 appendfsync 选项指定写入策略,如每次操作后、每秒或不进行同步。
- 优点:
- 更高的数据安全性,因所有写操作都被记录。
- 可以在恢复时更细粒度地控制数据。
- 缺点:
- 启动速度较慢,尤其是在大日志文件的情况下。
- 可能占用更多的磁盘空间。
-
持久化策略
- RDB 是默认方式持久化,如果同时开启则选择AOF。可以同时启用 RDB 和 AOF,以结合两者的优点。Redis 允许根据需要配置这两种持久化机制,从而实现数据的安全性和性能的平衡。
13. 为什么redis是单线程的
Redis 之所以采用单线程模型,主要有以下几个原因:
- 避免上下文切换:单线程模型消除了多线程环境中的上下文切换开销,简化了代码逻辑,提高了性能。
- 简化设计:单线程处理请求,避免了复杂的并发控制和锁机制,减少了潜在的竞争条件和死锁问题。
- 高效的 I/O 操作:Redis 使用事件驱动的 I/O 模型(例如,epoll),可以在单线程中高效处理大量并发请求,同时保持高性能。
- 数据一致性:由于只有一个线程在操作数据,确保了数据的一致性,避免了并发修改时可能出现的脏读和竞态条件。
- 适合内存数据库:Redis 主要作为内存数据库,内存访问速度极快,相较于磁盘 I/O 更容易达到高并发性能。
尽管 Redis 是单线程的,但它通过高效的 I/O 和数据结构设计,能够在高并发场景下仍然表现出色。对于需要进行复杂计算或大数据处理的场景,可以在一台服务器上启动多个节点或者采用分片集群的方式。想要使用服务的多核CPU,可以通过使用多个 Redis 实例或其他外部服务来处理。
14. Redis Cluster
Redis 集群是 Redis 提供的一种分布式实现,用于水平扩展数据存储能力。通过 Redis 集群,可以将数据分片存储在多个 Redis 节点上,同时提供高可用性和故障转移功能。
1. Redis 集群核心概念
- 分片(Sharding):
- Redis 集群将数据划分为 16384 个插槽(slots),每个插槽代表一部分数据。
- 每个 Redis 节点负责一部分插槽。数据键通过哈希函数映射到特定的插槽。
- 主从复制(Replication):
- 每个分片可以有一个主节点和多个从节点。
- 主节点负责处理写请求,从节点作为备份,提供读取和故障切换。
- 高可用性:
- 如果主节点故障,从节点会自动提升为主节点,确保服务可用性。
- 这只限于每个分片的主从节点范围内,如果主从节点都挂了,那么这个分片上的数据是访问不到的。
- 一致性模型:
- Redis 集群采用 最终一致性,在网络分区的情况下使用部分可用性,但不会丢失数据。
2. Redis 集群配置
下面是 Redis 集群的基本配置步骤,包括集群初始化和启动。
配置 Redis 实例
假设你在同一台机器上运行 6 个 Redis 实例(3 个主节点 + 3 个从节点),分别监听不同的端口(例如 7000 到 7005)。
- 创建配置文件: 为每个 Redis 实例创建一个单独的配置文件。例如,redis-7000.conf,内容如下:
port 7000
cluster-enabled yes
cluster-config-file nodes-7000.conf
cluster-node-timeout 5000
appendonly yes
dbfilename dump-7000.rdb
dir /path/to/redis/data/7000
- 启动 Redis 实例:
redis-server /path/to/redis/redis-7000.conf
redis-server /path/to/redis/redis-7001.conf
redis-server /path/to/redis/redis-7002.conf
redis-server /path/to/redis/redis-7003.conf
redis-server /path/to/redis/redis-7004.conf
redis-server /path/to/redis/redis-7005.conf
- 创建 Redis 集群: 使用 redis-cli 工具创建集群,假设 7000 到 7002 作为主节点,7003 到 7005 作为从节点:
redis-cli --cluster create 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 --cluster-replicas 1
其中 --cluster-replicas 1 表示每个主节点有一个从节点。
- 检查集群状态:
redis-cli -c -p 7000 cluster info
3. Spring Boot 项目集成 Redis 集群
在 Spring Boot 项目中集成 Redis 集群,可以使用 Spring Data Redis 来进行配置。
依赖配置(pom.xml)
确保你的项目中包含 Spring Boot 和 Redis 的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
使用 Redis Template 进行数据操作
在 Spring Boot 中,可以使用 RedisTemplate 来操作 Redis 数据。通过 Lettuce 客户端来实现 Redis 集群中的读写分离,可以利用 Lettuce 对读策略(ReadFrom)的支持,将读操作分配到从节点,写操作保持在主节点。这适用于 Redis 的主从复制架构。
Redis 配置类
创建一个配置类,用于设置 RedisTemplate。
- 使用 LettuceConnectionFactory 配置 Redis 连接。
- 设置 ReadFrom 策略,选择将读请求发送到从节点。
- 使用 RedisTemplate 执行读写操作,Lettuce 会根据配置的 ReadFrom 策略自动选择节点。
具体实现步骤
- 配置 LettuceConnectionFactory,通过自定义 LettuceConnectionFactory 配置 Redis 集群连接和读策略。
示例代码
@Configuration
public class RedisConfig {
// 配置 LettuceConnectionFactory 以支持读写分离
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
// 创建 Redis 集群配置
RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration();
// 添加集群节点,替换为你的 Redis 集群地址和端口
clusterConfig.clusterNode("127.0.0.1", 7000);
clusterConfig.clusterNode("127.0.0.1", 7001);
clusterConfig.clusterNode("127.0.0.1", 7002);
// 设置 Lettuce 客户端配置
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
// 设置读取策略,优先从从节点读取
.readFrom(ReadFrom.REPLICA_PREFERRED)
.build();
// 返回 LettuceConnectionFactory,配置读写分离
return new LettuceConnectionFactory(clusterConfig, clientConfig);
}
// 配置 RedisTemplate,用于与 Redis 交互
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
// 设置序列化器
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
return template;
}
}
使用 RedisTemplate 进行读写操作
RedisTemplate 会根据配置的 LettuceConnectionFactory,自动进行读写分离。写操作会默认发往主节点,而读操作则根据 ReadFrom 策略发往从节点。
示例使用
@Service
public class LearningRecordService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 写操作(写入到主节点)
public void saveLearningRecord(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}
// 读操作(从从节点读取)
public String getLearningRecord(String key) {
return (String) redisTemplate.opsForValue().get(key);
}
}
配置文件示例(application.yml)
可以将 Redis 集群的配置部分放在 application.yml 文件中,使配置更加灵活。
spring:
redis:
cluster:
nodes:
- 127.0.0.1:7000
- 127.0.0.1:7001
- 127.0.0.1:7002
timeout: 5000
lettuce:
read-from: REPLICA_PREFERRED # 优先从从节点读取
pool:
max-active: 8
max-idle: 8
min-idle: 0
- read-from 配置项说明:
- MASTER: 所有操作都在主节点执行(默认)。
- MASTER_PREFERRED:优先从主节点读取数据,如果主节点不可用则从从节点读取。
- REPLICA: 所有读取操作都从从节点执行。
- REPLICA_PREFERRED: 优先从从节点读取,如果没有可用从节点则读取主节点。
- ANY: 读取请求可以从任意节点执行(包括主节点和从节点)。
在配置类中读取配置文件
@Configuration
public class RedisConfig {
@Value("${spring.redis.cluster.nodes}")
private List<String> clusterNodes;
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
// 使用配置文件中的 Redis 集群节点信息
RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration(clusterNodes);
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
// 使用配置文件中的读取策略
.readFrom(ReadFrom.REPLICA_PREFERRED)
.build();
return new LettuceConnectionFactory(clusterConfig, clientConfig);
}
// 配置 RedisTemplate,用于与 Redis 交互
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
// 设置序列化器
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
return template;
}
}
4. 哈希标签
在 Redis 集群中,为了在执行 Lua 脚本时能够确保操作的多个键位于同一哈希槽中,Redis 引入了 哈希标签(Hash Tag) 的概念。
1. 什么是哈希标签
- 哈希标签 是一种机制,用来告诉 Redis 集群哪些键应该被视为一个整体,映射到同一个哈希槽(Hash Slot)中。
- 哈希标签是通过将键名的一部分用大括号 {} 包围来实现的。Redis 只会对 {} 内部的内容进行哈希计算,以决定该键的哈希槽位置。
2. 使用哈希标签的语法
- 如果你有两个键 key1 和 key2,通常它们会映射到不同的哈希槽中。然而,如果使用哈希标签,例如 user:{123} 和 order:{123},那么 123 部分会被用于哈希计算,使这两个键落在同一个哈希槽中。
- 例如:
- user:{123} 和 order:{123} 将会落在同一个哈希槽。
- user:123 和 order:123 将可能落在不同的哈希槽中。
3. 为什么 Lua 脚本需要哈希标签
- 在 Redis 集群环境中,当执行 Lua 脚本时,所有涉及的键必须位于同一个哈希槽中,否则会报错,因为 Redis 集群无法在不同的节点之间自动协调 Lua 脚本。
- 使用哈希标签可以确保多个相关键位于同一哈希槽,这样在执行 Lua 脚本时可以正常运行。
5. 数据倾斜
在 Redis 集群架构中,如果发生数据倾斜(即某些节点存储的数据明显多于其他节点),这可能会导致部分节点的负载过高,影响性能和稳定性。为了解决数据倾斜问题,可以采取以下措施:
1. 调整哈希槽的分配
Redis 集群中使用哈希槽(hash slots)来分布数据,每个节点管理一定数量的哈希槽。如果某些节点的哈希槽数量明显多于其他节点,可能会导致数据倾斜。可以通过以下步骤调整哈希槽的分配:
- 可以通过 redis-cli --cluster rebalance 命令重新分配哈希槽,让数据更均匀地分布在各个节点上。
# <node-address>:<port> 表示集群中任一主节点的地址和端口,用于连接到 Redis 集群。Redis 会通过这个节点获取集群的状态信息。
# 最终重新平衡哈希槽的分配是针对整个集群的。
redis-cli --cluster rebalance --cluster-use-empty-masters <node-address>:<port>
2. 添加新的节点
增加新的节点可以减轻现有节点的负担,并改善数据分布:
- 使用 redis-cli --cluster add-node 命令将新节点加入到集群中。
- 加入新节点后,使用 redis-cli --cluster rebalance 来重新平衡数据和哈希槽的分布,使得新节点参与数据存储。
3. 迁移数据
可以手动或自动将数据从高负载节点迁移到其他节点:
- 使用 redis-cli --cluster reshard 命令将部分哈希槽从高负载节点迁移到其他节点。
- 迁移数据时,需要指定源节点、目标节点以及要迁移的哈希槽数量。
redis-cli --cluster reshard <node-address>:<port>
- 输入目标哈希槽数量和目标节点,Redis 会自动完成迁移。
4. 选择合适的分片键
如果数据倾斜是由于分片键选择不合理造成的,可以考虑重新选择分片键:
- 使用均匀分布的数据字段作为分片键,例如随机数或 UUID,而不是用户 ID 等容易造成数据集中化的字段。
- 使用哈希策略来确保分片键的散列值在集群中均匀分布。
5. 监控和优化
通过监控工具持续关注 Redis 集群的运行状态,及早发现并解决数据倾斜问题:
- 使用 redis-cli --cluster info 或其他 Redis 监控工具来查看每个节点的哈希槽和内存使用情况。
- 借助 INFO 命令获取每个节点的状态信息,了解内存和负载分布。
- 定期评估数据分布,必要时进行数据迁移或重新分配哈希槽。
6. 考虑预分片
在创建集群时,可以考虑进行预分片,提前准备好更多节点,避免将来由于数据增长导致的数据倾斜:
- 预分片是在创建集群之前,根据预计的数据量和使用情况,提前将这些哈希槽分配给不同的节点。
- 这样可以确保在数据量增加时,集群的负载是均匀分布的,从而避免单个节点因为数据集中而过载。
- 在数据量还不大的时候,尽量部署足够多的节点,避免后期数据迁移。
- 规划好哈希槽的分布,以便在集群扩展时更容易管理。
在创建 Redis 集群时,哈希槽的分配通常是自动进行的,但也可以手动分配哈希槽给每个主节点。
首先,使用以下命令创建一个 Redis 集群,并为每个主节点分配哈希槽。
redis-cli --cluster create \
192.168.1.1:6379 \
192.168.1.2:6379 \
192.168.1.3:6379 \
192.168.1.4:6379 \
--cluster-replicas 1 \
--cluster-slots 16384
此命令将创建一个包含 4 个主节点的 Redis 集群,并为每个主节点指定 16384 个哈希槽。
手动分配哈希槽
# 为主节点 1 分配哈希槽 0 - 4095
redis-cli --cluster addslots 192.168.1.1:6379 0 4095
# 为主节点 2 分配哈希槽 4096 - 8191
redis-cli --cluster addslots 192.168.1.2:6379 4096 8191
# 为主节点 3 分配哈希槽 8192 - 12287
redis-cli --cluster addslots 192.168.1.3:6379 8192 12287
# 为主节点 4 分配哈希槽 12288 - 16383
redis-cli --cluster addslots 192.168.1.4:6379 12288 16383