全局id生成器
这里是在完成分布式锁之前我们要先思考怎么才能实现在分布式环境下有着唯一的 id 锁标识。
其要满足的以下的特点:
- 唯一性
- 高可用性
- 高性能
- 递增性
- 安全性
我们常见并且能容易想到的生成策略有:
- 采用UUID方法
- 使用Redis自增
- snowflake算法
- 采用数据库自增
Redis自增id策略如下:
- 每天一个key,用来统计每天的订单量
为了提高系统的安全性,我们一般不选择Redis的自增,以防被直接猜测出id,从而造成信息的泄露。
可以采用如下策略:用一个时间戳加上UUID来拼接成一个id
其组成部分为:
- 符号位:1bit,一直为零(代表正数)
- 时间戳:31bit,以秒为单位(可以使用69年)
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同的ID
其中代码生成部分如下:
@Component
public class RedisIdWorker {
/**
* 开始时间戳、这里的是2022,6,12,0,0,0
*/
private static final long BEGIN_TIMESTAMP = 1654992000L;
/**
* 序列号位移位数
*/
private static final int COUNT_BITS = 32;
@Autowired
StringRedisTemplate stringRedisTemplate;
public long nextId(String keyPrefix){
//1. 生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
//2. 生成序列号
//2.1 获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
//3. 拼接并返回
//日期位数左移32位后填充序列号
return timestamp << COUNT_BITS | count;
}
}
其中Redis中如下:
也可以根据天数查询当前的具体订单数量
分布式锁
分布式锁是需要满足在分布式系统和集群系统下多线程可见并且互斥的锁,其有如下特点:
- 互斥
- 高性能
- 多线程可见
- 高可用
- 安全性
其中分布式锁的核心是实现多线程之间互斥,而满足这一点的方式。常见的有如下三种:
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用mysql本身的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
其中在redis中实现分布式锁则要运用setnx的两个方法:
-
获取锁:
-
互斥:确保一个进程获取锁
-
setnx lock thread
expire lock 10
-
这里但是用这两了命令并不具有原子性,仍然可能在设锁后还没设置过期时间时宕机,因此可以采用set lock thread nx 10 ex来保证操作的原子性是引用
-
释放锁
-
在业务完成后手动释放
del key
-
超时释放:在获取锁的时候设置一个超时时间
-
如图,假如线程获取锁的同时的时候业务堵塞,锁由于超时被释放后、线程2获取到了锁开始执行业务的同时,线程1的堵塞消失,释放锁。由于此时是线程2的锁,导致线程2在执行的同时,线程3获取到了锁开始执行业务。那么锁的意义就不存在了,仍然会有线程安全问题。
如果想着增加过期时间来解决,其实只能降低这种问题出现的概率,并不能从根本上解决该问题。原因在于客户端在拿到锁之后,在操作共享资源时,遇到的场景是很复杂的,既然是预估的时间,也只能是大致的计算,不可能覆盖所有导致耗时变长的场景。
由于以上的超时释放导致的误删问题,我们想出对应的解决方案:
- 即在原有分布式锁的基础上改进,来放入当前线程的标识。在每次释放锁时先取获取锁中的线程标识与当前线程标识对照。一致则释放锁,否则不会释放。
由于业务一般采用分布式集群的操作,可能造成线程的 id 相同,因此采用UUID或者时间戳来保证对应id的一致性(即上述的全局id生成器)
代码如下:
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
//true是去除uuid的横线
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue().
setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
//获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断标识是否一致
if (threadId.equals(id)){
//释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
- 但是以上的操作在释放时没有原子性,如果线程1在判断标识一致后堵塞,导致锁过期释放后线程二开启了业务。然后线程1堵塞结束删除锁,仍会导致锁的误删,如下图:
- redis支持 lua 脚本来保证 redis 执行的原子性,其中lua的语法可以参考:菜鸟教程lua语法
其中对应的删除锁的lua脚本为:
-- 获取线程标识
local id = redis.call('get',KEY[1])
-- 比较线程标识与取出的标识是否一致
if(id == ARGV[1]) then
-- 释放锁
return redis.call('del',KEY[1])
end
return 0
调用lua脚本的代码如下
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
//静态代码块加载lua
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public void unlock() {
//调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
不足
虽然上面的基于 reids 中 setnx 实现了分布式锁,且具有原子性,但仍然在实际用途中有着缺陷。
- 不可重入(同一个线程无法多次获取同一把锁)
- 不可重试(获取锁只能尝试一次返回 false 后没有重试机制)
- 超时释放(虽然锁的超时释放可以避免死锁的产生,但是如果业务本身执行的耗时较长导致的锁释放,存在安全隐患)
- 主从一致性(如果redis提供了主从集群,主从同步存在延迟。当主机宕机时,如果从机没有同步主机中锁的数据,则会出现锁的失效问题)
Redisson
基于我们自主设计的分布式锁仍然存在诸多不足,我们可以选择市场上已经成熟的框架,诸如Redisson
- Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
- 官网地址:官网地址
- GitHub地址:GitHub地址
入门
-
引入依赖
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.13.6</version> </dependency
-
配置Redisson客户端
@Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient(){ //配置 Config config = new Config(); config.useSingleServer().setAddress("redis://43.138.40.82:6379").setPassword("021017"); //创建redissonclient对象 return Redisson.create(config); } }
-
使用redis分布式锁
@Resource private RedissonClient redissonClient; @Test void testRedisson() throws InterruptedException { // 获取锁(可重入),指定锁的名称 RLock lock = redissonClient.getLock("anyLock"); // 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位 boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS); // 判断释放获取成功 if(isLock){ try { System.out.println("执行业务"); }finally { // 释放锁 lock.unlock(); } } }
可重入锁实现
采用hash类型:key中存入锁的标识,然后在field中存入线程标识,用value表示取出的次数。在每次取出时先先验证跟field中存的线程是否一致。如果一致则将value值加1,否则返回false(底层都是由lua脚本实现)
- 而要删除时首先验证线程表示是不是自己的锁,如果是则将value中的数字减1。
- 再判断value的值是否为0,若为0则删除锁。
其中实现获取锁的部分为: - 进入Redisson,最后式调用lua脚本实现。
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断是否存在
if(redis.call('exists', key) == 0) then
-- 不存在, 获取锁
redis.call('hset', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
-- 锁已经存在,判断threadId是否是自己
if(redis.call('hexists', key, threadId) == 1) then
-- 不存在, 获取锁,重入次数+1
redis.call('hincrby', key, threadId, '1');
-- 设置有效期
redis.call('expire', key, releaseTime);
return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
其中删除部分为
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
-- 判断当前锁是否还是被自己持有
if (redis.call('HEXISTS', key, threadId) == 0) then
return nil; -- 如果已经不是自己,则直接返回
end;
-- 是自己的锁,则重入次数-1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判断是否重入次数是否已经为0
if (count > 0) then
-- 大于0说明不能释放锁,重置有效期然后返回
redis.call('EXPIRE', key, releaseTime);
return nil;
else -- 等于0说明可以释放锁,直接删除
redis.call('DEL', key);
return nil;
end;