一文了解分布式锁
2023-04-13 16:33:24
大多数互联网系统都是分布式部署的,确实可以提高性能和效率,但因此,我们需要解决分布式环境中的数据一致性问题。
当一个资源在多系统之间共享时,为了确保每个人访问的资源数据都是一致的,那么必须要求只有一个客户端同时处理,不能并发执行,否则会有人写有人同时阅读,每个人访问的数据都不一致。
为什么我们需要分布式锁?
在单机时代,虽然不需要分布式锁,但也面临着类似的问题,但在单机情况下,如果有多个线程同时访问共享资源,我们可以使用线程锁机制,即当线程获得资源时,立即锁定资源,当使用资源时,解锁,其他线程可以继续使用。例如,在JAVA中,一些API甚至提供了特殊的锁处理机制(synchronize/Lock等)
然而,在分布式系统时代,线程之间的锁定机制不起作用。系统可能有多个部件并部署在不同的机器上。这些资源不再在线程之间共享,而是在过程之间共享的资源。
因此,为了解决这个问题,我们必须介绍它「分布式锁」。
分布式锁是指在分布式部署环境下,通过锁机制访问多客户互斥的共享资源。
分布式锁应满足哪些要求?
排他性:只有一个客户端可以同时获得锁,其他客户端不能同时获得锁
避免锁:锁在有限的时间内,会被释放(正常释放或异常释放)
高可用性:获取或释放锁的机制必须高可用性和良好的性能
在完成背景和理论之后,让我们来看看分布式锁的具体分类和实际应用。
二、实现分布式锁的方法有哪些?
目前主流有三种,从实现的复杂性来看,难度从上到下依次增加:
基于数据库的实现
基于Redis的实现
实现基于Zookeper的实现
不管怎样,其实都不完美,还是要根据我们业务的实际情况来选择。
基于数据库的实现:
通常有两种方法可以基于数据库进行分布式锁定:
基于数据库的乐观锁
基于数据库的悲观锁
让我们来看看如何基于它「乐观锁」来实现:
乐观锁机制实际上是在数据库表中引入一个版本号(version)实现字段。
当我们想从数据库中读取数据时,我们也会读取这个version字段。如果我们想更新读取的数据并写回数据库,我们需要添加version1,并将新数据和新version更新到数据表中,我们必须在更新时检查当前数据库中的version值是否为以前的version。如果是,则正常更新。如果没有,则更新失败,表明在此过程中还有其他过程可以更新数据。
下面找图例,
如图所示,假设用户A和用户B必须在同一个账户中取款。账户原余额为2000,用户A取1500,用户B取1000。如果没有锁定机制,余额可能同时扣除1500和1000,导致最终余额不正确甚至负。但是,如果在这里使用乐观的锁定机制,当两个用户去数据库读取余额时,除了读取2000余额外,他们还读取了当前版本号version=1。当用户A或用户B修改数据库余额时,无论谁先操作,版本号都会增加1,即version=2,然后当另一个用户更新时,他们会发现版本号是错误的,已经变成2,当初读出来的时候不是1,那么这次更新失败了,就要重新读取最新的数据库余额。
从上面的例子可以看出,使用「乐观锁」必须满足机制:
(1)锁服务要有增加版本号version
(2)每次更新数据时,必须先判断版本号是否正确,然后写入新版本号
让我们来看看如何基于它「悲观锁」来实现:
悲观锁又称排他锁,基于Mysql for update 实现加锁,例如:///锁定方法-伪代码 public boolean lock(){ connection.setAutoCommit(false) for(){ result = select * from user where id = 100 for update; if(result){ //结果不是空的, ///说明获得了锁 return true; } ///没有得到锁,继续获取 sleep(1000); } return false; } //释放锁-伪代码 connection.commit();
在上面的例子中,id是user表中的主键,通过 for update 操作时,数据库会在查询此记录时添加排他锁。
(需要注意的是,只有在InnoDB中添加索引的字段才是行级锁,否则是表级锁,所以这个id字段应该添加索引)
当该记录加上排他锁时,其他线程无法操作此记录。
然后,在这种情况下,我们可以认为获得排他锁的线程有一个分布式锁,然后我们可以执行我们想要做的业务逻辑。逻辑完成后,我们可以调用上述释放锁的句子。
基于Redis的实现
基于Redis实现的锁定机制主要依靠Redis自身的原子操作,如: SET user_key user_value NX PX 100
redis从2.6开始.SET命令从12版开始支持这些参数:
NX:只有当键不存在时,才能设置键,SET key value NX 效果等同于 SETNX key value
PX millisecond:设置键的过期时间为millisecondm秒。当超过此时间时,设置键将自动失效
上述代码示例是指当redis中没有user_key键时,将设置user_key键,并将键的值设置为 user_value,而且这个键的存活时间是100ms
为什么这个命令可以帮助我们实现锁定机制?
因为这个命令只有在key不存在的时候才能成功执行。然后,当多个过程同时设置相同的key时,总会有一个过程成功。
当一个过程设置成功时,您可以执行业务逻辑,然后在业务逻辑完成后解锁。
解锁很简单,只需删除这个key即可,但在删除之前需要判断,这个key对应的value是原来自己设置的那个。
此外,redis集群模式的分布式锁可以采用redis的redlock机制。
实现基于Zookeper的实现
事实上,基于Zookeeper,是利用其临时有序节点实现的分布式锁。
原理是:当客户端需要逻辑锁定时,在zookeeper上的指定节点目录下生成唯一的临时有序节点, 然后判断自己是否是这些有序节点中最小的序号。如果是这样,它将被视为获得锁。如果没有,则表示没有锁,则需要在序列中找到比自己小的节点,并调用exist()方法监控其注册事件。当监控器听到节点被删除时,再次判断其创建的节点是否成为序列中最小的节点。如果是,获得锁,如果没有,重复上述步骤。
释放锁时,只需删除此临时节点即可。
如图所示,locker是一个持久节点,node_1/node_2/…/node_n 由客户端client创建的临时节点。
client_1/client_2/…/clien_n 都是想要获得锁的客户端。以client_1为例,要想获得分布式锁,需要跑到locker下创建临时节点(如果是node_1),看看自己的节点序号是否是locker下面最小的,如果是,就可以获得锁。如果没有,找到比自己小的节点(如果是node_2),找到后监控node_2,直到node_2被删除,然后开始判断你的node_1是否是序列中最小的。如果是,获得锁。如果没有,继续找节点。
以上是为什么我们需要分布式锁技术,以及分布式锁中常见的三种机制。