一、背景

在分布式架构下,CAP不能同时满足,一般互联网应用都是通过最终一致甚至弱一致(不保证系统中的数据的一致性)这种方式来牺牲一致性,换取高可用性:完全不访问:(提高系统的SLA),访问很卡(提高qps)。
导致一致性问题可两种原因:

  1. 一个请求需要更新两份数据且存在不同系统中,A系统数据更新成功,B系统数据更新失败,导致AB系统不一致。这种需要通过分布式事务来解决。
  2. 一份共享数据,A服务器(进程)获取到进行修改,B服务器(进程)获取到进行修改,最终修改结果和预想的不一样,这种分布式锁来解决。

二、概念

很显然,分布式锁和单机锁(synchronized)的概念很一致,前者是针对线程并发,后者是针对进程并发。
关于单机锁,有以下设计要点,

  1. 控制并发,同一时间只有一个线程能获取执行权力。
  2. 可重入
  3. 可选择阻塞或非阻塞。
  4. 可自动释放(synchronized和Lock都有各自的自动释放机制)

此外除了需要继承单机锁的所有要素之外,分布式锁独有的设计

  1. 高可用(因为单机锁用的是操作系统底层的监视器能力或CAS能力,只要单机不宕机就没有高可用的问题)
  2. 高性能(同上逻辑,没有性能问题)

分布式锁存在的问题?
在获取分布式锁之后,调用解锁锁之前的这段时间,如果服务器挂了,一般有如下机制来保证不会死锁:

  1. redis提供了超时时间机制
  2. zk提供了断连删除节点机制

考虑这种情况,如果通过redis加锁后,执行了某种耗时的操作(FullGC、IO、复杂计算),导致执行时间大于设置的超时时间,这时候锁会自动释放,所以另外一个进程获取到锁,此时,原线程刚好执行完执行删锁操作,那么就会导致删除掉后面一个进程的锁。怎么解决:

  1. 设置评估锁范围内的操作耗时情况,设置适当的超时时间。
  2. 当前线程在获取锁的时候通过setNx指令同时设置value,解锁时判断value是否是自己设定的,如果是则继续,如果不是则认为锁已经释放切被其他线程占用,无需操作。
  3. 使用zk分布式锁,因为zk方式只要没有主动释放锁或服务器没有挂掉,那么就不会出现这种情况。

三、方案

分布式锁设计的本质是,要有一个中间件可以存储一个共享标记,可以被不同进程获取和设置,有如下方式:

  1. mysql
  2. redis(memcache)
  3. zk | | mysql | redis | zookeeper | | —- | —- | —- | —- | | 并发控制 | √ | √ | √ | | 可重入 | √ | √ | √ | | 非阻塞 | √ | √ | √ | | 可自动释放 | × | √ | √ | | 高可用 | √ | √ | √ | | 高性能 | × | √ | √ |

3.1 redis实现

  1. public interface DistributeLock{
  2. /**
  3. * 阻塞锁
  4. **/
  5. void lock(String uniqueKey);
  6. /**
  7. * 非阻塞锁
  8. **/
  9. boolean tryLock(String uniqueKey, int expireTime);
  10. /**
  11. * 阻塞锁
  12. **/
  13. void unlock(String uniqueKey);
  14. }
  15. public class RedisDistributeLock{
  16. private RedisTemplate redisTemplate;
  17. @Override
  18. public void lock(String uniqueKey) {
  19. int cnt = 0;
  20. while(cnt++ < 100){
  21. if(redisTemplate.setNX(uniqueKey, "1") == 1){
  22. break;
  23. }
  24. Thread.sleep(100 * cnt);
  25. }
  26. }
  27. @Override
  28. public boolean tryLock(String uniqueKey, int expireTime) {
  29. String expireDate = DateUtil.add(now(), expireTime);
  30. while(now().before(expireDate)){
  31. if(redisTemplate.setNX(uniqueKey, "1") == 1){
  32. return true;
  33. }
  34. }
  35. return false
  36. }
  37. @Override
  38. public void unlock(String uniqueKey) {
  39. redisTemplate.del(uniqueKey);
  40. }
  41. }

3.2 zk实现

有两种实现方式:

  1. zk原理1:节点下的子节点命名有唯一性,比如创建/lock节点后,其他节点无法创建同路径节点。
    1. 其他节点在无法创建之后,会添加一个监听器在该节点上,节点删除之后,客户端会收到通知,会再次竞争锁。如果有大量客户端处于这种监听状态,一旦锁释放,那么就会导致惊群现象,是一个计算机普遍的概念,不止zk,包括linux、nginx等层面也有惊群现象,惊群现象带来的问题就是会瞬间耗费大量cpu资源、影响系统稳定性。
  2. zk原理2:临时有序类型的节点,临时可以会话丢失自动删除节点,有序可以用于判断是否最小,从而判断是否获取到锁,所以总结一下,包括redis.setNX方式,是否获取到锁的方式都判断是否获取某个唯一性资源。
    1. 线程1创建/lock/node_001,线程2创建/lock/node_002,并给前一个节点添加监听器(防止惊群)…
    2. 每个线程在创建完node之后,会判断自己是否最小节点(zk并没有提供获取最小节点的能力,但是提供了获取父节点下所有子节点的接口,那么就可以对所有子节点进行排序,其实就是节点路径(string)的字典序排序,排序后判断自己是否是首节点即可),如果是就拿到锁,如果不是就等待通知。