分布式锁的使用(解决分布式锁超时的场景)
-
前提:大部分服务模块都是用的一个redis实例
-
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)给前端(当前操作人)
- 给内层锁续期的目的就是尽量保证正在操作的人能一直编辑订单
- 内层锁的过期时间至少比外层锁的过期时间长,否则可能出现我还没退出这个编辑订单页面,就被别人抢占了。也不用太长,防止影响下一个用户的使用
- 给【用户n编辑订单m】这个操作加上一个外层的分布式锁(5秒过期)
- 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节点
}
}