分布式锁主要的实现方法主要有3种:
- MySQL
- Redis
- Zookeeper 生产环境中对性能一般要求很高,因此主要使用Redis,本篇主要讨论Redis分布式锁的实现方法,其余只作原理性讨论
MySQL
使用MySQL实现有两种思路
- MySQL的悲观锁机制
- MySQL的唯一键
悲观锁
锁的code预先写到数据库中,抢锁的时候,使用select for update查询锁对应的key,也就是这里的code,阻塞就说明别人在使用锁。
唯一键
使用唯一键作为限制,插入一条数据,其他待执行的SQL就会失败,当数据删除之后再去获取锁 ,这是利用了唯一索引的排他性。
ZooKeeper
zookeeper 瞬时znode节点 + watcher监听机制
临时节点具备数据自动删除的功能。当client与ZooKeeper连接和session断掉时,相应的临时节点就会被删除。zk有瞬时和持久节点,瞬时节点不可以有子节点。会话结束之后瞬时节点就会消失,基于zk的瞬时有序节点实现分布式锁:
- 多线程并发创建瞬时节点的时候,得到有序的序列,序号最小的线程可以获得锁;
- 其他的线程监听自己序号的前一个序号。前一个线程执行结束之后删除自己序号的节点;
- 下一个序号的线程得到通知,继续执行;
- 以此类推,创建节点的时候,就确认了线程执行的顺序。
Redis
上锁
SET lockKey requestId NX PX 30000
为什么必须设置超时时间
避免死锁:当一个客户端获取锁成功之后,假如它崩溃了,或者它忘记释放锁,或者由于发生了网络分割(network partition)导致它再也无法和 Redis 节点通信了,那么它就会一直持有这个锁,而其它客户端永远无法获得锁了
requestId作用
防止锁被误释放:requestId用于标记唯一的加锁请求,需要在释放锁的时候校验requestId
可重入
可重入实现是长尾需求,一般不需要考虑,重入时需要校验requestId,必须使用lua来保证操作的原子性
-- 校验请求
if redis.call("get", KEYS[1]) == ARGV[1]
then
-- 刷新过期时间
return redis.call("pexpire", KEYS[1], ARGV[2])
else
-- 设置 key value 和过期时间
return redis.call("set", KEYS[1], ARGV[1], "NX", "PX", ARGV[2])
end
解锁
如上所述,解锁时需要先校验requestId,因此需要用lua保证原子性
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
否则可能会有下面这种情况
- 客户端 1 获取锁成功;
- 客户端 1 进行业务操作;
- 客户端 1 为了释放锁,先执行’GET’操作获取随机字符串的值。
- 客户端 1 判断随机字符串的值,与预期的值相等。
- 客户端 1 由于某个原因阻塞住了很长时间。
- 过期时间到了,锁自动释放了。
- 客户端 2 获取到了对应同一个资源的锁。
- 客户端 1 从阻塞中恢复过来,执行 DEL 操纵,释放掉了客户端 2 持有的锁。
自动续期
任务处理的时间总是很难预估准确,肯定会有超出过期时间的而任务没有结束的情况,因此需要异步线程定时给锁续期
代码封装
用golang实现一个简单的分布式锁sdk
func Lock(ctx context.Context, key string,
expiration time.Duration, retry rlock.RetryStrategy, timeout time.Duration) (*Lock, error) {
value := uuid.New().String()
for {
lock, err := TryLock(ctx, key, expiration)
if lock != nil {
return lock, nil
}
interval, ok := retry.Next()
if !ok {
// 不用重试
return nil, ErrFailedToPreemptLock
}
timer = time.NewTimer(interval)
timer.Reset(interval)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-timer.C:
}
}
}
// TryLock (ctx, key, time.Second * 10)
func TryLock(ctx context.Context, key string,
expiration time.Duration) (*Lock, error) {
value := uuid.New().String()
res, err := c.client.SetNX(ctx, key, value, expiration).Result()
if err != nil {
return nil, err
}
if !res {
return nil, ErrFailedToPreemptLock
}
return newLock(c.client, key, value, expiration), nil
}
type Lock struct {
client redis.Cmdable
key string
value string
expiration time.Duration
unlock chan struct{}
}
func newLock(client redis.Cmdable, key string, value string, expiration time.Duration) *Lock {
return &Lock{
client: client,
key: key,
value: value,
expiration: expiration,
unlock: make(chan struct{}, 1),
}
}
func (l *Lock) AutoRefresh(interval time.Duration, timeout time.Duration) error {
ticker := time.NewTicker(interval)
for {
select {
case <-ticker.C:
ctx, cancel := context.WithTimeout(context.Background(), timeout)
err := l.Refresh(ctx)
cancel()
if err != nil {
logs.Info("")
}
case <-l.unlock:
return nil
}
}
}
func (l *Lock) Refresh(ctx context.Context) error {
res, err := l.client.PExpire(ctx, l.key, l.value, l.expiration.Milliseconds()).Int64()
if err == redis.Nil {
return ErrLockNotHold
}
if err != nil {
return err
}
if res != 1 {
return ErrLockNotHold
}
return nil
}
func (l *Lock) Unlock(ctx context.Context) error {
defer func() {
l.unlock <- struct{}{}
close(l.unlock)
}()
res, err := l.client.Eval(ctx, luaUnlock, []string{l.key}, l.value).Int64()
if err == redis.Nil {
return ErrLockNotHold
}
if err != nil {
return err
}
// 要判断 res 是不是 1
if res == 0 {
// 这把锁不是你的,或者这个 key 不存在
return ErrLockNotHold
}
return nil
}