在分布式场景下,有很多种情况都需要实现最终一致性。在设计远程上下文的领域事件的时候,为了保证最终一致性,在通过领域事件进行通讯的方式中,可以共享存储(领域模型和消息的持久化数据源),或者做全局XA事务(两阶段提交,数据源可分开),也可以借助消息中间件(消费者处理需要能幂等)。通过Observer模式来发布领域事件可以提供很好的高并发性能,并且事件存储也能追溯更小粒度的事件数据,使各个应用系统拥有更好的自治性。 

 

本文主要探讨了一种实现分布式最终一致性的解决方案——采用分布式锁。基于分布式锁的解决方案,比如zookeeper,redis都是相较于持久化(如利用InnoDB行锁,或事务,或version乐观锁)方案提供了高可用性,并且支持丰富化的使用场景。 本文通过Java版本的redis分布式锁开源框架——Redisson来解析一下实现分布式锁的思路。

 

Redis本身支持的事务


MULTI 、 EXEC 、 DISCARD 和 WATCH 是 Redis 事务的基础,MULTI, EXEC, DISCARD 和 WATCH 命令是Redis事务的基石。一个Redis事务允许一组Redis命令单步执行,并提供下面两个重要保证:一个事务中的所有命令串行执行;要么全部命令要么没有任何命令被处理。具体可参开这篇文章:http://blog.xiping.me/2010/12/transaction-in-redis.html。

 

事务可以一次执行多个命令, 并且带有以下两个重要的保证:

 

  1. 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  2. 事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。

 

EXEC 命令负责触发并执行事务中的所有命令:

 

如果客户端在使用 MULTI 开启了一个事务之后,却因为断线而没有成功执行 EXEC ,那么事务中的所有命令都不会被执行。另一方面,如果客户端成功在开启事务之后执行 EXEC ,那么事务中的所有命令都会被执行。当使用 AOF 方式做持久化的时候, Redis 会使用单个 write(2) 命令将事务写入到磁盘中。然而,如果 Redis 服务器因为某些原因被管理员杀死,或者遇上某种硬件故障,那么可能只有部分事务命令会被成功写入到磁盘中。如果 Redis 在重新启动时发现 AOF 文件出了这样的问题,那么它会退出,并汇报一个错误。

 

自己实现Redis分布式锁

 

根据一些分布式锁相关的文档,开始动手根据redis提供的原子操作进行实现:

 


 

在其中主要用到了Redis中的两条原子命令:

 

1.SETNX key value

 

Java代码  


1. Available since 1.0.0.  
2. Time complexity: O(1)  
3. Set key to hold string value if key does not exist. In that case, it is equal to SET. When key already holds a value, no operation is performed. SETNX is short for "SET if Not eXists".  
4. Return value  
5. Integer reply, specifically:  
6. 1 if the key was set  
7. 0 if the key was not set

 

 

2.GETSET key value

 

Java代码  

1. 起始版本:1.0.0
2. 时间复杂度:O(1)  
3.    
4. 自动将key对应到value并且返回原来key对应的value,返回之前的旧值,如果之前Key不存在将返回nil。

 

 

其中有一节:GETSET的妙用,其中有一段:

 

Java代码  

1. 上一个经验虽说可以解决这条数据该“插入还是更新”的问题,但需要知道当前操作是否针对某数据的首次操作的需求还不少。例如我的程序会在不同时间接收到同一条消息的不同分片信息包,我需要在收到该消息的首个信息包(发送是无序的)时做些特殊处理。  
2.    
3. 早些时候的做法是为消息在MongoDB维护一个状态对象,有信息包来的时候就走“上锁->检查消息状态->根据状态决定做不做特殊操作->解锁” 这个流程,虽然同事已经把锁的粒度控制得非常细了,但有锁的程序遇上多实例部署就歇了。  
4.    
5. Redis的GETSET是解决这个问题的终极武器,只需要把当前信息包的唯一标识对指定的状态属性进行一次GETSET操作,再判断返回值是否为空则知道是否首次操作。GETSET替我们把两次读写的操作封装成了原子操作。

 

 

实现逻辑

 

获取redis针对某个锁的时候,需要根据lockKey区进行setnx(set not exist,顾名思义,如果key值为空,则正常设置,返回1,否则不会进行设置并返回0)操作,如果设置成功,表示已经获得锁,否则并没有获取锁。

 

如果没有获得锁,去Redis上拿到该key对应的值,在该key上我们存储一个时间戳(用毫秒表示,t1),为了避免死锁以及其他客户端占用该锁超过一定时间(5秒),使用该客户端当前时间戳,与存储的时间戳作比较。


如果没有超过该key的使用时限,返回false,表示其他人正在占用该key,不能强制使用;如果已经超过时限,那我们就可以进行解锁,使用我们的时间戳来代替该字段的值。使用getset来设置该字段的值,getset可以返回设置完成之前的值t2,如果t2!=t1,说明在getset之前已经有其他线程已经设置了该字段,事实上,我们还是没有获得该锁(已经被其他人抢占)。但是这会造成一定的数据错误,因为我们已经用getset设置了一个新的时间戳(并不是抢占该锁的客户端设置的时间戳),所幸这样操作的误差比较小可以忽略不计。

 

但是如果在setnx失败后,get该值却无法拿到该字段时,说明在我们操作之前该锁已经被释放,这个时候,最好的办法就是重新执行一遍setnx方法来获取其值以获得该锁。当然,这仍然可能失败,但失败的逻辑与之前的相同。虽然出现这种情况非常少见,但是为了避免每次都出现导致StackOverflowError的错误,设置调用层次。

 

 

Java代码  

1. public static final int ACQUIRE_LOCK_MAX_ATTEMPTS = 10;  
2.     public static final long EXPIRE_TIME = 5000L;  
3.    
4. /**
5.      * 基于时间戳根据lockKey尝试获取锁,需要与releaseLock成对使用;
6.      * <p/>使用该方法的前提是必须要保证服务器之间时间同步
7.      * <p/>如果持有锁的时间超过 #{EXPIRE_TIME},视为超时,其他客户端可以对其进行重新获取锁的操作
8.      *
9.      * @param lockKey - 锁键值,即争夺的资源
10.      * @return - 当成功获取锁后,返回true,否则返回false;如果没有获取锁,需要客户端进行轮询来尝试获取
11.      */
12.     public boolean acquireLock(String lockKey) {  
13.         return acquireLock(lockKey, 0);  
14.     }  
15.    
16.     private boolean acquireLock(String lockKey, int depth) {  
17.         long setnx = jedis.setnx(lockKey, String.valueOf(System.currentTimeMillis()));  
18.         if (setnx == 1L) {  
19. //说明客户端已经获得锁
20. "lock key : %s is acquired!", lockKey));  
21.             return true;  
22.         } else {  
23. //此时,该lockKey已经被其他客户端加锁
24.             String keyTimestamp = jedis.get(lockKey);  
25.             if (keyTimestamp == null) {  
26. //如果该值已经被清空,就尝试去重新获取
27.                 if (depth == ACQUIRE_LOCK_MAX_ATTEMPTS) {  
28. //如果尝试次数超过10次,则不再尝试,直接返回false
29. "lock key : %s exceed max attemps: %s, quit!", lockKey,  
30.                             ACQUIRE_LOCK_MAX_ATTEMPTS));  
31.                     return false;  
32.                 }  
33.                 return acquireLock(lockKey, depth + 1);  
34.             }  
35.    
36.             long intervalTime = System.currentTimeMillis() - Long.valueOf(keyTimestamp);  
37.             if (intervalTime < EXPIRE_TIME) {  
38. //锁在一定时间内并没有超时,获取锁失败
39. "lock key : %s acquire failed! other client persist this lock!", lockKey);  
40.                 return false;  
41.             } else {  
42. //锁已经超时,尝试执行getset操作,设置当前时间戳
43.                 String getSetTimestamp = jedis.getSet(lockKey, String.valueOf(System.currentTimeMillis()));  
44.                 if (getSetTimestamp == null) {  
45. //考虑非常特殊的情况,有人释放了锁执行del操作
46. //此时get/set拿到的是nil值,说明已经获得了锁
47. "lock key : %s is acquired! GETSET returns null!", lockKey));  
48.                     return true;  
49.                 }  
50.                 if (!getSetTimestamp.equals(keyTimestamp)) {  
51. //在设置时,说明该锁已经被其他client加上
52. //此时会有对应的副作用,比如
53. "lock key : %s acquire failed! other client acquire this lock!", lockKey);  
54.                     return false;  
55.                 } else {  
56. //锁已更新,可以正常返回
57. "lock key : %s is acquired! origin client is time out!", lockKey));  
58.                     return true;  
59.                 }  
60.             }  
61.         }  
62.     }

 

释放锁的过程相对来说比较简单,但是我们采用这种方式的话,不能控制什么时候该释放锁,当然也可以采用回调的方式来实现默认释放掉锁,以便于控制释放过程。

 

   

Java代码  

1. /**
2.     * 释放lockKey对应的锁,注意需要与acquireLock成对使用
3.     * <p/>不能随意对其他人使用的锁进行释放操作
4.     *
5.     * @param lockKey
6.     * @return - 如果释放成功返回true
7.     */
8.    public boolean releaseLock(String lockKey) {  
9.        boolean result = jedis.del(lockKey) == 1L;  
10. "lock key: %s is released!", lockKey));  
11.        return result;  
12.    }

 

 

但这套毕竟是自己实现的,还会有很多漏洞,在github发现了一套开源的实现:https://github.com/mrniko/redisson/wiki,可以对其进行深入研究并应用到我们的系统中,这样系统会更加健壮。

 

在简单对其使用Jmeter进行性能测试,发现库存控制比较好,能够满足实际需求,但测试过程中也遇到了一些问题。

 

我们如果使用这种方式,能否让没有拿到锁的线程能够及时收到通知,重新连接?

 

对于使用的JedisClient时,不能每次都使用同一个实例,需要在必要的情况化对其执行回收。 

http://stackoverflow.com/questions/32429981/jedis-broken-pipe-exception

 

Java代码  

1. 四月 28, 2016 5:03:18
2. 严重: Servlet.service() for servlet [springmvc] in context with path [] threw exception [Request processing failed; nested exception is redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketException: Broken pipe] with root cause  
3. java.net.SocketException: Broken pipe  
4.     at java.net.SocketOutputStream.socketWrite0(Native Method)  
5. 109)  
6. 153)  
7. 52)  
8. 213)  
9. 288)  
10. 214)  
11. 205)  
12. 101)  
13. 52)  
14.     at sun.reflect.GeneratedMethodAccessor25.invoke(Unknown Source)  
15. 43)  
16. 483)  
17. 221)  
18. 137)  
19. 110)  
20. 777)  
21. 706)  
22. 85)  
23. 943)  
24. 877)  
25. 966)  
26. 857)  
27. 620)  
28. 842)  
29. 727)  
30. 303)  
31. 208)  
32. 52)  
33. 241)  
34. 208)  
35. 88)  
36. 107)  
37. 241)  
38. 208)  
39. 220)  
40. 122)  
41. 501)  
42. 171)  
43. 102)  
44. 950)  
45. 116)  
46. 408)  
47. 1040)  
48. 607)  
49. 314)  
50. 1142)  
51. 617)  
52. 61)  
53. 745)

 

 

此时就需要使用JedisPool来获取资源,可以避免出现这个问题,注意在最后要回收资源:

 

Java代码  

1. Jedis jedis = jedisPool.getResource();  
2.         try {  
3.             while (true) {  
4. "product");  
5.                 if (Integer.parseInt(productCountString) > 0) {  
6.                     if (acquireLock(jedis, "abc")) {  
7.                         int productCount = Integer.parseInt(jedis.get("product"));  
8. "%tT --- Get product: %s", new Date(), productCount));  
9. //                        System.out.println(productCount);
10. "product");  
11. "abc");  
12.                         return "Success";  
13.                     }  
14.                     Thread.sleep(1000L);  
15.                 } else {  
16.                     return "Over";  
17.                 }  
18.             }  
19.         } finally {  
20.             jedis.close();  
21.         }