文章目录
Redis set 命令详解
Redis 分布式锁官方文档
Redis 分布式锁面试
synchronized
,单机版oK。nginx
分布式微服务,单机锁不行。- 取消单机锁,上
Redis
分布式锁setnx
。 - 加了锁,出异常的话,可能无法释放锁,必须要在代码层面
finally
释放锁 - 宕机了,部署了微服务代码层面根本没有走到
finally
这块,没办法保证解锁,这个key
没有被删除,需要设置锁的过期时间。 - 为
Redis
的分布式锁key
,增加过期时间,此外,还必须要setnx+过期时间必须同一行的原子性操作。不然也会出现锁一直存在。 - 必须规定只能自己删除自己的锁,不能把别人的锁删除了,防止张冠李戴,1删2,2删3。
- 判断删除自己的锁的时候,也是需要原子性,否则也会出现删除别人的锁。
- 上面在单实例
Redis
情况下没任何问题,但在Redis
集群环境下,可能存在key
还没有从master
同步到slave
情况被读取。 - 直接上
RedLock
之Redisson
落地实现。
锁的特性
- 互斥: 任何时刻只能有一个客户端获取锁。
- 安全: 解铃还须系铃人,只能解锁自己持有的锁。
- 不死锁: 不能因为意外的发生,导致锁不能被正常释放。
什么是分布式锁
使用场景: 多个服务间保证同一时刻同一时间段内同一用户只能有一个请求访问共享资源(防止关键业务出现并发攻击)。
引出分布式锁:
Java 多线程情况下访问共享资源,锁是一种线程同步机制,锁可以限制某一时刻只有一个线程操作共享资源。
对于单进程应用而言,所有线程都在同一个 JVM 进程里运行,使用 Java 提供的锁机制可以对共享资源进行同步的作用。
如果在分布式环境下,一个应用程序的多个实例会分别运行在多个机器上的 JVM 进程中,这时多个线程也会分别在多个 JVM 进程里运行,那么用 Java 锁机制就无法实现对共享资源的同步了,现在必须借助分布式锁来解决分布式环境下共享资源的同步问题。
线程锁:
主要用来给方法、代码块加锁。当某个方法或代码使用锁,在同一时刻仅有一个线程执行该方法或该代码段。线程锁只在同一JVM中有效果,因为线程锁的实现在根本上是依靠线程之间共享内存实现的,比如synchronized是共享对象头,显示锁Lock是共享某个变量(state)。
进程锁:
为了控制同一操作系统中多个进程访问某个共享资源,因为进程具有独立性,各个进程无法访问其他进程的资源,因此无法通过synchronized等线程锁实现进程锁。
分布式锁:
当多个进程不在同一个系统中,用分布式锁控制多个进程对资源的访问。
Redis 单实例分布式锁(Redis2.8之后)
Redis2.8之后 set
命令新增了 nx px
参数,包含了setnx、expire
的功能,起到了原子操作的效果。
使用 set 命令设置锁:
SET key_name my_random_value NX PX 30000
# NX 表示if not exist 就设置并返回True,否则不设置并返回False
# PX 表示过期时间用毫秒级, 30000 表示这些毫秒时间后此key过期
在获取锁后,在完成相关业务后,需要删除自己设置的锁(必须是只能删除自己设置的锁,不能删除他人设置的锁)。
删除原因: 保证服务器资源的高利用效率,不用等到锁自动过期才删除。
删除方法: 由于 Lua 脚本的原子(Redis 在执行 Lua 脚本的过程中,其他客户端的命令都需要等待该 Lua 脚本执行完才能执行)性。
代码如下: 逻辑是先获取key,如果存在并且值是自己设置的就删除此key,否则就跳过。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
代码实现:
public class RedisUtils {
private static JedisPool jedisPool;
static {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(20);
jedisPoolConfig.setMaxIdle(10);
jedisPool = new JedisPool(jedisPoolConfig, "192.168.56.90", 3380, 100000);
}
public static Jedis getJedis() throws Exception {
if (null != jedisPool) {
return jedisPool.getResource();
}
throw new Exception("JedisPool is not ok");
}
}
spring.redis.database=0
spring.redis.host=192.168.56.90
spring.redis.port=3380
#连接池最大连接数(使用负值表示没有限制)默认8
spring.redis.lettuce.pool.max-active=8
#连接池最大阻塞等待时间(使用负值表示没有限制)默认-1
spring.redis.lettuce.pool.max-wait=-1
#连接池中的最大空闲连接默认8
spring.redis.lettuce.pool.max-idle=8
#连接池中的最小空闲连接默认0
spring.redis.lettuce.pool.min-idle=0
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Serializable> redisTemplate(LettuceConnectionFactory connectionFactory) {
RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setConnectionFactory(connectionFactory);
return redisTemplate;
}
}
@RestController
public class GoodController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private final static String REDIS_LOCK_KEY = "goods:001:lock";
@GetMapping("/buy_goods")
public String buy_Goods() {
String value = UUID.randomUUID().toString() + Thread.currentThread().getName();
try {
Boolean lockFlag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK_KEY, value, 10L, TimeUnit.SECONDS);
stringRedisTemplate.expire(REDIS_LOCK_KEY, 10L, TimeUnit.SECONDS);
if (!lockFlag) {
return "抢锁失败!";
} else {
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if (goodsNumber > 0) {
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001", realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件");
return "你已经成功秒杀商品,此时还剩余:" + realNumber;
} else {
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临");
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临";
}
} finally {
String script = "if redis.call('get', KEYS[1]) == ARGV[1]" + "then "
+ "return redis.call('del', KEYS[1])" + "else " + " return 0 " + "end";
DefaultRedisScript<List> redisScript = new DefaultRedisScript<List>();
redisScript.setResultType(List.class);
redisScript.setScriptText(script);
List execute = stringRedisTemplate.execute(redisScript, Collections.singletonList(REDIS_LOCK_KEY), value);
System.out.println(execute);
if (!CollectionUtils.isEmpty(execute) && "1".equals(execute.get(0).toString())) {
System.out.println("------del REDIS_LOCK_KEY success");
} else {
System.out.println("------del REDIS_LOCK_KEY error");
}
}
}
}
使用 Redis 事务删除锁:
while (true){
stringRedisTemplate.watch(REDIS_LOCK_KEY); //加事务,乐观锁
if (value.equalsIgnoreCase(stringRedisTemplate.opsForValue().get(REDIS_LOCK_KEY))){
stringRedisTemplate.setEnableTransactionSupport(true);
stringRedisTemplate.multi();//开始事务
stringRedisTemplate.delete(REDIS_LOCK_KEY);
List<Object> list = stringRedisTemplate.exec();
if (list == null) { //如果等于null,就是没有删掉,删除失败,再回去while循环那再重新执行删除
continue;
}
}
//如果删除成功,释放监控器,并且breank跳出当前循环
stringRedisTemplate.unwatch();
break;
}
单点实现分布式锁的缺点
在分布式系统中,为了避免单点故障,提高可靠性,redis都会采用主从架构,当主节点挂了后,从节点会作为主继续提供服务。该种方案能够满足大多数的业务场景,但是对于要求强一致性的场景如交易,该种方案还是有漏洞的,原因如下:
redis主从架构采用的是异步复制,当master节点拿到了锁,但是锁还未同步到slave节点,此时master节点挂了,发生故障转移,slave节点被选举为master节点,丢失了锁。这样其他线程就能够获取到该锁,显然是有问题的。
因此,上述基于redis实现的分布式锁只是满足了AP,并没有满足C。
该分布式锁针对单机Redis服务不会出现问题,但是 Redis 服务是集群方式的情况下也是有问题的。因为 Redis 集群中每个主节点还会有从节点,由于Redis主从复制是异步的,或者当某个节点宕机后,又立即重启了,可能会出现两个客户端同时持有同一把锁。
极端情况下,比如 master 节点刚写入锁后的时候挂了,由于 Redis 是异步复制到 slave 节点,同步没完成的时候,访问到 slave 就会认为锁没有被占用,实际上已经被别的线程占用了。出现两个客户端同时持有同一把锁。
多节点Redis实现的分布式锁算法(RedLock)
什么是RedLock
Redis 提出了一种权威的基于 Redis 实现分布式锁的方式名叫 Redlock,此种方式比原先的单节点的方法更安全。它可以保证以下特性:
- 安全特性:互斥访问,即永远只有一个 client 能拿到锁
- 避免死锁:最终 client 都可能拿到锁,不会出现死锁的情况,即使原本锁住某资源的 client crash 了或者出现了网络分区
- 容错性:只要大部分 Redis 节点存活就可以正常提供服务
RedLock算法
- 获取当前Unix时间,以毫秒为单位
- 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)
如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)
失败重试
当client不能获取锁时,应该在随机时间后重试获取锁;并且最好在同一时刻并发的把set命令发送给所有redis实例;而且对于已经获取锁的client在完成任务后要及时释放锁,这是为了节省时间。
释放锁
释放锁操作很简单,就是依次释放所有节点上的锁就行了。
性能、崩溃恢复和 fsync
如果我们的节点没有持久化机制,client 从 5 个 master 中的 3 个处获得了锁,然后其中一个重启了,这是注意 整个环境中又出现了 3 个 master 可供另一个 client 申请同一把锁! 违反了互斥性。如果我们开启了 AOF 持久化那么情况会稍微好转一些,因为 Redis 的过期机制是语义层面实现的,所以在 server 挂了的时候时间依旧在流逝,重启之后锁状态不会受到污染。但是考虑断电之后呢,AOF部分命令没来得及刷回磁盘直接丢失了,除非我们配置刷回策略为 fsnyc = always,但这会损伤性能。解决这个问题的方法是,当一个节点重启之后,我们规定在 max TTL 期间它是不可用的,这样它就不会干扰原本已经申请到的锁,等到它 crash 前的那部分锁都过期了,环境不存在历史锁了,那么再把这个节点加进来正常工作。
简单理解就是当一个Client在5个master中的三个申请到了锁,但是其中一个master挂了,重启之后恢复到没有个Client锁的状态,这时又有一个Client过来申请锁,刚好又申请到了三个master,此时出现两个Client获得锁,违反互斥性
Reddison对RedLock的实现
public static void main() {
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://xxxx1:xxx1")
.setPassword("xxxx1")
.setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer()
.setAddress("redis://xxxx2:xxx2")
.setPassword("xxxx2")
.setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().
setAddress("redis://xxxx3:xxx3")
.setPassword("xxxx3")
.setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);
String lockName = "redlock-test";
RLock lock1 = redissonClient1.getLock(lockName);
RLock lock2 = redissonClient2.getLock(lockName);
RLock lock3 = redissonClient3.getLock(lockName);
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
try {
isLock = redLock.tryLock(500, 30000, TimeUnit.MILLISECONDS);
System.out.println("isLock = " + isLock);
if (isLock) {
// lock success, do something;
Thread.sleep(30000);
}
} catch (Exception e) {
} finally {
// 无论如何, 最后都要解锁
redLock.unlock();
System.out.println("unlock success");
}
}
Redis 集群多实例分布式锁
Redisson 提供一个 Redis 集群模式的 Redis 分布式锁,它基于 N 个完全独立的 Redis 节点,部分节点宕机,依然可以保证锁的可用性。
使用 Redisson 构建 分布式锁:
package com.yq.controller.redis;
import org.redisson.Redisson;
import org.redisson.api.RAtomicLong;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class RedissonManager {
private static Config config = new Config();
private static RedissonClient redisson = null;
private static final String RAtomicName = "genId_";
public static void init() {
try {
config.useClusterServers()
.setScanInterval(200000)
// 设置集群状态扫描间隔
.setMasterConnectionPoolSize(10000)
// 设置对于master节点的连接池中连接数最大为10000
.setSlaveConnectionPoolSize(10000)
// 设置对于slave节点的连接池中连接数最大为500
.setIdleConnectionTimeout(10000)
// 如果当前连接池里的连接数量超过了最小空闲连接数,而同时有连接空闲时间超过了该数值,那么这些连接将会自动被关闭,并从连接池里去掉。时间单位是毫秒。
.setConnectTimeout(30000)
// 同任何节点建立连接时的等待超时。时间单位是毫秒。
.setTimeout(3000)
// 等待节点回复命令的时间。该时间从命令发送成功时开始计时。
.setRetryInterval(3000)
// 当与某个节点的连接断开时,等待与其重新建立连接的时间间隔。时间单位是毫秒。
.addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001", "redis://127.0.0.1:7002",
"redis://127.0.0.1:7003", "redis://127.0.0.1:7004", "redis://127.0.0.1:7005");
redisson = Redisson.create(config);
RAtomicLong atomicLong = redisson.getAtomicLong(RAtomicName);
atomicLong.set(1);// 自增设置为从1开始
} catch (Exception e) {
e.printStackTrace();
}
}
/** 获取redis中的原子ID */
public static Long nextID() {
RAtomicLong atomicLong = getRedisson().getAtomicLong(RAtomicName);
atomicLong.incrementAndGet();
return atomicLong.get();
}
public static RedissonClient getRedisson() {
if (redisson == null) {
RedissonManager.init(); // 初始化
}
return redisson;
}
}
package com.yq.redis;
import java.util.concurrent.TimeUnit;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
public class RedisLock {
private static RedissonClient redisson = RedissonManager.getRedisson();
private static final String LOCK_TITLE = "redisLock_";
public static void acquire(String lockName) {
String key = LOCK_TITLE + lockName;
// 获取锁对象实例
RLock mylock = redisson.getLock(key);
// 获取分布式锁,并设置过期时间
mylock.lock(2, TimeUnit.MINUTES);
boolean res = false;
try {
res = mylock.tryLock(0, 10, TimeUnit.SECONDS);
System.out.println("res:" + res);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.err.println("======lock======" + Thread.currentThread().getName());
}
public static void release(String lockName) {
String key = LOCK_TITLE + lockName;
RLock mylock = redisson.getLock(key);
mylock.unlock();
System.err.println("======unlock======" + Thread.currentThread().getName());
}
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
try {
String key = "test123";
RedisLock.acquire(key);
Thread.sleep(1000); // 获得锁之后可以进行相应的处理
System.err.println("======获得锁后进行相应的操作======");
RedisLock.release(key);
System.err.println("=============================");
} catch (Exception e) {
e.printStackTrace();
}
}
});
t.start();
}
}
}
Redis 实现分布式锁进化
主要用到了它的一个命令—— SETNX
。SETNX
是 SET if Not eXists
的缩写,即在指定的 key 不存在时,为 key 设置指定的值。并且当设置成功时返回 1 。 设置失败时返回 0 。因此,我们可以根据返回值来判断加锁是否成功。
第一版:
@Component
public class RedisLock {
@Autowired
private StringRedisTemplate redisTemplate;
public boolean lock(String key, String value) {
return redisTemplate.opsForValue().setIfAbsent(key, value);
}
public void unLock(String key) {
redisTemplate.delete(key);
}
}
业务代码中使用。
问题所在:当 lock() 成功以后,在 do something 的过程中出现意外导致后面的 unLock() 没有被执行,那么就会导致其他请求无法再获得锁,从而造成了死锁。
@RestController
@RequestMapping("/redislock")
public class RedisLockController {
private final long TIME_OUT = 50 * 1000;
private final String REDIS_LOCK = "REDIS_LOCK";
@Autowired
private RedisLock redisLock;
@GetMapping("/lock")
public void lock() {
// 加锁
long currentTime = System.currentTimeMillis();
boolean isLock = redisLock.lock(REDIS_LOCK, String.valueOf(currentTime + TIME_OUT));
if (!isLock) {
throw new RuntimeException("资源已被抢占,换个姿势再试试吧!");
}
// do something
// 解锁
redisLock.unLock(REDIS_LOCK);
}
}
进化版:
给 key 加上过期时间。
问题所在:假如有两个线程 A 和 B,在 A 执行完 do something 之后,恰好 key 到了过期时间,又恰好这时 B 获得了锁,接下来 A 执行 unLock() 会将 B 获得的锁删掉。违背了锁的安全性质。
@Component
public class RedisLock {
@Autowired
private StringRedisTemplate redisTemplate;
public boolean lockV2(String key, String value,Long timeOut) {
return redisTemplate.opsForValue().setIfAbsent(key, value, timeOut, TimeUnit.MILLISECONDS);
}
}
再改进:
上面遇到了一个问题,一个线程删除了不属于它的锁。解决这个问题,就需要在删除之前先判断一下,当前的锁是不是被自己持有,如果是那么删除,如果不是,说明锁已经过期了(此时可能有别的线程持有了锁,也可能没有任何线程持有锁),则不需要再删除了。
@Component
public class RedisLock {
@Autowired
private StringRedisTemplate redisTemplate;
public void unLockV2(String key, String value) {
String oldValue = redisTemplate.opsForValue().get(key);
if (Objects.nonNull(oldValue) && oldValue.equals(value)) {
redisTemplate.delete(key);
}
}
}
OK,这次我们在删除之前对锁的持有者进行了判断,只有确定自己是锁的持有者才去释放锁。这次看起来没什么毛病了,但是很遗憾,判断持有者的逻辑和删除 key 的逻辑仍然不是一个原子的操作。虽然这两个操作之间的间隔非常短,但仍有可能在这两个操作之间被其他线程干扰。
虽然仍不完美,但相对于上面执行完业务代码后直接删除 key 的方式的可靠性已经提升了 N 个数量级了。因为一般的业务逻辑执行耗时都在几百毫秒上下,而判断所有者的逻辑与删除 key 的逻辑间隔在微秒级别,而时间越短,出错的概率就会越低。
Redis 使用分布式锁实现秒杀
Jedis 线程池配置
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
@Configuration
public class SpringConfig {
@Bean
public JedisPool jedisPool() {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
// 最大连接数
jedisPoolConfig.setMaxTotal(20);
// 定义Jedis连接池
JedisPool jedisPool = new JedisPool(jedisPoolConfig, "192.168.56.90", 3380);
return jedisPool;
}
}
创建锁的策略
使用 set
命令 nx
参数 来设置值,当A用户先set成功了,那B用户set的时候就返回失败,满足了某个时间点只允许一个用户拿到锁。
锁过期时间
某个抢购场景时候,如果没有过期的概念,当A用户生成了锁,但是后面的流程被阻塞了一直无法释放锁,那其他用户此时获取锁就会一直失败,无法完成抢购的活动;当然正常情况一般都不会阻塞,A用户流程会正常释放锁;过期时间只是为了更有保障。
public boolean setnx(String key, String val) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
if (jedis == null) {
return false;
}
return jedis.set(key, val, "NX", "PX", 1000 * 60).equalsIgnoreCase("ok");
} catch (Exception ex) {
} finally {
if (jedis != null) {
jedis.close();
}
}
return false;
}
- NX:是否存在key,存在就不set成功
- PX:key过期时间单位设置为毫秒(EX:单位秒)
删除锁方式
场景如:如果锁有效时间设置1分钟,本身用户A获取锁后,没遇到什么特殊情况正常生成了抢购订单后,此时其他用户应该能正常下单了才对,但是由于有个1分钟后锁才能自动释放,那其他用户在这1分钟无法正常下单(因为锁还是A用户的),因此我们需要A用户操作完后,主动去解锁。
这里用Reids官方推荐的直接执行lua脚本:根据val判断其是否存在,如果存在就del。
public int delnx(String key, String val) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
if (jedis == null) {
return 0;
}
String lua_script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
String shakey = jedis.scriptLoad(lua_script);// 加载脚本
// 由于 Lua 脚本的原子性
// Redis 在执行 Lua 脚本的过程中,其他客户端的命令都需要等待该 Lua 脚本执行完才能执行
return Integer.valueOf(jedis.evalsha(shakey, Arrays.asList(key), Arrays.asList(val)).toString());
} catch (Exception ex) {
} finally {
if (jedis != null) {
jedis.close();
}
}
return 0;
}
模拟抢单动作
模拟下10w人抢单的场景,其实就是一个并发操作请求而已。
初始化10w个用户,并初始化库存,商品等信息。
设定商品只有10个库存,然后通过并行流的方式来模拟抢购。
@RestController
@Slf4j
public class DemoController {
@Resource
private JedisPool jedisPool;
// 库存数量
private long nKuCuen = 10;
// 商品key名字
private String productKey = "computer_key";
// 获取锁的超时时间 秒
private int timeout = 30 * 1000;
@GetMapping("/qiangdan")
public List<String> qiangdan() {
// 抢到商品的用户
List<String> shopUsers = new ArrayList<>();
// 构造要抢单的用户
List<String> users = new ArrayList<>(100000);
IntStream.range(0, 100000).parallel().forEach(user -> {
users.add("神牛-" + user);
});
// 模拟开抢
users.parallelStream().forEach(b -> {
String shopUser = qiang(b);
if (!StringUtils.isEmpty(shopUser)) {
shopUsers.add(shopUser);
}
});
return shopUsers;
}
/**
* 模拟抢单动作
*/
private String qiang(String user) {
long startTime = System.currentTimeMillis();
// 未抢到的情况下,30秒内继续获取锁
while ((startTime + timeout) >= System.currentTimeMillis()) {
//商品是否剩余
if (nKuCuen <= 0) {
break;
}
if (setnx(productKey, user)) {
// 用户拿到锁
log.info("用户{}拿到锁...", user);
try {
// 商品是否剩余
if (nKuCuen <= 0) {
break;
}
// 模拟生成订单耗时操作,方便查看:神牛-50 多次获取锁记录
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 抢购成功,商品递减,记录用户
nKuCuen--;
// 抢单成功跳出
log.info("用户{}抢单成功跳出...所剩库存:{}", user, nKuCuen);
return user + "抢单成功,所剩库存:" + nKuCuen;
} finally {
log.info("用户{}释放锁...", user);
// 释放锁
delnx(productKey, user);
}
}
}
return "";
}
}
实现的逻辑:
- parallelStream():并行流模拟多用户抢购
- (startTime + timeout) >= System.currentTimeMillis():判断未抢成功的用户,timeout秒内继续获取锁
- 获取锁前和后都判断库存是否还足够
- jedis.setnx(productKey, user):用户获取抢购锁
- 获取锁后并下单成功,最后释放锁:jedis.delnx(productKey, user)
最终返回抢购成功的用户:
使用 Zookeeper 作为分布式锁
可以理解成 ZooKeeper 就像是我们的电脑文件系统,我们可以在 d 盘中创建文件夹 a,并且可以继续在文件夹 a 中创建文件夹 a1,a2。
文件系统的特点就是同一个目录下文件名称不能重复,同样 ZooKeeper 也是这样的。
ZooKeeper 可以创建 4 种类型的节点:
- 持久性节点:客户端和 ZooKeeper 断开连接,ZooKeeper 依然都会记录这个节点。
- 持久性顺序节点:
- 临时性节点:客户端和 ZooKeeper 断开连接,ZooKeeper 就不再保存这个节点。
- 临时性顺序节点:
节点顺序性特点: 在创建节点的时候,ZooKeeper 会自动给节点编号比如 0000001,0000002 这种的。
Zookeeper 监听机制: 客户端注册监听它关心的目录节点,当目录节点发生变化(数据改变、被删除、子目录节点增加删除)等,Zookeeper 会通知客户端。
创建锁:
多个JVM服务器之间,同时在 Zookeeper
上创建相同的一个临时节点
,因为临时节点路径是保证唯一。
只要谁能够创建节点成功,谁就能获取到锁。
没有创建成功节点,只能注册这个监听器监听这个锁并进行等待,当释放锁的时候,采用事件通知给其他客户端重新获取锁的资源。
这时候客户端使用事件监听,如果该临时节点被删除的话,重新进入获取锁的步骤。
释放锁:
Zookeeper
使用直接关闭临时节点 session
会话连接,因为临时节点生命周期与session
会话绑定在一起。如果程序被一次停止了,session
会话会断开。
如果session
会话连接关闭的话,该临时节点也会被删除。
代码实现:
<dependency>
<groupId>com.github.sgroschupf</groupId>
<artifactId>zkclient</artifactId>
<version>0.1</version>
</dependency>
定义分布式锁接口。
public interface ZkLock {
void zkLock();
void zkUnLock();
}
定义抽象模板类,使用模板方法设计模式。
public abstract class ZkAbstractTemplateLock implements ZkLock {
protected ZkClient zkClient = new ZkClient("192.168.56.90:2181", 45 * 1000);
protected String path = null;
protected CountDownLatch countDownLatch = null;
@Override
public void zkLock() {
if (tryZkLock()) {
System.out.println(Thread.currentThread().getName() + "\t 占用锁成功");
} else {
waitZkLock();
zkLock();
}
}
@Override
public void zkUnLock() {
if (zkClient != null) {
zkClient.close();
}
System.out.println(Thread.currentThread().getName() + "\t 释放锁成功");
}
public abstract boolean tryZkLock();
public abstract void waitZkLock();
}
定义分布式锁实现,tryZkLock 创建临时节点。
public class ZkDistributedLock extends ZkAbstractTemplateLock {
public ZkDistributedLock(String path) {
this.path = path;
}
@Override
public boolean tryZkLock() {
try {
zkClient.createEphemeral(path);
return true;
} catch (Exception e) {
return false;
}
}
@Override
public void waitZkLock() {
IZkDataListener iZkDataListener = new IZkDataListener() {
@Override
public void handleDataChange(String dataPath, Object data) throws Exception {
}
@Override
public void handleDataDeleted(String dataPath) throws Exception {
countDownLatch.countDown();
}
};
zkClient.subscribeDataChanges(path, iZkDataListener);
if (zkClient.exists(path)) {
countDownLatch = new CountDownLatch(1);
try {
countDownLatch.await();
} catch (InterruptedException e) {
}
}
}
}
业务类使用分布式锁生成订单号。
public class OrderNumCreateUtil {
private static int number = 0;
public String getOrdNumber() {
return "\t 生成订单号:" + (++number);
}
}
public class OrderService {
private OrderNumCreateUtil orderNumCreateUtil = new OrderNumCreateUtil();
private ZkLock zkLock = new ZkDistributedLock("/zkLock");
public void getOrdNumber() {
zkLock.zkLock();
try {
System.out.println(Thread.currentThread().getName() + orderNumCreateUtil.getOrdNumber());
} catch (Exception e) {
} finally {
zkLock.zkUnLock();
}
}
}
模拟分布式场景。
public class Client {
public static void main(String[] args) {
for (int i = 1; i <= 100; i++) {
new Thread(() -> {
new OrderService().getOrdNumber();
}, "Thread-" + i).start();
}
}
}
Redisson分布式锁续期
面试问题: Redis 锁的过期时间小于业务的执行时间该如何续期?
如何回答: 只要客户端一旦加锁成功,就会启动一个watch dog
看门狗,他是一个后台线程,看门狗的间隔时间是internalLockLeaseTime/3
秒检查一下,如果客户端还持有锁key
,那么就会不断的延长锁key
的生存时间。
默认情况下,加锁的时间是30秒,如果加锁的业务没有执行完,就会进行一次续期,把锁重置成30秒。
那这个时候可能又有同学问了,那业务的机器万一宕机了呢?
宕机了定时任务跑不了,就续不了期,那自然30秒之后锁就解开了。
如果一个已经获得锁的线程的业务代码由于调用外部接口(网络原因)一直处于等待状态,那守护线程一直给他续期吗?这样不是一直挂在那里。
接口调用一般都有超时时间的。
@GetMapping("hello")
public String hello() {
//1、获取一把锁,只要锁的名字一样,就是同一把锁
RLock lock = redisson.getLock("my-lock");
//2、加锁
//lock.lock();//阻塞式等待。默认加的锁都是30s时间。
//1、锁的自动续期,如果业务时间超长,运行期间自动给锁续上新的30s。不用担心业务时间长,锁自动过期被删掉
//2、加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认在30s以后自动删除
lock.lock(10, TimeUnit.SECONDS); //10秒自动解锁,自动解锁时间一定要大于业务的执行时间。
//问题:lock,lock(10, TimeUnit.SECONDS); 在锁时间到了以后,不会自动续期
//1、如果我们传递了锁的超时时间,就发送给redis执行脚本,进行占锁,默认超时就是我们指定的时间
//2、如果我们未指定锁的超时时间,就是用30*1000【LockWatchdogTimeout看门狗的默认时间】;
// 只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是看门狗的默认时间】,每隔10s都会自动再次续期,续成30s
// internalLockLeaseTime【看门狗时间】/ 3, 10s
//最佳实战
//1、lock.lock(10, TimeUnit.SECONDS);省掉了整个续期操作。手动解锁
try {
System.out.println("加锁成功,执行业务。。。" + Thread.currentThread().getId());
Thread.sleep(30000);
} catch (Exception e) {
} finally {
//3、解锁 将设解锁代码没有运行,redisson会不会出现死锁
System.out.println("释放锁。。。" + Thread.currentThread().getId());
lock.unlock();
}
return "hello";
}
参考:
Redlock(redis分布式锁)原理分析
setnx分布式锁原理_看一眼就能懂的“分布式锁”原理
Redis分布式锁的实现原理
每秒上千订单场景下的分布式锁高并发优化