我们在编程有很多场景使用本地锁和分布式锁,但是是否考虑这些锁的原理是什么?本篇讨论下实现分布式锁的常见办法及他们实现原理。
使用本地锁和分布式锁是为了解决并发导致脏数据的场景,使用锁的最高境界是通过流程设计避免使用锁,锁会牺牲掉系统性能为代价的。
分布式锁总结:
产品性能:redis>zookeeper>mysql,获取锁成功率:mysql悲观>zk>redis
锁实现 | 实现方式 | 性能 | 选型注意 | 选择关注点 |
---|---|---|---|---|
mysql | 乐观锁 | 好 | 并发场景锁无效 | 低成本实现 |
悲观锁 | 差 | 可能导致锁表 | 极端场景 | |
zk | 顺序节点 | 中 | 性能、可靠一般 | 性能、可靠的兼顾选择 |
redis | setNx | 低 | 锁没有唯一标示 | 简单但不推荐 |
lua脚本 | 最高 | 用不好效果更差 | 大神选用不是大神用redisson | |
redisson | 中高 | 平衡做的好 | 关注性能 |
通过在mysql加入version或者updatetime时间戳的方式实现。下面主要介绍新增流程,修改的更简单不做介绍。乐观锁实现出现事务回滚的时候要处理掉新增场景中无效数据。乐观锁其实并没有用到锁的概念,其实他是一个版本同步的技巧实现。
**version-新增实现:**在数据库新增的时候先新增一条数据,然后读取这个数据返回给修改页面,当修改页面提交的时候version对比如果一致说明数据合法,如果不一致说明数据不合法。然后version自增写入数据库。
**updatetime-新增实现:**同version规则一致。
乐观锁无法解决并发多线程问题,这种适合解决并发比较低的场景的数据库数据一致性问题。
/** * 功能:zk - zk Curator客户端 - 实现分布式锁测试 * 作者:丁志超 */ public class ZkCuratorLock{ //实例化客户端 private static RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000,3); private static CuratorFramework client = CuratorFrameworkFactory.builder() .connectString("ip:2181") .sessionTimeoutMs(3000) .connectionTimeoutMs(5000) .retryPolicy(retryPolicy) .build(); //zk分布式锁创建节点在零时目录zklock下创建 static String lockPath = "/zklock"; //实例化分布式锁 final static InterProcessLock lock = new InterProcessSemaphoreMutex(client, lockPath); public static void main(String[] args) { //获取锁 try { lock.acquire(); } catch (Exception e) { // TODO Auto-generated catch block e.printStackTrace(); }finally { //释放锁 try { lock.release(); } catch (Exception e) { } } } } <!-- zookeeper curator客户端 --> <dependency> <groupId>org.apache.curator</groupId> <artifactId>curator-recipes</artifactId> <version>2.12.0</version> </dependency>
由于节点的临时属性,如果创建znode的那个客户端崩溃了,那么相应的znode会被自动删除。这样就避免了设置过期时间的问题,Curator客户端是基于顺序节点实现的分布式锁。
由于zookeeper是基于Ha方式部署的,其写全部在主节点进行,只要主节点服务正常就不会出现脑裂问题。
由于zookeeper客户端与服务端session保持会话需要心跳机制保证。如果客户端或者服务端GC导致心跳超时,此时零时节点会被zookeeper全部踢掉。zookeeper的零时节点绑定在会话session上面,session不存在客户端创建的零时节点全部会被删除。
//1、Curator锁数据结构 private static class LockData { //当前拥有锁的线程 final Thread owningThread; //当前锁的路径 final String lockPath; //锁计数器 final AtomicInteger lockCount = new AtomicInteger(1); } //2、这段是Curator的精华 - 获取锁成功后验证 及 获取锁失败后等待 private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception { boolean haveTheLock = false; boolean doDelete = false; try { if ( revocable.get() != null ) { client.getData().usingWatcher(revocableWatcher).forPath(ourPath); } while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock ) { List<String> children = getSortedChildren(); String sequenceNodeName = ourPath.substring(basePath.length() + 1); // +1 to include the slash //成功获取锁 PredicateResults predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases); if ( predicateResults.getsTheLock() ) { haveTheLock = true; } else { //没有获取锁,监听拥有锁节点的变化 String previousSequencePath = basePath + "/" + predicateResults.getPathToWatch(); //获取锁失败后等待超时如果不设置超时一直等待 synchronized(this) { try { // use getData() instead of exists() to avoid leaving unneeded watchers which is a type of resource leak client.getData().usingWatcher(watcher).forPath(previousSequencePath); if ( millisToWait != null ) { millisToWait -= (System.currentTimeMillis() - startMillis); startMillis = System.currentTimeMillis(); if ( millisToWait <= 0 ) { doDelete = true; // timed out - delete our node break; } wait(millisToWait); } else { wait(); } } catch ( KeeperException.NoNodeException e ) { // it has been deleted (i.e. lock released). Try to acquire again } } } } } catch ( Exception e ) { ThreadUtils.checkInterrupted(e); doDelete = true; throw e; } finally { if ( doDelete ) { deleteOurPath(ourPath); } } return haveTheLock; } //3、获取锁算法思想 - maxLeases默认值是1,要求获取锁的线程永远是list的第一个线程,保证获取锁顺序性 public PredicateResults getsTheLock(CuratorFramework client, List<String> children, String sequenceNodeName, int maxLeases) throws Exception { int ourIndex = children.indexOf(sequenceNodeName); validateOurIndex(sequenceNodeName, ourIndex); boolean getsTheLock = ourIndex < maxLeases; String pathToWatch = getsTheLock ? null : children.get(ourIndex - maxLeases); return new PredicateResults(pathToWatch, getsTheLock); }
//setNx实现分布式锁 public class SetNxLock { public static void main(String[] args) { Jedis jedis = new Jedis("localhost"); jedis.setnx("key", "value"); try { if(jedis.exists("key")) { jedis.expire("key", 10); System.out.println("我获取了锁,干点活!"); jedis.del("key"); } } catch (Exception e) { } finally { jedis.del("key"); } } }
/** * 功能:redis - lua - lua实现分布式锁 使用redis.clients客户端 * 作者:丁志超 */ public class LuaLock { public static void main(String[] args) { lock("122333", "33331","10000" ); unlock("122333", "33331"); } /** * 加锁语法 * key:redis key * value:redis value * time: redis timeouts 锁过期时间一般大于最耗时的业务消耗的时间 * 语法参考文档:https://www.runoob.com/redis/redis-scripting.html * */ public static String lock(String key, String value,String timeOut ) { /** * -- 加锁脚本,其中KEYS[]为外部传入参数 * -- KEYS[1]表示key * -- ARGV[1]表示value * -- ARGV[2]表示过期时间 */ String lua_getlock_script = "if redis.call('SETNX','"+key+"','"+value+"') == 1 then" + " return redis.call('pexpire','"+key+"','"+timeOut+"')" + " else" + " return 0 " + "end"; Jedis jedis = new Jedis("localhost"); //在缓存中添加脚本但不执行 String scriptId = jedis.scriptLoad(lua_getlock_script); //查询脚本是否添加 Boolean isExists = jedis.scriptExists(scriptId); //执行脚本 返回1表示成功,返回0表示失败 Object num = jedis.eval(lua_getlock_script);; return String.valueOf(num); } /** * 释放锁语法 * key:redis key * value:redis value * time: redis timeouts 锁过期时间一般大于最耗时的业务消耗的时间 * 语法参考文档:https://www.runoob.com/redis/redis-scripting.html * */ public static String unlock(String key, String value ) { /** * -- 加锁脚本,其中KEYS[]为外部传入参数 * -- KEYS[1]表示key * -- ARGV[1]表示value * -- ARGV[2]表示过期时间 */ String lua_unlock_script = "if redis.call('get','"+key+"') == '"+value+"' then " + " return redis.call('del','"+key+"') " + "else return 0 " + "end"; Jedis jedis = new Jedis("localhost"); //在缓存中添加脚本但不执行 String scriptId = jedis.scriptLoad(lua_unlock_script); //查询脚本是否添加 Boolean isExists = jedis.scriptExists(scriptId); //执行脚本 返回1表示成功,返回0表示失败 Object num = jedis.eval(lua_unlock_script);; return String.valueOf(num); } }
/** * 功能:redis - Redisson - Redisson实现分布式锁 * 作者:丁志超 */ public class RedissonLock { public static void main(String[] args) { Config config = new Config(); config.useSingleServer().setAddress("localhost"); RedissonClient redissonClient = Redisson.create(config); RLock rLock = redissonClient.getLock("key"); try { rLock.tryLock(10, TimeUnit.SECONDS); System.out.println("我获取了锁,该我干活了。"); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); }finally { if(rLock.isLocked()) { rLock.unlock(); } } } }
**setNx线程模型:**setNx线程模型必须是单线程(包括接收、解析、处理线程),才能保证其没有并发问题。这个猜想没有找到源码及相关文档的证明。redis线程模型文章
SET resource_name my_random_value NX PX 30000
**lua脚本:**lua脚本其实就是一个类似于mysql的存储过程,其最大的意义是降低网络交互。性能要优于单独使用redis命令。
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
//redisson锁实体数据结构 public class RedissonLockEntry { //计数器 private int counter; //信号类 控制多少线程同时获取锁 private final Semaphore latch; private final RPromise<RedissonLockEntry> promise; //线程队列 private final ConcurrentLinkedQueue<Runnable> listeners = new ConcurrentLinkedQueue<Runnable>(); } //源码类位置 redisson RedissonLock.class //redisson实现分布式锁源码解析 public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { long time = unit.toMillis(waitTime); long current = System.currentTimeMillis(); long threadId = Thread.currentThread().getId(); //尝试获取锁其实现见后面的方法 Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId); // lock acquired if (ttl == null) { return true; } //如果锁超时直接返回失败 time -= System.currentTimeMillis() - current; if (time <= 0) { acquireFailed(waitTime, unit, threadId); return false; } current = System.currentTimeMillis(); //通过线程ID获取锁结构体 RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId); if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) { if (!subscribeFuture.cancel(false)) { subscribeFuture.onComplete((res, e) -> { if (e == null) { unsubscribe(subscribeFuture, threadId); } }); } acquireFailed(waitTime, unit, threadId); return false; } try { //再次检查锁是否超时,如果超时释放锁 time -= System.currentTimeMillis() - current; if (time <= 0) { acquireFailed(waitTime, unit, threadId); return false; } //在非超时周期内通过自旋方式获取锁 while (true) { long currentTime = System.currentTimeMillis(); ttl = tryAcquire(waitTime, leaseTime, unit, threadId); // lock acquired if (ttl == null) { return true; } //超时释放锁 time -= System.currentTimeMillis() - currentTime; if (time <= 0) { acquireFailed(waitTime, unit, threadId); return false; } // waiting for message currentTime = System.currentTimeMillis(); if (ttl >= 0 && ttl < time) { subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); } else { subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS); } time -= System.currentTimeMillis() - currentTime; if (time <= 0) { acquireFailed(waitTime, unit, threadId); return false; } } } finally { unsubscribe(subscribeFuture, threadId); } // return get(tryLockAsync(waitTime, leaseTime, unit)); } //redisson获取锁最底层实现,使用lua脚本实现,如果有key返回key的剩余生命时间 <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) { return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command, //若 key 存在返回 1 ,否则返回 0 "if (redis.call('exists', KEYS[1]) == 0) then " + //给key增加超时时间 "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + //设置key的生命周期 "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + //查询key存在 "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + "return redis.call('pttl', KEYS[1]);", Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId)); } //释放锁 @Override protected RFuture<Void> acquireFailedAsync(long waitTime, TimeUnit unit, long threadId) { long wait = threadWaitTime; if (waitTime != -1) { wait = unit.toMillis(waitTime); } //这块看的有点懵,等学习lua在看 return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_VOID, // 移除list超时的key及对应的线程 "local queue = redis.call('lrange', KEYS[1], 0, -1);" + // find the location in the queue where the thread is "local i = 1;" + "while i <= #queue and queue[i] ~= ARGV[1] do " + "i = i + 1;" + "end;" + // go to the next index which will exist after the current thread is removed "i = i + 1;" + // decrement the timeout for the rest of the queue after the thread being removed "while i <= #queue do " + "redis.call('zincrby', KEYS[2], -tonumber(ARGV[2]), queue[i]);" + "i = i + 1;" + "end;" + // remove the thread from the queue and timeouts set //移除超时的线程 "redis.call('zrem', KEYS[2], ARGV[1]);" + "redis.call('lrem', KEYS[1], 0, ARGV[1]);", Arrays.<Object>asList(threadsQueueName, timeoutSetName), getLockName(threadId), wait); }
上面介绍的setNx、lua实现分布式锁都是基于单节点的redis本地实现方式,只要解决好本地的线程并发问题即可。当时当redis集群中使用分布式锁怎么办?redis集群实现分布式锁基于redlock算法,下面介绍其实现原理及缺陷。
在算法的分布式版本中,我们假设我们有N个 Redis 主节点,这些节点是完全独立的。上面已经介绍单个节点如何获取释放锁。那么在集群中每台独立的redis也会使用这种方式获取释放锁。我们假设5台redis节点,这个数值不是固定的是业务需要的选择。
**总结:**上面是redis官方介绍的,为了保证原汁原味我给翻译下了,下面按照我理解的总结。红色部分是我对官方理解不一致的地方。
算法安全吗?我们可以尝试了解在不同场景中会发生什么。
首先让我们假设客户端能够在大多数情况下获取锁。所有实例都将包含一个具有相同生存时间的密钥。但是,密钥是在不同的时间设置的,因此密钥也会在不同的时间到期。但是如果第一个键在时间 T1(我们在联系第一台服务器之前采样的时间)设置为最差,而最后一个键在时间 T2(我们从最后一个服务器获得回复的时间)设置为最差,我们肯定集合中第一个过期的键至少会存在MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT。所有其他密钥将在稍后到期,因此我们确信至少这次密钥将同时设置。
在设置了大部分键的期间,另一个客户端将无法获取锁,因为如果 N/2+1 个键已经存在,则 N/2+1 SET NX 操作将无法成功。因此,如果获取了锁,则不可能同时重新获取它(违反互斥属性)。
但是,我们还希望确保多个客户端同时尝试获取锁不能同时成功。
如果客户端使用接近或大于锁最大有效时间(我们基本用于 SET 的 TTL)的时间锁定了大多数实例,它会认为锁无效并解锁实例,因此我们只需要考虑客户端能够在小于有效时间的时间内锁定大多数实例的情况。在这种情况下,对于上面已经表达的参数,MIN_VALIDITY没有客户端应该能够重新获取锁。因此,只有当锁定多数的时间大于 TTL 时间时,多个客户端才能同时锁定 N/2+1 个实例(“时间”为第 2 步的结束),从而使锁定无效。
**总结:**上面是redis官方对redlok如何保证安全的理解,我这边做个自己理解的注释。
想说的就是这个算法MIN_VALIDITY=TTL-(T2-T1)-CLOCK_DRIFT,节点T2-T1这个变量就是为了屏蔽掉集群节点的时间差异,如果得到的结果生命周期小于0或者无法获取N/2+1个节点就认为获取锁失败了。
我认为 Redlock 算法是一个糟糕的选择,因为它“既不是鱼也不是家禽”:它对于效率优化锁来说是不必要的重量级和昂贵的,但是对于正确性取决于锁的情况来说它不够安全。
特别是,该算法对时序和系统时钟做出了危险的假设(基本上假设同步系统具有有限的网络延迟和有限的操作执行时间),如果不满足这些假设,则会违反安全属性。此外,它缺乏生成防护令牌的设施(保护系统免受网络长时间延迟或暂停进程的影响)。
如果您只需要尽力而为的锁定(作为效率优化,而不是为了正确性),我建议坚持使用简单的 Redis单节点锁定算法(条件设置如果不存在以获得锁定, atomic delete-if-value-matches 以释放锁),并在您的代码中非常清楚地记录锁只是近似值,有时可能会失败。不要费心设置五个 Redis 节点的集群。
另一方面,如果您需要锁定以确保正确性,请不要使用 Redlock。相反,请使用适当的共识系统,例如ZooKeeper,可能通过 实现锁的Curator 配方之一。(至少,使用具有合理事务保证的数据库。)并且请在锁定下的所有资源访问中强制使用防护令牌。
正如我开头所说的,Redis 是一个很好的工具,如果你使用得当。以上都不会削弱 Redis 对其预期目的的有用性。Salvatore多年来一直致力于该项目,其成功当之无愧。但是每个工具都有局限性,了解它们并相应地进行计划很重要。
**总结:**马丁·克莱普曼说的也对,比如redis在5台机器中3台已经获取锁,但是其中3台有一台挂了,然后重启redis还是认为成功获取锁。redlock是一种分布式场景解决一致性的方案,其也受制CAP理论。其保证AP可用性、分区容错性。redis是最大的最求性能,这是他一直信奉的原则,redlock在极苛刻的条件下出现问题。但是不代表其不科学,zk也有自己丢数据的场景,mysql一样也有自己丢数据的场景。关键是这个事情发生的概率,所以我个人观点是不怎么支持马丁·克莱普曼的观点。
光说不练说明没有理解,光练不说说明没有系统的看全面,最后给各类分布式锁性能做一个测试,由于受制于测试环境资源,我们测试的值可能和你们的不一样,但是我们追求的是测试方法的科学性而不是绝对值。本次测试用本地单体应用,实际生产环境多个节点要结合自己的环境特点测试。测试代码及压测脚本
测试方式:
1.单应用-本地部署(I58G mac)一套应用jmeter压测这个应用
2.redis master集群-4核心8G3节点
3.zk集群 - 4核心8G*3节点
锁实现 | 集群方式 | 实现方式 | 获取锁成功率 | TPS | 取样次数 |
---|---|---|---|---|---|
mysql | 乐观锁 | 待测 | 待测 | 1-3万次 | |
悲观锁 | 待测 | 待测 | 1-3万次 | ||
zk | 单节点 | curator | 100% | 1066 | 1-3万次 |
集群 | curator | 100% | 50-70 | 1-3万次 | |
redis | cluster | setNx | 100% | 100-120 | 1-3万次 |
lua脚本 | 100% | 200 | 1-3万次 | ||
redisson | 50-100% | 1100 | 1-10万次 | ||
哨兵 | setNx | 待测 | 待测 | 1-3万次 | |
lua脚本 | 待测 | 待测 | 1-3万次 | ||
redisson | 待测 | 待测 | 1-3万次 | ||
单节点 | setNx | 待测 | 待测 | 1-3万次 | |
lua脚本 | 待测 | 待测 | 1-3万次 | ||
redisson | 待测 | 待测 | 1-3万次 |
3.1、redis分布式锁实现原理官方文档
3.2、马丁·克莱普曼对redlock质疑