Redis分布式锁总结

2 阅读4分钟

本篇仅作Redis分布式的介绍。默认你了解分布式锁,以及为什么需要分布式锁。

为什么需要Redis 分布式锁

分布式锁的实现方式有很多, 为什么我们常常采用Redis实现?回答这个问题,就需要先看一下分布式锁应该具备哪些条件?

  1. 互斥:同一个资源,同一时刻只能有一个有效持有者。
  2. 高可用:锁服务本身要保证高可用。
  3. 过期时间:要提供过期时间,避免因为客户端崩溃导致锁无法释放。
  4. 续约机制:要提供续约机制,避免因为业务时间执行过长导致锁提前释放。
  5. 安全释放:释放锁时必须验证释放者身份,只能释放自己持有的锁。
    除此之外,好的分布式锁,还需要满足以下条件:
  6. 高性能:锁服务本身要保证高性能。
  7. 可重入:同一请求或者线程可以重复进入同一临界区,就需要记录持有者和冲入次数,避免自己把自己阻塞
  8. 获取语义明确:获取锁可以是阻塞等待、限时等待,也可以是立即失败。生产中通常要设置最大等待时间和重试退避,不能无限等待。

从上述要求上可以看出Redis自身的特性能够很好的满足上述条件,除此之外,MySQL、zookeeper都可以满足上述条件,但是MySQL性能较差,zookeeper部署成本高,而且一般微服务系统都包含Redis集群,可以复用已有的服务,因此成为主流的实现方式。

实现原理

如何实现Redis分布式锁?这里不会详细的介绍实现细节,而是简述一下实现时需要考虑的问题。

  1. 如何原子性的获取锁?原子性的操作可以确保同一资源、同一时刻只能有一个有效持有者。在redis中可以使用setnx命令,确保操作的原子性。如果key存在则创建失败,反之则创建成功。

    setnx lock_key value 
    
  2. 如何避免因为业务时间执行过长导致锁提前释放? 获取key时设置过期时间。

    SET lock_key value NX EX 30
    
  3. 如何保证安全释放?一是使用业务ID+线程ID作为value,二是删除时先判断value是否与业务ID+当前线程ID相同,相同则删除,否则失败。此时需要使用lua脚本来保证原子性,不能直接使用del命令。

  4. 业务时间超过了过期时间。获取锁后,加个定时任务每隔一段时间重置一下过期时间。

  5. 如何实现可重入?此时需要把set升级为hash的方式实现。并且改成lua脚本的方式来保证原子性。

    	-- key不存在,第一次获取锁
    if redis.call('exists', KEYS[1]) == 0 then
    
    	redis.call('hset',KEYS[1],ARGV[1],1)
    	redis.call('expire',KEYS[1],ARGV[2])
    	return 1
    	-- key存在,但是是自己的锁
    elseif redis.call('hexists',KEYS[1],ARGV[1]) == 1 then
    	redis.call('hincrby',KEYS[1],ARGV[1],1)
    	return 1
    	-- 别人的锁
    else
    	return 0
    end
    

适合场景

Redis 分布式锁解决的是并发互斥的问题,而不是事务一致性问题。它的使用场景本质上都是多个服务实例需要同时访问某个共享资源,但业务要求同一时间只能有一个实例执行。

  1. 分布式定时任务时,防止任务重复执行
  2. 下单时防止重复提交
  3. 热点数据失效时,防止缓存击穿。当缓存失效时,确保只有一个请求查询数据库写入缓存,其他请求等待。
  4. 按钮防止重复点击
  5. 防止重复生成数据,例如导出excel

不适合场景。

  1. 强一致性场景,例如转账

  2. 长时间的任务,缺点是锁续期复杂,故障恢复困难,更推荐使用MQ消息

  3. 防止重复消费,该场景下可以使用分布式锁,但是更推荐采用如下方式:

    1. 消息幂等
    2. 去重表
    3. 唯一约束
  4. 秒杀场景,也不推荐采用redis 分布式锁,主要原因如下:

    1. 秒杀场景的特点如下:

      • 瞬时流量巨大
      • 成功率极低(例如100万人抢100件商品)
      • 请求大部分注定失败
    2. 因此采用分布式锁并没有消除服务的请求压力,会导致请求排队。而秒杀场景需要的是快速失败。大量的请求排队,会占据应用资源,拖垮应用服务器。