分布式锁
点击阅读更多查看文章内容
为什么需要锁?
并发场景下的超卖问题:两个goroutine同时进行库存减1的操作,g1查询库存为100,在g1还没有扣减库存的情况下,g2也查询到了库存为100,此时g1扣减库存更新后为99,g2同样更新的值也是99,此时卖出两件商品,但实际的库存只减了1
此时就需要对goroutine加锁,通过在 var m sync.Mutex 全局声明一把互斥锁,在查询库存前通过 m.lock 获取锁,此时其他的goroutine要想获取锁就会阻塞,在更新完数据库后执行 m.unlock 释放锁,此时其他的goroutine可以继续获取锁查询更新数据库。
为什么需要分布式锁?
库存服务可能会部署在多台服务器上但是共享同一个数据库,此时一个服务器上的多个goutine使用一把锁,但是不同服务器上的goroutine之间没有锁请求同一个数据库时仍会存在超卖问题。
此时需要在库存服务1和库存服务2之外设置一个第三方的分布式锁,这两个服务首先请求分布式锁,如果库存服务1中的任何一个goroutine拿到了分布式锁,那么库存服务2中的任何一个goroutine就需要等待。
分布式锁的实现方案
基于mysql实现
悲观锁与乐观锁是人们定义出来的概念,你可以理解为一种思想,是处理并发资源的常用手段。
不要把他们与mysql中提供的锁机制(表锁,行锁,排他锁,共享锁)混为一谈。
悲观锁
顾名思义,就是对于数据的处理持悲观态度,总认为会发生并发冲突,获取和修改数据时,别人会修改数据。所以在整个数据处理过程中,需要将数据锁定。(前面的sync.Mutex就是悲观锁)
悲观锁的实现,通常依靠数据库提供的锁机制实现,比如mysql的排他锁,在mysql查询时使用for update可以锁住记录,使用该方法时需要关闭autocommit(set autocommit=0)
首先执行select * from inventory where goods=421 for update ,对记录加锁,随后再执行相同的语句则会一直等待直到锁释放,释放锁只需要执行 commit 即可
注意,当有索引时(goods设置为索引) for update 加的锁是行锁,只会将符合条件的记录(goods=421)锁住,如果在执行select * from inventory where goods=422 for update 针对422的查询那么可以正常查询不会阻塞。
如果没有索引时,行锁会升级为表锁,整个表都会被锁住,针对任何记录的查询都会阻塞
锁只是锁住要更新的语句 select * from inventory where goods=422,普通的select可以正常执行(读写锁)
乐观锁
顾名思义,就是对数据的处理持乐观态度,乐观的认为数据一般情况下不会发生冲突,只有提交数据更新时,才会对数据是否冲突进行检测。如果发现冲突了,则返回错误信息给用户,让用户自已决定如何操作。
乐观锁的实现不依靠数据库提供的锁机制,需要我们自已实现,实现方式一般是记录数据版本,一种是通过版本号,一种是通过时间戳。
给表加一个版本号或时间戳的字段,读取数据时,将版本号一同读出,数据更新时,将版本号加1。当我们提交数据更新时,更新与第一次读取出来的版本号相等的记录。此时如果有另一个goroutine更新了数据库,那么记录的版本号会改变,查询不到最初的记录,就无法更新数据了,此时更新失败需要重新读取最新数据再执行一遍业务逻辑。
乐观锁没有对数据库加锁,并且不会出现数据不一致的问题
唯一键约束
利用MySQL表的唯一键约束特性,通过插入操作竞争锁资源。当多个客户端尝试插入相同的锁名称时,只有一个能成功,其他会因唯一键冲突而失败,从而实现互斥锁的效果。
基于redis的分布式锁
https://github.com/go-redsync/redsync
使用redsync实现
1 | package main |
源码解析 - setnx的作用
使用redis实现锁只需要对要加锁的记录在redis中添加一个记录(例如key为加锁记录id,value为随机数),在操作完成后删除key。如果有其他线程想要操作该记录则先在redis中查询是否有对应的key,如果有则代表正有线程在操作该记录。
但是这里获取和设置值的操作需要满足原子性,如果先获取key再设置value,那么在高并发场景中,可能有线程发现redis没有key之后设置value之前又有其它线程也发现redis没有key,这样多个线程就会同时操作单个资源。
setnx:如果key不存在则设置value,如果key已存在则不会设置 —— 将获取和设置值变成原子性的操作;
过期时间和延长锁过期时间
如果线程请求到锁之后服务挂掉了,就无法执行删除key的逻辑,此时其他的线程都在等待key的释放无法执行
设置过期时间
如果你设置了过期时间,那么如果过期时间到了我的业务逻辑没有执行完怎么办?
在过期之前刷新一下,需要自己去启动协程完成延时的工作
为什么不自动延时?延时的接口可能会带来负面影响 - 如果其中某一个服务hung住了, 2s就能执行完,但是你hung住那么你就会一直去申请延长锁,导致别人永远获取不到锁,这个很要命
延长过期时间,这里的操作是基于lua脚本完成的,保证原子性
如何防止锁被其他的goroutine删除
设置的value随机生成
- 当时设置的value值是多少只有当时的g才能知道
- 在删除的时取出redis中的值和当前自己保存下来的值对比一下
删除锁:
redlock
redis的分布式锁在集群环境之下容易出现的问题
在向主redis写入数据后由于宕机或网络故障等原因没有及时同步到从redis,此时其它的服务在从redis上没有查询到锁,会继续业务逻辑的执行,此时两个库存服务就会冲突
redlock向每个redis服务器都加锁这样就避免了同步问题
Redlock 算法概述
Redlock 是在多个独立的 Redis 实例上实现分布式锁的一种方法,适用于需要确保互斥访问共享资源的场景。这个算法的设计目标是,即使在网络分区、Redis 实例宕机或其他故障的情况下,也能保证锁的可靠性。
工作原理
- 初始化锁的获取:Redlock 假设有
N个独立的 Redis 实例(一般推荐是 5 个)。客户端向这些实例发送加锁请求。 - 尝试加锁:
- 客户端向每个 Redis 实例尝试获取锁,使用一个唯一的随机值(如 UUID)作为锁的值。
- 每次请求加锁时,会设置一个过期时间(通常是一个合理的时间,例如 10 秒),确保锁在一定时间后自动释放,防止死锁。
- 加锁成功的条件:
- 客户端需要在大多数 Redis 实例中成功获取锁。例如,假设有 5 个 Redis 实例,客户端至少需要在 3 个实例上成功获取锁。
- 如果成功获取锁的 Redis 实例数达到大多数(如 3 个或更多),则客户端认为获取锁成功。
- 加锁失败:
- 如果客户端在大多数 Redis 实例上未能成功获取锁,则认为加锁失败,释放所有已获取的锁。
- 释放锁:
- 客户端持有锁时,需要定期向所有 Redis 实例发送释放锁的请求。
- 锁的释放也是通过检查唯一的随机值来进行的,确保不会误释放其他客户端的锁。
什么是时钟漂移
如果redis服务器的机器时钟发生了向前跳跃,就会导致这个key过早超时失效,比如说客户端1拿到锁后,key的过期时间是12:02分,但redis服务器本身的时钟比客户端快了2分钟,导致key在12:00的时候就失效了,这时候,如果客户端1还没有释放锁的话,就可能导致多个客户端同时持有同一把锁的问题。

