此篇文章主要结合测试用例介绍redis分布式锁使用,以及深入介绍实现原理。如果想在项目中使用请先阅读README。
clone该项目,进入项目目录执行mvn install,把包安装到maven仓库
com.redislock
redislock-spring-boot-starter
1.0.0
版本要求:spring-boot 2.0.0以上 ,jdk 1.8以上,redis 2.6.12以上
如下:
@Service
public class TestService {
@RedisSynchronized(value = "talk",fallbackMethod = "shutup")
public String myTurn(String speak){
//已经获得了锁,可以对抢占到的资源做操作了
//为了便于观察我们在获得锁后,让线程sleep10s(独占这个锁10s)
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return speak;
}
@Fallback(value = "shutup",replaceReturn = true)
private String notYourTurn(RedisLockJoinPoint redisLockJoinPoint){
//当锁失败会走入降级方法
return "silence";
}
}
安装好redis客户端之后,启动redis服务,登陆到redis客户端
127.0.0.1:6379> keys *
(empty list or set)
127.0.0.1:6379>
目前redis中没有任何的key
调用第一步中@RedisSynchronized标注的方法
@RunWith(SpringRunner.class)
@SpringBootTest(classes = RedislockApplication.class)
public class RedislockTest {
@Autowired
private TestService testService;
@Test
public void testLock() {
System.out.println("运行结果:" + testService.myTurn("bulaha", 0));
}
}
在方法运行结束之前,再次查看redis,可以看到,redis里多出了一个key,与@RedisSynchronized的value值相同,代表当前方法获得了锁。
127.0.0.1:6379> keys *
1) "talk"
127.0.0.1:6379>
10s后,运行结果如下:
运行结果:bulaha
方法运行结束后,再次观察redis,可以发现"talk"已经被自动移除了,即锁被释放了。
127.0.0.1:6379> keys *
(empty list or set)
127.0.0.1:6379> keys *
1) "talk"
127.0.0.1:6379> keys *
(empty list or set)
127.0.0.1:6379>
现在模拟一下当其他的线程或者进程已经抢占到锁时,当前线程锁失败时的情况。
先在redis中设置一个key “talk”
127.0.0.1:6379> set talk 1
OK
127.0.0.1:6379> keys *
1) "talk"
127.0.0.1:6379>
再运行一次在第三步中的测试方法,运行结果如下:
运行结果:silence
可以看到,最终并没有像成功获得锁时,输出我们期望的结果"bulaha",而是输出了第一步中@Fallback方法的返回值,这就是所谓的方法降级。在锁失败时,执行一些替代的逻辑并返回替代的值。(也可以不指定降级方法,锁失败会抛出异常。)
目前只要有
三个注解@RedisSynchronized,@Fallback,@FallbackHandler
三个配置项
redislock.prefix
redislock.timeout
redislock.heart-beat
详见README
实现分布式锁的策略为:
1.使用setnx在redis中设置一个键,并且设置过期时间(redis在2.6.12版本之后,支持了加强的set命令,即SET key value [expiration EX seconds|PX milliseconds] [NX|XX],一条set命令就可以实现setnx+expire,我们不必再为上篇文章中所说的setnx和expire分别执行时产生的死锁问题担心了,是不是很赞呢)
具体在java里怎么向redis发送这条命令呢,这里我们使用spring-data-redis,代码如下
public class MyRedisTemplate{
private RedisTemplate redisTemplate;
public void setRedisTemplate(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Boolean setifAbsent(String key, String value, long timeoutMilis){
Boolean execute = redisTemplate.execute(new MyRedisCallback(key,value,timeoutMilis));
return execute;
}
class MyRedisCallback implements RedisCallback{
private String key;
private String value;
private long timeoutMilis;
public MyRedisCallback(String key, String value, long timeoutMilis) {
this.key = key;
this.value = value;
this.timeoutMilis = timeoutMilis;
}
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
RedisStringCommands redisStringCommands = connection.stringCommands();
//执行加强了的set命令,返回true代表设置成功
Boolean set = redisStringCommands.set(key.getBytes(), value.getBytes(), Expiration.milliseconds(timeoutMilis), RedisStringCommands.SetOption.SET_IF_ABSENT);
return set;
}
}
public Boolean expire(String key, long time) {
return redisTemplate.expire(key,time,TimeUnit.MILLISECONDS);
}
public Boolean delete(String key) {
return redisTemplate.delete(key);
}
}
2.将成功获得的锁加入到一个集合中,开启一个Timer,给集合中的锁续命。由于这个集合是要在线程之间共享的,所以要使用线程安全的集合,这里使用ConcurrentHashMap。(本来想使用Set,但是java的concurrent包中没有提供,就用ConcurrentHashMap来代替,其实Java中的Set也就是用Map来实现的)
3.当解锁时先将key从“续命集合”中移除,再从redis中移除key。这里不能将顺序倒过来,因为如果A线程先从redis中移除key的话,可能出现马上又有一个B线程得到了锁并将key加入“续命集合”之后,A线程才将这个key从“续命集合”中移除,这样线程B得到的锁就没有了续命功能。如果这个线程偏偏到锁过期还没有执行完,就出现了并发操作被锁住的资源的情况。
public class RedisLock {
private long timeout = LOCK_TIMEOUT;
private long heartBeat = LOCK_HEART_BEAT;
private MyRedisTemplate myRedisTemplate;
public void setMyRedisTemplate(MyRedisTemplate myRedisTemplate) {
this.myRedisTemplate = myRedisTemplate;
}
private Map aliveLocks = new ConcurrentHashMap();
private final Timer timer = new Timer();
//在bean初始化完成之后启动timer,在timer任务中给锁续命
@PostConstruct
public void init(){
timer.schedule(new TimerTask() {
@Override
public void run() {
keeplockAlive();
}
},0,heartBeat);
}
//在销毁之前将timer取消
@PreDestroy
private void destroy(){
timer.cancel();
}
private void keeplockAlive(){
if(aliveLocks.size() > 0){
for (String key:
aliveLocks.keySet()) {
myRedisTemplate.expire(key,timeout);
}
}
}
public void unlock(String key) {
aliveLocks.remove(key);
myRedisTemplate.delete(key);
}
public boolean lock(String key){
return lock(key,true);
}
public boolean lock(String key,boolean keepAlive){
Boolean redisLock = myRedisTemplate.setifAbsent(key, "redisLock", timeout);
if(redisLock && keepAlive){
aliveLocks.put(key,"");
}
return redisLock;
}
}
现在我们已经可以使用之前讲到的RedisLock类实现加锁解锁,其实分布式锁的基本功能已经实现了,你可以如下这样使用来实现分布式锁
redislock.lock("mykey");
//需要加锁的代码段
redislock.unlock("mykey");
但是这样使用起来不是很方便,也不是很安全,你需要把RedisLock实例注入进来,自己进行加解操作。
我们可以仿照Spring的做法,声明一个注解用于标记想要加锁的方法
@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisSynchronized {
String value() default "";
String fallbackMethod() default "";
}
现在我们有了自己的注解,你可以随便加到哪个方法上标记他们。但是你会发现不论运行多少遍你所标记的方法都不会如你所愿加上锁,这是当然的事情。因为我们虽然有了自己的注解但是我们并没有真正把加锁的功能赋予给我们标记的方法。那我们该怎么做才能像spring和其他框架一样在使用时只要标记注解就能赋予相应的功能呢?那就是动态代理。可以这么说实现Spring生态的两大利器,其一是注解,其二就是动态代理。注解流于光鲜的外表,给我们提供友善的体验;动态代理潜藏在背后支撑起整个机制的运行。
动态代理简介:spirng中的动态代理使用主要有两种,一种是jdk动态代理,一种是cglib动态代理。其中jdk动态代理要求被代理类一定要继承接口,而cglib却没有这个限制,这也是cglib能够盛行的原因之一。而cglib也分很多种版本,普通的cglib需要额外依赖,如asm;还有cglib-nodep顾名思义,no dependence,避免了在实际使用时依赖的包不兼容的问题。而我们使用的不在以上两个之中,我们使用的是spring-core包中自带的cglib,Spring 添加了一些很赞的功能,接下来我会介绍到。
要达到我们最终的目的,有以下三件事要做
1.代理,给方法上添加了@RedisSynchronized注解的任意业务bean生成带有加解锁功能的代理。
2.替换,在使用@Autoware注入bean时,注入的应该是我们代理后的bean。
3.时机,选取代理时机,即在我们业务代码运行之前也就是Spring初始化完成前要完成代理并替换。
结合以上三个要点我们结合cglib并引入Spring的一个组件就可以全部满足,他就是BeanPostProcessor。
BeanPostProcessor简介:参考BeanPostProcessor的注释可知,容器在启动时可以自动检测BeanPostProcessor的实现类提前实例化,并在其后实例话的bean实例化时回调接口方法。
接口方法postProcessBeforeInitialization会在属性设置完成后,初始化方法(init-method)执行之前被回调。
接口方法postProcessAfterInitialization初始化方法(init-method)执行之后被回调。
回调方法的返回值可以是原来的bean,也可以是被代理的bean(either the original or a wrapped one)。
从简介中我们理解到,只要继承BeanPostProcessor,时机就有了,我们可以在回调方法中进行代理,并且在代理后可以直接把生成的代理当作返回值返回,替换就完成了。
实现如下
public class RedisLockAutoProxyCreator implements BeanPostProcessor{
private RedisLock redisLock;
public void setRedisLock(RedisLock redisLock) {
this.redisLock = redisLock;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
Class> aClass = AopUtils.getTargetClass(bean);
for (Method method : aClass.getDeclaredMethods()) {
//如果有方法标注了RedisSynchronized就生成cglib代理,并返回代理bean
if(method.isAnnotationPresent(RedisSynchronized.class)){
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(aClass);
enhancer.setCallback(new MyHandler(bean));
return enhancer.create();
}
}
//如果没有方法标注RedisSynchronized就返回原bean
return bean;
}
class MyHandler implements InvocationHandler {
private Object o;
MyHandler(Object o){
this.o=o;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//如果没有被RedisSynchronized注解标注就不对方法做代理
if(!method.isAnnotationPresent(RedisSynchronized.class)){
return method.invoke(o, args);
}
//如果被RedisSynchronized注解标注,添加锁功能
String key = getKey(o,method,args);
boolean locked = redisLock.lock(key);
if(locked){
try{
return method.invoke(o, args);
}finally {
redisLock.unlock(key);
}
}
//锁失败就异常
throw new LockFailedException("lock failed");
}
private String getKey(Object o, Method method, Object[] args) {
RedisSynchronized annotation = method.getAnnotation(RedisSynchronized.class);
String key = annotation.value();
if("".equals(key)){
key = method.toGenericString();
}
return key;
}
}
}
为了更清晰的表达代理过程,此代码舍去了有点复杂的锁降级部分,如果以上都理解了,可以clone代码看一下锁降级的实现。
到此我们完成了注解化的redis锁。
由于篇幅问题先讲到这里,如果对集成spring-boot-starter感兴趣,可以给我留言,我另写一篇进行讲解。此篇文章已经写好,传送门。