go项目中分布式锁的使用(解决分布式锁超时的场景)

分布式锁的使用(解决分布式锁超时的场景)

  • 前提:大部分服务模块都是用的一个redis实例

  • 参考资料:图解redsync开源包,告诉你分布式锁为什么不仅仅是setnx

  • redsync库:https://github.com/go-redsync/redsync

    加解锁使用的是通用的做法(如下)

    • 加锁:setnx、value为锁持有者的唯一标识符、设置过期时间

      // redsync/v4/mutex.go
      // 注意,传入的value是RedSync库自己生成的随机字符串
      func (m *Mutex) acquire(ctx context.Context, pool redis.Pool, value string) (bool, error) {
      	conn, err := pool.Get(ctx)
      	if err != nil {
      		return false, err
      	}
      	defer conn.Close()
      	reply, err := conn.SetNX(m.name, value, m.expiry)
      	if err != nil {
      		return false, err
      	}
      	return reply, nil
      }
      
    • 解锁:lua脚本(保证原子操作),只有删除锁为锁的持有者才能允许删除

      // redsync/v4/mutex.go
      var deleteScript = redis.NewScript(1, `
      	if redis.call("GET", KEYS[1]) == ARGV[1] then
      		return redis.call("DEL", KEYS[1])
      	else
      		return 0
      	end
      `)
      
      func (m *Mutex) release(ctx context.Context, pool redis.Pool, value string) (bool, error) {
      	conn, err := pool.Get(ctx)
      	if err != nil {
      		return false, err
      	}
      	defer conn.Close()
      	status, err := conn.Eval(deleteScript, m.name, value)
      	if err != nil {
      		return false, err
      	}
      	return status != int64(0), nil
      }
      

项目中封装了下newMutex,就是加上了服务模块名作为锁名称的前缀

func (s *RedisSync) NewMutex(path string, expiration time.Duration) Mutex {
	path = fmt.Sprintf("%s/%s", s.prefix, strings.TrimPrefix(path, "/"))

	return &RedisMutex{
		mutex: s.redsync.NewMutex(path, redsync.WithExpiry(expiration)),
	}
}

func (s *RedisSync) NewMutexWithExpirationAndTry(path string, expiration time.Duration, try int) Mutex {
	path = fmt.Sprintf("%s/%s", s.prefix, strings.TrimPrefix(path, "/"))
	return &RedisMutex{
		mutex: s.redsync.NewMutex(path, redsync.WithExpiry(expiration), redsync.WithTries(try)),
	}
}

本项目中,分布式锁可以用来在代码层面锁住用户正在操作的一个功能(比如:编辑订单)。本来在数据库层已经通过事务保证了一致性了,我们在代码层再做的话,就是,防止出现用户a和用户b同时操作一个功能(修改数据)后,用户a发现他修改完数据、保存后,数据和自己设置的不一样。

项目中分布式锁的使用示例(定时给锁续期)

背景:在编辑订单的时候,可能存在一个企业账号n,多个人正在使用这个账号去编辑同一条订单。
该场景涉及两个接口:编辑订单的接口:UpdateOrder;获取编辑订单锁的接口:EditOrderLock;

我们使用了两个锁:外层分布式锁<用户id+订单id,RedisSync库自己会生成随机字符串>、内层锁<用户id+订单号 , lockkey(用户id+当前时间进行MD5)>(表示只有前端传的lockkey和redis存的lockkey一样的人才能使用当前这个功能)

  • 前端进入编辑订单页面后,每2秒调用一个EditOrderLock接口(目的是给内层锁续期)需携带内层锁的key,用于判断两次获取内层锁的是不是同一个人,接口会返回内层锁的value: lockkey
  • EditOrderLock接口完成3件事:
    • 给【用户n编辑订单m】这个操作加上一个外层的分布式锁(5秒过期)
      • 加不上说明有人在用
      • 假设小明拿到这个外层锁,他要操作很久,5秒后锁过期了,被别人拿到锁了,但没携带内层锁的key或携带了以前的key,别人在内层锁就被挡了,就不能进入编辑订单页面。此时,假设小明也退出了操作,那我们要给内层锁设置过期值,以防内层锁一直挡着别人。
    • 判断redis中是否存在内层锁(k-v)<用户id+订单号 , lockkey(用户id+当前时间进行MD5)>
    • 给内层锁(k-v)续期并返回一个lockkey(用户id+当前时间进行MD5)给前端(当前操作人)
      • 给内层锁续期的目的就是尽量保证正在操作的人能一直编辑订单
      • 内层锁的过期时间至少比外层锁的过期时间长,否则可能出现我还没退出这个编辑订单页面,就被别人抢占了。也不用太长,防止影响下一个用户的使用
  • UpdateOrder接口:(用户必须是 EditLock 接口返回成功,才能调用到这个接口)
    • 获取一个新的分布式锁(key 为固定字符➕订单号) ,过期时间设计为 30s
    • 根据传入的订单基本信息去查询是否存在这个订单,存在的话看下该记录的revision字段是否相同(每条订单记录在修改后,版本 revision 会+1,也就是乐观锁,在数据库层面解决并发问题)
    • 若第 2 步中发现前端携带的订单 revision 和数据库中的订单的 revision 不同,则去判断下当前操作人是否是内层锁拥有者(主要就是通过前端传的 LockKey 和 redis 中存的 LockKey 进行比较)是则用查数据库得到的订单对象去完成剩余业务逻辑,反之报错

总结来说:我们通过每 2s 给内层锁(5s过期)去续期,解决我们单纯使用一个UpdateOrder接口去进行编辑订单时可能出现的分布式锁超时问题(可能执行某部分业务逻辑时死锁阻塞了很久,导致分布式锁超时自动被删掉)。这种方式是锁续期机制的实现,使用了另一个接口(里面不会有什么阻塞操作,所以用了一个外层的分布式锁,5s 过期已经足够后面的内层锁判断、续期逻辑了)来完成内层锁(不是分布式锁,只是一个单纯的 k-v,用来判断身份)的续期。

	// 前端会每2s调用一次这个接口,传了个LockKey到后端,后端要么报错表示有其他人正在编辑订单,要么返回lockkey,说明可以你访问
	// 首先获取外层分布式锁 mutex(<用户id+订单id,RedisSync库自己会生成随机字符串>),接口执行完会释放这个分布式锁,说明其他人是有机会拿到这个锁的,但是我们已经有内层锁的判断了,
	// 结果就是:1. 要么内层锁拥有者一直访问,但抢不到外层锁,这样子内层锁就没办法续签,5s后过期;
	//    	   2. 要么内层锁访问,抢到了外层锁,完成内层锁续签操作;
	// 		   3. 要么就是内层锁拥有者结束操作了,同第一点
	mutex := impl.Sync.NewMutexWithExpirationAndTry(fmt.Sprintf("ORDER_ORDER_UPDATE_LOCK_%v_%v", userInfo.GroupId, request.SerialNo), 5*time.Second, 2) //外层锁过期时间 5 秒,接口调用完就释放
	err = mutex.Lock()// 这个外层分布式锁用来防止多个人使用同一个账号(GroupId相同)去操作同一个订单
	// 。。。
	defer func(mutex grpcsync.Mutex) {
		err = mutex.Unlock()
		// 。。。
	}(mutex)
	// 去拿内层锁
	key := fmt.Sprintf("GM_ORDER_ORDER_UPDATE_%v_%v", userInfo.GroupId, request.SerialNo)
	result, err := impl.Redis.Exists(ctx, key).Result()
	if err != nil {
		grpclog.Errorf("EditOrderLock,check exists key error,serialNo=%v,err=%v", request.SerialNo, err)
		return nil, err
	}
	//
	var lockKey string
	nowTime := time2.UnixMilliNow()
	//
	if result > 0 { //说明锁已经存在,已经有人正处于编辑状态(可能是自己也可能是别人)
		if len(request.LockKey) <= 0 { //前端传的LockKey<=0说明这个人是第一次访问这个接口,而此时有别人正在操作这个订单,则直接不允许编辑
			return nil, errors.GRPCError(ordermodel.Status_CODE_ORDER_EDITING_ERROR)
		}
		lockKey, err = impl.Redis.Get(ctx, key).Result()
		if err != nil {
			grpclog.Errorf("EditOrderLock,get redis value,serialNo=%v,err=%v", request.SerialNo, err)
			return nil, err
		}
		if strings.Compare(request.LockKey, lockKey) != 0 { //前端传了lockkey但和现在内层锁的value不同,说明这个人可能曾经进入过编辑状态,但是锁续期失败或者没有续期,被其他人抢到了锁,所以前端传的lockkey对不上redis中的lockkey
			return nil, errors.GRPCError(ordermodel.Status_CODE_ORDER_EDITING_ERROR)
		}
	} else { //内层锁不存在,说明没人在访问这个订单,直接生成lockKey(保证唯一)
		lockKey = crypto.Md5String(fmt.Sprintf("%v_%v", key, nowTime))
	}
	//内层锁续期(代码执行到这里说明自己就是拥有内层锁的人,这是前端每2s进行一次续签,自己就可以一直操作)
	err = impl.Redis.Set(ctx, key, lockKey, 5*time.Second).Err()
	if err != nil {
		grpclog.Errorf("EditOrderLock,set redis value,serialNo=%v,err=%v", request.SerialNo, err)
		return nil, err
	}
	// 最后将生成lockKey返回给前端

EditLockKey 接口中不会有任何可能导致阻塞超时的代码

其他:每个服务模块在启动时,会创建一个与redis的连接:

// 使用go-redis库创建redis cli之后,使用了redsync库去创建连接池
func NewRedisSync(client *redis.Client, prefix string) Sync {
	pool := goredis.NewPool(client)// goredis是redsync库中的

	return &RedisSync{
		prefix:  prefix,
		redsync: redsync.New(pool),// 实现分布式系统中的互斥锁
    // func New(pools ...Pool) *Redsync
    // 说明支持多redis节点
	}
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值