Go结合Redis用最简单的方式实现分布式锁

目录
  • 前言
  • 单redis实例场景
    • 加解锁示例
    • 小结
  • 多redis实例场景
    • 加解锁示例
    • 小结
  • 总结

    前言

    在项目中我们经常有需要使用分布式锁的场景,而redis是实现分布式锁最常见的一种方式,并且我们也都希望能够把代码写得简单一点,所以今天我们尽量用最简单的方式来实现。

    下面的代码使用go-redis客户端和gofakeit,参考和引用了redis官方文章

    单redis实例场景

    如果熟悉redis的命令,可能会马上想到使用redis的set if not exists操作来实现,并且现在标准的实现方式是set resource_name my_random_value nx px 30000这串命令,其中:

    • resource_name表示要锁定的资源
    • nx表示如果不存在则设置
    • px 30000表示过期时间为30000毫秒,也就是30秒
    • my_random_value这个值在所有的客户端必须是唯一的,所有同一key的获取者(竞争者)这个值都不能一样。

    value的值必须是随机数主要是为了更安全的释放锁,释放锁的时候使用脚本告诉redis:只有key存在并且存储的值和我指定的值一样才能告诉我删除成功。可以通过以下lua脚本实现:

    if redis.call("get",keys[1]) == argv[1] then
        return redis.call("del",keys[1])
    else
        return 0
    end

    举个例子:客户端a取得资源锁,但是紧接着被一个其他操作阻塞了,当客户端a运行完毕其他操作后要释放锁时,原来的锁早已超时并且被redis自动释放,并且在这期间资源锁又被客户端b再次获取到。

    使用lua脚本是因为判断和删除是两个操作,所以有可能a刚判断完锁就过期自动释放了,然后b就获取到了锁,然后a又调用了del,导致把b的锁给释放了。

    加解锁示例

    package main
    
    import (
       "context"
       "errors"
       "fmt"
       "github.com/brianvoe/gofakeit/v6"
       "github.com/go-redis/redis/v8"
       "sync"
       "time"
    )
    
    var client *redis.client
    
    const unlockscript = `
    if redis.call("get",keys[1]) == argv[1] then
        return redis.call("del",keys[1])
    else
        return 0
    end`
    
    func lottery(ctx context.context) error {
       // 加锁
       myrandomvalue := gofakeit.uuid()
       resourcename := "resource_name"
       ok, err := client.setnx(ctx, resourcename, myrandomvalue, time.second*30).result()
       if err != nil {
          return err
       }
       if !ok {
          return errors.new("系统繁忙,请重试")
       }
       // 解锁
       defer func() {
          script := redis.newscript(unlockscript)
          script.run(ctx, client, []string{resourcename}, myrandomvalue)
       }()
    
       // 业务处理
       time.sleep(time.second)
       return nil
    }
    
    func main() {
       client = redis.newclient(&redis.options{
          addr: "127.0.0.1:6379",
       })
       var wg sync.waitgroup
       wg.add(2)
       go func() {
          defer wg.done()
          ctx, _ := context.withtimeout(context.background(), time.second*3)
          err := lottery(ctx)
          if err != nil {
             fmt.println(err)
          }
       }()
       go func() {
          defer wg.done()
          ctx, _ := context.withtimeout(context.background(), time.second*3)
          err := lottery(ctx)
          if err != nil {
             fmt.println(err)
          }
       }()
       wg.wait()
    }

    我们先看lottery()函数,这里模拟一个抽奖操作,在进入函数时,先使用set resource_name my_random_value nx px 30000加锁,这里使用uuid作为随机值,如果操作失败,直接返回,让用户重试,如果成功在defer里面执行解锁逻辑,解锁逻辑就是执行前面说到得lua脚本,然后再进行业务处理。

    我们在main()函数里面执行了两个goroutine并发调用lottery()函数,其中有一个操作会因为拿不到锁而直接失败。

    小结

    • 生成随机值
    • 使用set resource_name my_random_value nx px 30000加锁
    • 如果加锁失败,直接返回
    • defer添加解锁逻辑,保证在函数退出的时候会执行
    • 执行业务逻辑

    多redis实例场景

    在单实例情况下,如果这个实例挂了,那么所有请求都会因为拿不到锁而失败,所以我们需要多个分布在不同机器上的redis实例,并且拿到其中大多数节点的锁才能加锁成功,这也就是redlock算法。它其实也是基于上面的单实例算法的,只是我们需要同时对多个redis实例获取锁。

    加解锁示例

    package main
    
    import (
       "context"
       "errors"
       "fmt"
       "github.com/brianvoe/gofakeit/v6"
       "github.com/go-redis/redis/v8"
       "sync"
       "time"
    )
    
    var clients []*redis.client
    
    const unlockscript = `
    if redis.call("get",keys[1]) == argv[1] then
        return redis.call("del",keys[1])
    else
        return 0
    end`
    
    func lottery(ctx context.context) error {
       // 加锁
       myrandomvalue := gofakeit.uuid()
       resourcename := "resource_name"
       var wg sync.waitgroup
       wg.add(len(clients))
       // 这里主要是确保不要加锁太久,这样会导致业务处理的时间变少
       lockctx, _ := context.withtimeout(ctx, time.millisecond*5)
       // 成功获得锁的redis实例的客户端
       successclients := make(chan *redis.client, len(clients))
       for _, client := range clients {
          go func(client *redis.client) {
             defer wg.done()
             ok, err := client.setnx(lockctx, resourcename, myrandomvalue, time.second*30).result()
             if err != nil {
                return
             }
             if !ok {
                return
             }
             successclients <- client
          }(client)
       }
       wg.wait() // 等待所有获取锁操作完成
       close(successclients)
       // 解锁,不管加锁是否成功,最后都要把已经获得的锁给释放掉
       defer func() {
          script := redis.newscript(unlockscript)
          for client := range successclients {
             go func(client *redis.client) {
                script.run(ctx, client, []string{resourcename}, myrandomvalue)
             }(client)
          }
       }()
       // 如果成功加锁得客户端少于客户端数量的一半+1,表示加锁失败
       if len(successclients) < len(clients)/2+1 {
          return errors.new("系统繁忙,请重试")
       }
    
       // 业务处理
       time.sleep(time.second)
       return nil
    }
    
    func main() {
       clients = append(clients, redis.newclient(&redis.options{
          addr: "127.0.0.1:6379",
          db:   0,
       }), redis.newclient(&redis.options{
          addr: "127.0.0.1:6379",
          db:   1,
       }), redis.newclient(&redis.options{
          addr: "127.0.0.1:6379",
          db:   2,
       }), redis.newclient(&redis.options{
          addr: "127.0.0.1:6379",
          db:   3,
       }), redis.newclient(&redis.options{
          addr: "127.0.0.1:6379",
          db:   4,
       }))
       var wg sync.waitgroup
       wg.add(2)
       go func() {
          defer wg.done()
          ctx, _ := context.withtimeout(context.background(), time.second*3)
          err := lottery(ctx)
          if err != nil {
             fmt.println(err)
          }
       }()
       go func() {
          defer wg.done()
          ctx, _ := context.withtimeout(context.background(), time.second*3)
          err := lottery(ctx)
          if err != nil {
             fmt.println(err)
          }
       }()
       wg.wait()
       time.sleep(time.second) 
    }

    在上面的代码中,我们使用redis的多数据库模拟多个redis master实例,一般我们会选择5个redis实例,真实环境中这些实例应该是分布在不同机器上的,避免同时失效。
    在加锁逻辑里,我们主要是对每个redis实例执行set resource_name my_random_value nx px 30000获取锁,然后把成功获取锁的客户端放到一个channel里(这里使用slice可能有并发问题),同时使用sync.waitgroup等待所以获取锁操作结束。
    然后添加defer释放锁逻辑,释放锁逻辑很简单,只是把成功拿到的锁给释放掉即可。
    最后判断成功获取到的锁的数量是否大于一半,如果没有得到一半以上的锁,说明加锁失败。
    如果加锁成功接下来就是进行业务处理。

    小结

    • 生成随机值
    • 并发给每个redis实例使用set resource_name my_random_value nx px 30000加锁
    • 等待所有获取锁操作完成
    • defer添加解锁逻辑,保证在函数退出的时候会执行,这里先defer再判断是因为有可能获取到一部分redis实例的锁,但是因为没有超过一半,还是会判断为加锁失败
    • 判断是否拿到一半以上redis实例的锁,如果没有说明加锁失败,直接返回
    • 执行业务逻辑

    总结

    通过使用go的goroutine、channel、context、sync.waitgroup等功能可以很容易的实现redlock(30多行代码)
    可以把加解锁操作封装成函数,这样就不会在业务代码里参杂太多加解锁的逻辑

    到此这篇关于go+redis用最简单的方式实现分布式锁的文章就介绍到这了,更多相关go redis分布式锁内容请搜索www.887551.com以前的文章或继续浏览下面的相关文章希望大家以后多多支持www.887551.com!

    (0)
    上一篇 2022年3月21日
    下一篇 2022年3月21日

    相关推荐