这是我参与「第五届青训营」笔记创作活动的第17天
人生没有白走的路,每一步它都算数—考研政治老师孔昱力
分布式锁的引出
业务需求:电商公司现在有如下分布式服务,某款商品对外销售。网关收到请求后会分发给服务集群中的多台服务器。假设库存数量为7,请求数量为9,服务集群中三台服务器各收到3个下单请求,并行执行扣减库存操作。由于每台服务器同时读到的库存数量均为7,于是都在库存数量上进行了减3操作。最终导致超卖。
解决该问题的方法就是使用分布式锁。分布式锁的宏观思路就是所有服务器的写操作都尝试对同一第三方内容加锁,加锁成功才能够读数据库、更改库存。这个锁必须是互斥的,即只能让一个服务加锁成功。于是就有了下图所示的分布式锁思路。不同的分布式锁方案就是对第三方加锁的策略不同。
MYSQL分布式锁
mysql分布式加锁的机制是利用同一表中记录ID不能相同。解锁机制是删除该条记录。
如下图所示,某一服务在表中成功插入一条ID为0的记录后,其他服务无法再向表中插入ID为0的记录。插入成功的服务具有扣减库存的权限。
此外,还可以利用mysql唯一索引互斥的机制,在表中创建唯一索引,然后插入相同索引值的服务在并发时就是互斥的。同样是插入成功的服务具有扣减库存的权限。
MYSQL分布式锁性能讨论
mysql分布式锁是对磁盘进行操作的,那就能说mysql分布式锁的性能很差么?不是的,性能其实取决于你的机器,当然是机器配置越高,性能越好。下表是阿里对MYSQL性能测试的结果。
| 规格 | 单表数据量 | 表数量 | 最大连接数 | IOPS | Sysbench线程数 | Sysbench读取(单位:次) | SysBench写入(单位:次) |
|---|---|---|---|---|---|---|---|
| 1核1 GB(rds.mysql.t1.small) | 80000 | 32 | 300 | 600 | 8 | 374584 | 107024 |
| 2核4 GB(rds.mysql.s2.large) | 80000 | 32 | 1200 | 2000 | 8 | 439418 | 125548 |
| 4核16 GB(rds.mysql.m1.medium) | 800000 | 128 | 4000 | 7000 | 16 | 851326 | 243236 |
| 8核32 GB(rds.mysql.c1.xlarge) | 800000 | 128 | 8000 | 12000 | 32 | 2384256 | 681216 |
| 16核64 GB(rds.mysql.c2.xlarge) | 800000 | 128 | 16000 | 14000 | 64 | 4584090 | 1309740 |
可以看出来,16核64G的读写速度还是可以的,但是这个配置属实贵的很呀。那有没有更好的解决方案呢?有的,使用基于内存的Redis分布式锁。
Redis分布式锁
Redis分布式锁的机制是向Redis中插入一个k-v对,只要这个key存在了,别人就无法插入。这个操作在Redis中就是setnx。
格式:
setnx key value
解释:
setnx设置key的值为value,当且仅当key不在的时候。
若给定的key已经存在,则SETNX不做任何动作。
释放锁时将该键删除即可。
格式:
del key
解释:
删除redis中值为key的k-v对
Redis分布式锁:死锁
业务场景:假设服务A获取到了锁,事务还没完成,锁还没来得及释放,服务A挂了。那么Redis中的这个key就不会被删除,其他服务也无法获得锁。这个时候就出现了死锁问题。
那如何解决服务挂掉后,redis中的key还会一直存在的问题呢?利用redis的另一个关键字expire为key设置过期时间。其实还有其他设置过期时间的方法,详见。
格式:
EXPIRE key seconds
解释:
给key设置过期时间,到达过期时候时,该key会从redis中删除。
但是上边这种加锁方式还是存在一些问题,因为设置key和设置过期时间是两步操作,不是原子的。如果设置key后还没来得及设置过期时间服务就挂了,死锁同样会发生。所以可以采用redis另外一个命令set,在设置key时同时设置过期时间。
格式:
SET key NX EX seconds
解释:
NX参数保证只有当key不在时才设置key,存在则忽略
EX seconds是给key设置过期时间
过期时间带来的问题
业务场景:服务A加锁,设置过期时间10s。但是,10s后服务A还没完成自己的业务,时间却到了,key过期。此时服务B申请锁10s,这时是能够加锁成功的。假设,服务A在第16s时执行完毕,释放锁,而服务A释放的是服务B的锁。但是服务B还没执行完毕,这个时候就可能有别人过来加锁,就又会出现两个服务并行操作数据库。
上述业务场景中出现了两个问题:
- 服务还没执行完,锁到期了
- 释放了别人的锁
针对第一个问题,可以给加锁成功的服务设置一个守护线程。假设服务A设置锁过期时间未60ms,当执行到50ms时,如果服务A还没执行完,那么守护线程给锁续期。如果服务一直执行不完,守护线程就可以一直续。也有人管这种机制叫做看门狗机制
针对第二个问题,可以在设置k-v对的时候,将v设置为服务线程的ID,每个服务线程在释放锁之前都判断一下,这个锁的值是不是与自己的线程ID相同,即判断这个锁是不是自己加的。如果是自己加的,则可以释放;不是自己加的,忽略即可。
分段锁提升Redis锁性能
业务场景:假设有10台服务器,1000件商品。同一时刻只能有一个服务器获取锁,其他的服务器就都得等着。
出现这个问题的根本原因是,10台服务器争抢同一个锁,这个锁管理着全部的1000件商品。为了解决这问题,引入分段锁。分段锁是设置多把锁,每个锁都管理着一些商品。按照例子中,10台服务器可以设置5把锁,每把锁管理200件商品。这样就是两个服务器抢一个锁,并发数量5倍增长。
Redis分布式锁还是有问题的
其实无论是redis单点的,还是集群的都有redis挂掉的问题。单点redis挂掉那就是挂掉了,莫得办法。对于集群,即便是redis高可用集群也无法保证master和slave数据实时同步,如果服务A申请锁成功后,slave还没来得及同步master数据,master就挂掉了的话,集群就会再选出一个master。当其他服务向redis申请锁申请锁时,仍然能够成功。服务又一次并行执行了,还会导致超卖问题。
红锁
用Redis中的多个master实例,来获取锁,只有大多数实例获取到了锁,才算是获取成功。具体的红锁算法分为以下五步:
- 获取当前的时间(单位是毫秒)。
- 使用相同的key和随机值在N个节点上请求锁。这里获取锁的尝试时间要远远小于锁的超时时间,防止某个master挂了,我们还在不断的获取锁,而被阻塞过长的时间。
- 只有在大多数节点上获取到了锁,而且总的获取时间小于锁的超时时间的情况下,认为锁获取成功了。
- 如果锁获取成功了,锁的超时时间就是最初的锁超时时间进去获取锁的总耗时时间。
- 如果锁获取失败了,不管是因为获取成功的节点的数目没有过半,还是因为获取锁的耗时超过了锁的释放时间,都会将已经设置了key的master上的key删除。
注意:master的数量必须是基数。如果是偶数的话,比如是6,两个线程各获得3个锁,无法决策到底谁最终获得了锁。
Redis分布式锁的终极问题
上边说过,获取锁后还存在事务没结束锁过期了的情况,采取的解决办法是启用一个守护进程。但是如果此时服务器开始了垃圾回收,而此垃圾回收机制需要STW,守护线程也就停止工作了,无法为锁续期。GO的垃圾回收机制详见Golang 不同版本的垃圾回收 - 飞书云文档 (feishu.cn)
ZooKeeper分布式锁
zk的节点类型总共有 4 种。临时有序节点,还有持久节点,还有持久有序节点。为什么叫临时节点?因为我们的程序执行完,或者是程序出现异常,或者会话结束了,临时节点就会消失,所以它称之为临时节点。临时有序节点是什么意思?临时节点,它都是有序的排列,比如我创建了 10 个节点, 10 个节点是有序排列的,从 1 到 10 进行排列。持久节点是什么意思?它就和临时节点相反,无论是我程序结束了,或是会话失效了,或者是我程序异常结束了。持久节点都是存在的。持久有序节点就不用说了,它是一个持久和有序的节点。
我们来说一下流程。 五个服务并发申请锁,这五个服务会在zk中创建5个临时有序节点(各自创建一个节点)。序号最小的服务会获得锁。
其余服务按顺序监听前一个序号小的节点,是否释放了锁。服务A一旦结束,节点1就会自动删除。服务B会收到消息,获得锁。