Redis 作为分布式锁的思路
-
因为 Redis 有 setnx k v 这个命令,即 set if not exist,如果 key 已存在,就无法成功设置值。那么就可以通过判断谁在 Redis 中设置相同 key 的值成功,谁就获得了锁。 但是存在问题,如果某个服务卡住了导致这个锁不会被删除,那这个锁就会一直存在,那服务链路就崩了。
-
为了防止锁一直存在的情况,可以给这个 kv键值对加上一个过期时间,这样的话就可以防止这把锁无法释放的问题了。set k v EX time NX,NX 表示使用 setnx模式,时间单位是 秒。 但是也存在问题,加了锁有固定的过期时间,假如微服务A 的工作时间大于了 kv键值对的过期时间,那么就会出现问题。
-
为了防止删除别人的锁,那就保证锁只能被加锁的人删,就让锁的值设置成该任务专属的值,然后我删除锁的时候会判断该锁的值是不是对应我的任务。 但是由于高并发,会存在这种情况:A 在超时前进入释放锁的代码块,然后获取到的锁的值还是属于 A 的,然后在 A 进行释放锁的时候刚刚好超时,锁自动失效,B 加上了锁,然后 A 又刚好在 B 上锁之后删除锁,那 B 又是还没干活锁就没了。
Redisson框架
简介
自己去做分布式锁,因为锁的超时时间不太好衡量,所以很容易出现各种问题。
我们可以借助一下 Redisson框架,它是 Redis 官方推荐的 Java版的 Redis客户端。
Redisson 内部提供了一个监控锁的看门狗(前提是不设置超时时间),它的作用是在 Redisson实例被关闭前,不断的延长锁的有效期,它为我们提供了很多种分布式锁的实现。Redisson 提供的分布式锁是支持锁自动续期的,也就是说,如果线程仍旧没有执行完,那么 redisson 会自动给 redis 中的目标 key 延长超时时间,这在 Redisson 中称之为 Watch Dog 机制。同时 redisson 还有公平锁、读写锁的实现。
注意:如果 Redis 没有密码,有可能会报错,所以报错可以试试给 Redis 加上密码验证。
实例
实践1
SpringBoot 集成 Redisson。
引入依赖
注意版本,我的 SpringBoot 版本是 2.2.1.RELEASE,所以使用 3.12。
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.12.5</version> </dependency>
配置文件
server: # 服务端口 port: 8001 spring: application: # 服务名称 name: daily-test redis: host: 192.168.174.128 database: 0 timeout: 1800000 lettuce: pool: max-active: 20 max-wait: -1 max-idle: 5 min-idle: 0
使用 RLock.lock() 加锁
多线程下对 Redis 修改,如果不加锁,数值很可能会出错。
学习时直接 new Thread 了,应该是要使用 ThreadPoolExecutor。
除了 RLock,还有 RedLock,防止因为 Redis服务器挂了而崩溃。
自定义 Client
import com.chw.service.TestService; import java.util.concurrent.TimeUnit; import javax.annotation.Resource; import org.redisson.Redisson; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @Resource private StringRedisTemplate stringRedisTemplate; public void testLock() throws InterruptedException { System.out.println("进来了"); Config config = new Config(); // 配置连接的Redis服务器,也可以指定集群 config.useSingleServer().setAddress("redis://192.168.174.128:6379"); // 创建RedissonClient客户端 RedissonClient client = Redisson.create(config); for (int i = 0; i < 10; i++) { new Thread(() -> { System.out.println("开始!"); // 指定锁的名称,拿到锁对象 RLock lock = client.getLock("testLock"); for (int j = 0; j < 10; j++) { // 加锁 lock.lock(); int num = Integer.parseInt(stringRedisTemplate.opsForValue().get("test")) + 1; System.out.println("---" + num); stringRedisTemplate.opsForValue().set("test", String.valueOf(num)); // 解锁 lock.unlock(); } System.out.println("结束!"); }).start(); } }
使用自动注入的 RedissonClient
@Resource private RedissonClient redissonClient; @Resource private StringRedisTemplate stringRedisTemplate; @GetMapping("test1") public void testLock() throws InterruptedException { System.out.println("进来了"); for (int i = 0; i < 10; i++) { new Thread(() -> { System.out.println("开始!"); // 指定锁的名称,拿到锁对象 RLock lock = redissonClient.getLock("testLock"); for (int j = 0; j < 10; j++) { // 加锁 lock.lock(); int num = Integer.parseInt(stringRedisTemplate.opsForValue().get("test")) + 1; System.out.println("---" + num); stringRedisTemplate.opsForValue().set("test", String.valueOf(num)); // 解锁 lock.unlock(); } System.out.println("结束!"); }).start(); } }
实践2
使用注解 + 切面的方式。
但这种方式有一个注意点,因为是使用切面去自动加锁,所以你这个加锁的方法的调用者必须是一个 bean,就是说必须是 service 的 impl方法,而不能是 impl 里面的私有方法,这会使得其使用不到 Spring 的代理。
自定义加锁注解
import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.concurrent.TimeUnit; /** * The interface Distributed lock. * * @author xxw * @date 2022.08.11 14:43 */ @Documented @Target(value = ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DistributedLock { /** * 锁的名称 * * @return the string */ String value() default ""; /** * Key string. * * @return the string */ String key() default ""; /** * 等待获取时间 * * @return the long */ long waitTime() default 10; /** * 获得锁有效时间,到时间自动释放 * * @return the long */ long leaseTime() default 10; /** * Time unit time unit. * * @return the time unit */ TimeUnit timeUnit() default TimeUnit.SECONDS; }
定义一个切面去处理注解
import com.chw.annotations.DistributedLock; import java.lang.reflect.Method; import javax.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.junit.jupiter.api.Assertions; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.core.LocalVariableTableParameterNameDiscoverer; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.stereotype.Component; @Aspect @Component @Slf4j public class DistributedLockAspect { /** * The Redisson client. */ @Resource private RedissonClient redissonClient; /** * Lock object. * * @param pjp the pjp * @return the object * @throws Throwable the throwable */ @Around("@annotation(com.chw.locks.DistributedLock)") public Object lock(ProceedingJoinPoint pjp) throws Throwable { MethodSignature ms = (MethodSignature) pjp.getSignature(); Method method = ms.getMethod(); DistributedLock distributedLock = method.getAnnotation(DistributedLock.class); StringBuffer lockKey = new StringBuffer("distributed:lock"); String value = distributedLock.value(); Assertions.assertNotNull(value); lockKey.append(":" + value); // 获取key String spel = distributedLock.key(); Assertions.assertNotNull(spel); lockKey.append(":" + generatorKey(spel, method, pjp.getArgs())); RLock lock = redissonClient.getLock(lockKey.toString()); try { boolean isSuccess = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); if (!isSuccess) { log.warn("distributed lock fail key: {}", lockKey); throw new RuntimeException("分布式锁获取锁失败"); } return pjp.proceed(); } catch (Exception e) { log.error("distributed lock error", e); throw e; } finally { lock.unlock(); } } private String generatorKey(String key, Method method, Object[] args) { LocalVariableTableParameterNameDiscoverer u = new LocalVariableTableParameterNameDiscoverer(); String[] paraNameArr = u.getParameterNames(method); ExpressionParser parser = new SpelExpressionParser(); StandardEvaluationContext context = new StandardEvaluationContext(); for (int i = 0; i < paraNameArr.length; i++) { context.setVariable(paraNameArr[i], args[i]); } return parser.parseExpression(key).getValue(context, String.class); } }
加锁测试
我们这里用两个 controller,先请求 test2,让他拿到锁,然后再请求 test3,那么他就会等待锁。
@DistributedLock(value = "testLock", key = "#request.id+'-'+#request.name") @PostMapping("test2") public void testLock2(@RequestBody TestRequest request) throws InterruptedException { System.out.println("开始!"); for (int j = 0; j < 10; j++) { int num = Integer.parseInt(stringRedisTemplate.opsForValue().get("test")) + 1; System.out.println(Thread.currentThread().getId() + "--" + num); stringRedisTemplate.opsForValue().set("test", String.valueOf(num)); Thread.sleep(5 * 1000); } System.out.println(Thread.currentThread().getId() + "结束!"); } @DistributedLock(value = "testLock", key = "#request.id+'-'+#request.name") @PostMapping("test3") public void testLock3(@RequestBody TestRequest request) throws InterruptedException { System.out.println("开始!"); for (int j = 0; j < 10; j++) { int num = Integer.parseInt(stringRedisTemplate.opsForValue().get("test")) + 1; System.out.println(Thread.currentThread().getId() + "--" + num); stringRedisTemplate.opsForValue().set("test", String.valueOf(num)); } System.out.println(Thread.currentThread().getId() + "结束!"); }
发现的问题
然后就会发现,因为这个锁有有效时间,然后 test2 还没有干完活,它的锁就失效了,然后 test3 就立马拿到锁然后把活干完了再解锁。但此时 test2 活还没干完,但是它已经加过锁了,然后当他干完活去解锁的时候,就会出现解锁失败的报错了。
问题原因
因为 DistributedLockAspect 中使用的加锁方式是带有 等待时间、上锁时间 参数的,那么这个锁就不会自动续期,超过等待时间就上锁失败,超过上锁时间就自动解锁。而普通的 lock.lock() 是可以让看门狗自动续期 30s 的。
lock.lock(); 是阻塞式等待的,默认加锁 30s;不用担心业务时间长,锁自动过期被删掉;加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认会在 30s内自动过期,不会产生死锁问题;
自己指定解锁时间 lock.lock(10,TimeUnit.SECONDS),10秒钟自动解锁,自己指定解锁时间 redisson 不会自动续期。