本篇仅作Redis分布式的介绍。默认你了解分布式锁,以及为什么需要分布式锁。
为什么需要Redis 分布式锁
分布式锁的实现方式有很多, 为什么我们常常采用Redis实现?回答这个问题,就需要先看一下分布式锁应该具备哪些条件?
- 互斥:同一个资源,同一时刻只能有一个有效持有者。
- 高可用:锁服务本身要保证高可用。
- 过期时间:要提供过期时间,避免因为客户端崩溃导致锁无法释放。
- 续约机制:要提供续约机制,避免因为业务时间执行过长导致锁提前释放。
- 安全释放:释放锁时必须验证释放者身份,只能释放自己持有的锁。
除此之外,好的分布式锁,还需要满足以下条件: - 高性能:锁服务本身要保证高性能。
- 可重入:同一请求或者线程可以重复进入同一临界区,就需要记录持有者和冲入次数,避免自己把自己阻塞
- 获取语义明确:获取锁可以是阻塞等待、限时等待,也可以是立即失败。生产中通常要设置最大等待时间和重试退避,不能无限等待。
从上述要求上可以看出Redis自身的特性能够很好的满足上述条件,除此之外,MySQL、zookeeper都可以满足上述条件,但是MySQL性能较差,zookeeper部署成本高,而且一般微服务系统都包含Redis集群,可以复用已有的服务,因此成为主流的实现方式。
实现原理
如何实现Redis分布式锁?这里不会详细的介绍实现细节,而是简述一下实现时需要考虑的问题。
-
如何原子性的获取锁?原子性的操作可以确保同一资源、同一时刻只能有一个有效持有者。在redis中可以使用setnx命令,确保操作的原子性。如果key存在则创建失败,反之则创建成功。
setnx lock_key value -
如何避免因为业务时间执行过长导致锁提前释放? 获取key时设置过期时间。
SET lock_key value NX EX 30 -
如何保证安全释放?一是使用业务ID+线程ID作为value,二是删除时先判断value是否与业务ID+当前线程ID相同,相同则删除,否则失败。此时需要使用lua脚本来保证原子性,不能直接使用del命令。
-
业务时间超过了过期时间。获取锁后,加个定时任务每隔一段时间重置一下过期时间。
-
如何实现可重入?此时需要把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 分布式锁解决的是并发互斥的问题,而不是事务一致性问题。它的使用场景本质上都是多个服务实例需要同时访问某个共享资源,但业务要求同一时间只能有一个实例执行。
- 分布式定时任务时,防止任务重复执行
- 下单时防止重复提交
- 热点数据失效时,防止缓存击穿。当缓存失效时,确保只有一个请求查询数据库写入缓存,其他请求等待。
- 按钮防止重复点击
- 防止重复生成数据,例如导出excel
不适合场景。
-
强一致性场景,例如转账
-
长时间的任务,缺点是锁续期复杂,故障恢复困难,更推荐使用MQ消息
-
防止重复消费,该场景下可以使用分布式锁,但是更推荐采用如下方式:
- 消息幂等
- 去重表
- 唯一约束
-
秒杀场景,也不推荐采用redis 分布式锁,主要原因如下:
-
秒杀场景的特点如下:
- 瞬时流量巨大
- 成功率极低(例如100万人抢100件商品)
- 请求大部分注定失败
-
因此采用分布式锁并没有消除服务的请求压力,会导致请求排队。而秒杀场景需要的是快速失败。大量的请求排队,会占据应用资源,拖垮应用服务器。
-