Redis分布式锁
分布式锁也算是 Redis 比较常见的使用场景。 问题场景:
例如一个简单的用户操作,一个线城去修改用户的状态,首先从数据库中读出用户的状态,然后 在内存中进行修改,修改完成后,再存回去。在单线程中,这个操作没有问题,但是在多线程 中,由于读取、修改、存 这是三个操作,不是原子操作,所以在多线程中,这样会出问题。 对于这种问题,我们可以使用分布式锁来限制程序的并发执行。
1, 基本用法
分布式锁实现的思路很简单,就是进来一个线程先占位,当别的线程进来操作时,发现已经有人占位 了,就会放弃或者稍后再试。 在 Redis 中,占位一般使用 setnx 指令,先进来的线城先占位,线城的操作执行完成后,再调用 del 指 令释放位子。 根据上面的思路,我们写出的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public class LockTest { public static void main(String[] args) { Redis redis = new Redis(); redis.execute(jedis->{ Long setnx = jedis.setnx("k1", "v1"); if (setnx == 1) { jedis.set("name", "javaboy"); String name = jedis.get("name"); System.out.println(name); jedis.del("k1"); }else{ } }); } }
|
上面的代码存在一个小小问题:如果代码业务执行的过程中抛异常或者挂了,这样会导致 del 指令没有 被调用,这样,k1 无法释放,后面来的请求全部堵塞在这里,锁也永远得不到释放。 要解决这个问题,我们可以给锁添加一个过期时间,确保锁在一定的时间之后,能够得到释放。改进后 的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public class LockTest { public static void main(String[] args) { Redis redis = new Redis(); redis.execute(jedis->{ Long setnx = jedis.setnx("k1", "v1"); if (setnx == 1) { jedis.expire("k1", 5); jedis.set("name", "javaboy"); String name = jedis.get("name"); System.out.println(name); jedis.del("k1"); }else{ } }); } }
|
这样改造之后,还有一个问题,就是在获取锁和设置过期时间之间如果如果服务器突然挂掉了,这个时 候锁被占用,无法及时得到释放,也会造成死锁,因为获取锁和设置过期时间是两个操作,不具备原子 性。 为了解决这个问题,从 Redis2.8 开始,setnx 和 expire 可以通过一个命令一起来执行了,我们对上述 代码再做改进:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public class LockTest { public static void main(String[] args) { Redis redis = new Redis(); redis.execute(jedis->{ String set = jedis.set("k1", "v1", new SetParams().nx().ex(5)); if (set !=null && "OK".equals(set)) { jedis.expire("k1", 5); jedis.set("name", "javaboy"); String name = jedis.get("name"); System.out.println(name); jedis.del("k1"); }else{ } }); } }
|
2, 解决超时问题
为了防止业务代码在执行的时候抛出异常,我们给每一个锁添加了一个超时时间,超时之后,锁会被自 动释放,但是这也带来了一个新的问题:如果要执行的业务非常耗时,可能会出现紊乱。
举个例子:第 一个线程首先获取到锁,然后开始执行业务代码,但是业务代码比较耗时,执行了 8 秒,这样,会在第 一个线程的任务还未执行成功锁就会被释放了,此时第二个线程会获取到锁开始执行,在第二个线程刚 执行了 3 秒,第一个线程也执行完了,此时第一个线程会释放锁,但是注意,它释放的第二个线程的 锁,释放之后,第三个线程进来。
对于这个问题,我们可以从两个角度入手:
- 尽量避免在获取锁之后,执行耗时操作。
- 可以在锁上面做文章,将锁的 value 设置为一个随机字符串,每次释放锁的时候,都去比较随机 字符串是否一致,如果一致,再去释放,否则,不释放。 对于第二种方案,由于释放锁的时候,要去查看锁的 value,第二个比较 value 的值是否正确,第三步 释放锁,有三个步骤,很明显三个步骤不具备原子性,为了解决这个问题,我们得引入 Lua 脚本。
Lua 脚本的优势:
- 使用方便,Redis 中内置了对 Lua 脚本的支持。
- Lua 脚本可以在 Redis 服务端原子的执行多个 Redis 命令。
在 Redis 中,使用 Lua 脚本,大致上两种思路:
- 提前在 Redis 服务端写好 Lua 脚本,然后在 Java 客户端去调用脚本(推荐)。
- 可以直接在 Java 端去写 Lua 脚本,写好之后,需要执行时,每次将脚本发送到 Redis 上去执行。 首先在 Redis 服务端创建 Lua 脚本,内容如下:
1 2 3 4 5
| if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
|
接下来,可以给 Lua 脚本求一个 SHA1 和,命令如下:
1
| cat lua/releasewherevalueequal.lua | redis-cli -a javaboy script load --pipe
|
script load 这个命令会在 Redis 服务器中缓存 Lua 脚本,并返回脚本内容的 SHA1 校验和,然后在 Java 端调用时,传入 SHA1 校验和作为参数,这样 Redis 服务端就知道执行哪个脚本了。 接下来,在 Java 端调用这个脚本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| public class LuaTest { public static void main(String[] args) { Redis redis = new Redis(); for (int i = 0; i < 2; i++) { redis.execute(jedis -> { String value = UUID.randomUUID().toString(); String k1 = jedis.set("k1", value, new SetParams().nx().ex(5)); if (k1 != null && "OK".equals(k1)) { jedis.set("site", "www.javaboy.org"); String site = jedis.get("site"); System.out.println(site); jedis.evalsha("b8059ba43af6ffe8bed3db65bac35d452f8115d8", Arrays.asList("k1"), Arrays.asList(value)); } else { System.out.println("没拿到锁"); } }); } } }
|
一下代码是对随机数锁加lua脚本的场景考验代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| public class LockTest implements Runnable{
public static void main(String[] args) { Thread thread1 = new Thread(new LockTest()); thread1.start(); try { Thread.sleep(6000); } catch (InterruptedException e) { e.printStackTrace(); } Thread thread2 = new Thread(new LockTest()); thread2.start(); }
@Override public void run() { Redis redis=new Redis(); redis.execute(jedis->{ String value = UUID.randomUUID().toString(); String lock = jedis.set("lock", value, new SetParams().nx().ex(5L)); if ("OK".equals(lock)){ jedis.set("project",value); String v = jedis.get("project"); System.out.println("v = " + v);
Thread.sleep(6500); jedis.eval("if redis.call(\"get\",KEYS[1])==ARGV[1] then\n" + " return redis.call(\"del\",KEYS[1])\n" + " else\n" + " return 0\n" + " end",Arrays.asList("lock"),Arrays.asList(value)); }else { System.out.println("没有拿到锁"); } }); } }
|
我们发现线程一在完成业务操作后,休眠了8秒,然后等待lock过期
这是线程二启动发现lock已经过期,重新设置了lock,
0.5秒后线程一启动,执行lua脚本,但是线程一并没有杀掉lock,
lock还是以5秒到0秒慢慢消逝
本文章大部分来自作者
江南一点雨(松哥)【微信公众号江南一点雨】