分布式锁

点击阅读更多查看文章内容

为什么需要锁?

并发场景下的超卖问题:两个goroutine同时进行库存减1的操作,g1查询库存为100,在g1还没有扣减库存的情况下,g2也查询到了库存为100,此时g1扣减库存更新后为99,g2同样更新的值也是99,此时卖出两件商品,但实际的库存只减了1

此时就需要对goroutine加锁,通过在 var m sync.Mutex 全局声明一把互斥锁,在查询库存前通过 m.lock 获取锁,此时其他的goroutine要想获取锁就会阻塞,在更新完数据库后执行 m.unlock 释放锁,此时其他的goroutine可以继续获取锁查询更新数据库。

image.png

为什么需要分布式锁?

库存服务可能会部署在多台服务器上但是共享同一个数据库,此时一个服务器上的多个goutine使用一把锁,但是不同服务器上的goroutine之间没有锁请求同一个数据库时仍会存在超卖问题。

此时需要在库存服务1和库存服务2之外设置一个第三方的分布式锁,这两个服务首先请求分布式锁,如果库存服务1中的任何一个goroutine拿到了分布式锁,那么库存服务2中的任何一个goroutine就需要等待。

image.png

分布式锁的实现方案

基于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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import (
goredislib "github.com/redis/go-redis/v9"
"github.com/go-redsync/redsync/v4"
"github.com/go-redsync/redsync/v4/redis/goredis/v9"
)

func main() {
// Create a pool with go-redis (or redigo) which is the pool redisync will
// use while communicating with Redis. This can also be any pool that
// implements the `redis.Pool` interface.
client := goredislib.NewClient(&goredislib.Options{
Addr: "localhost:6379",
})
pool := goredis.NewPool(client) // or, pool := redigo.NewPool(...)

// Create an instance of redisync to be used to obtain a mutual exclusion
// lock.
rs := redsync.New(pool)

// Obtain a new mutex by using the same name for all instances wanting the
// same lock.
mutexname := "my-global-mutex"
mutex := rs.NewMutex(mutexname)

// Obtain a lock for our given mutex. After this is successful, no one else
// can obtain the same lock (the same mutex name) until we unlock it.
if err := mutex.Lock(); err != nil {
panic(err)
}

// Do your work that requires the lock.

// Release the lock so other processes or threads can obtain a lock.
if ok, err := mutex.Unlock(); !ok || err != nil {
panic("unlock failed")
}
}

源码解析 - setnx的作用

使用redis实现锁只需要对要加锁的记录在redis中添加一个记录(例如key为加锁记录id,value为随机数),在操作完成后删除key。如果有其他线程想要操作该记录则先在redis中查询是否有对应的key,如果有则代表正有线程在操作该记录。

但是这里获取和设置值的操作需要满足原子性,如果先获取key再设置value,那么在高并发场景中,可能有线程发现redis没有key之后设置value之前又有其它线程也发现redis没有key,这样多个线程就会同时操作单个资源。

setnx:如果key不存在则设置value,如果key已存在则不会设置 —— 将获取和设置值变成原子性的操作;

image-20250215200647316

过期时间和延长锁过期时间

如果线程请求到锁之后服务挂掉了,就无法执行删除key的逻辑,此时其他的线程都在等待key的释放无法执行

  1. 设置过期时间

  2. 如果你设置了过期时间,那么如果过期时间到了我的业务逻辑没有执行完怎么办?

    • 在过期之前刷新一下,需要自己去启动协程完成延时的工作

      为什么不自动延时?延时的接口可能会带来负面影响 - 如果其中某一个服务hung住了, 2s就能执行完,但是你hung住那么你就会一直去申请延长锁,导致别人永远获取不到锁,这个很要命

延长过期时间,这里的操作是基于lua脚本完成的,保证原子性

image-20250215200719955

如何防止锁被其他的goroutine删除

设置的value随机生成

  1. 当时设置的value值是多少只有当时的g才能知道
  2. 在删除的时取出redis中的值和当前自己保存下来的值对比一下

删除锁:

image-20250215200543772

redlock

redis的分布式锁在集群环境之下容易出现的问题

在向主redis写入数据后由于宕机或网络故障等原因没有及时同步到从redis,此时其它的服务在从redis上没有查询到锁,会继续业务逻辑的执行,此时两个库存服务就会冲突

image-20250215201226619

redlock向每个redis服务器都加锁这样就避免了同步问题

Redlock 算法概述

Redlock 是在多个独立的 Redis 实例上实现分布式锁的一种方法,适用于需要确保互斥访问共享资源的场景。这个算法的设计目标是,即使在网络分区、Redis 实例宕机或其他故障的情况下,也能保证锁的可靠性。

工作原理

  1. 初始化锁的获取:Redlock 假设有 N 个独立的 Redis 实例(一般推荐是 5 个)。客户端向这些实例发送加锁请求。
  2. 尝试加锁
    • 客户端向每个 Redis 实例尝试获取锁,使用一个唯一的随机值(如 UUID)作为锁的值。
    • 每次请求加锁时,会设置一个过期时间(通常是一个合理的时间,例如 10 秒),确保锁在一定时间后自动释放,防止死锁。
  3. 加锁成功的条件
    • 客户端需要在大多数 Redis 实例中成功获取锁。例如,假设有 5 个 Redis 实例,客户端至少需要在 3 个实例上成功获取锁。
    • 如果成功获取锁的 Redis 实例数达到大多数(如 3 个或更多),则客户端认为获取锁成功。
  4. 加锁失败
    • 如果客户端在大多数 Redis 实例上未能成功获取锁,则认为加锁失败,释放所有已获取的锁。
  5. 释放锁
    • 客户端持有锁时,需要定期向所有 Redis 实例发送释放锁的请求。
    • 锁的释放也是通过检查唯一的随机值来进行的,确保不会误释放其他客户端的锁。

什么是时钟漂移

如果redis服务器的机器时钟发生了向前跳跃,就会导致这个key过早超时失效,比如说客户端1拿到锁后,key的过期时间是12:02分,但redis服务器本身的时钟比客户端快了2分钟,导致key在12:00的时候就失效了,这时候,如果客户端1还没有释放锁的话,就可能导致多个客户端同时持有同一把锁的问题。

作者

ShiHaonan

发布于

2025-03-03

更新于

2025-04-09

许可协议

评论