分布式锁的使用场景?
使用分布式锁必须要满足以下条件:
(1)系统是一个分布式系统,java的锁已经锁不住共享资源了;
(2)操作共享资源;
(3)同步访问,即多个进程同时操作共享资源;
分布式锁使用场景示例:
消费积分在很多系统里都有,信用卡系统、电商网站等通过积分换礼品等,这里「消费积分」这个操作典型的需要使用锁的场景。
「事件A:」以积分兑换礼品为例来讲,完整的积分消费过程简单分成3步:
A1:用户选中商品,发起兑换提交订单。
A2:系统读取用户剩余积分,判断用户当前积分是否充足。
A3:积分充足则扣掉用户积分,兑换商品并更新用户积分。
「事件B:」系统给用户发放积分也简单分成3步:
B1:计算用户当天应得积分(如根据用户当天的消费额)。
B2:读取用户原有积分。
B3:在原有积分上增加本次应得积分,更新用户积分。
那么问题来了,如果用户消费积分和用户发放积分同时发生(同时对用户积分进行操作)会怎样?
假设用户有1000积分(记录用户积分的数据可以理解为「共享资源」),本次兑换要消耗掉900积分。
不加锁的情况:事件A在执行到第A2读积分时,A2操作读到的结果是1000分,判断剩余积分够本次兑换,紧接着要执行A3操作扣除积分(1000 - 900 = 100),正常结果应该是用户还是100分。但是这个时候事件B也在执行,本次要给用户发放100积分,两个线程同时进行( 同步访问共享资源积分数据 ),不加锁的情况,就会有下面这种可能,A2 --> B2 --> A3 --> B3 ,在A3尚未完成前(扣积分,1000 - 900),用户总积分被事件B的线程读取了,在执行B3之后,最后用户的总积分变成了1100分,还白白兑换了一个900积分的礼物,这显然不符合预期结果。
Java本身提供了两种内置的锁的实现,一种是由JVM实现的synchronized 和 JDK 提供的 Lock,以及很多原子操作类都是线程安全的,当你的应用是单机或者说单进程应用时,可以使用这两种锁来实现锁。
但是当下互联网公司的系统几乎都是分布式的,这个时候Java自带的 synchronized 或 Lock 已经无法满足分布式环境下锁的要求了,因为代码会部署在多台机器上,内存数据不是共享的,为了解决这个问题,分布式锁应运而生,分布式锁的特点是多进程,多个物理机器上无法共享内存,常见的解决办法是基于内存层的干涉,常见的落地方案有基于Redis的分布式锁和ZooKeeper分布式锁。
常见的分布式锁有哪些解决方案?
- 基于Memcached的分布式锁,利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add 成功,也就意味着线程得到了锁。
- 基于Redis Cluster的分布式锁,和 Memcached 的方式类似,利用 Redis 的 setnx 命令。此命令同样是原子性操作,只有在 key 不存在的情况下,才能 set 成功。
- 基于Zookeeper 集群的分布式锁,利用 Zookeeper 的顺序临时节点,来实现分布式锁和等待队列。Zookeeper 设计的初衷,就是为了实现分布式锁服务的。
- 基于数据库的分布式锁,如MySQL。通过主键id的唯一性进行加锁,加锁的形式是向一张表中插入一条数据,该条数据的id就是一把分布式锁,例如当一次请求插入了一条id为1的数据,其他想要进行插入数据的并发请求必须等第一次请求执行完成后删除这条id为1的数据才能继续插入,实现了分布式锁的功能。
Redis分布式锁的实现方法?
(1)使用setnx命令加锁:
public static boolean getLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
//加锁并设置过期时间,保证操作的原子性
if(1 == jedis.setnx(lockKey, requestId, expireTime)) {
return true; //加锁成功
}else {
return false; //加锁失败
}
「代码解释:」
- setnx命令(set if not exist),如果lockKey不存在,把lockKey存入redis,保存成功后返回1,表示设置锁成功,返回不是1表示设置锁失败,说明别的进程已经设置过此资源的锁了。
- expireTime设置过期时间,防止死锁。如果加锁和设置锁的过期时间两个操作是分开进行的,非原子性的操作,会存在一种情况:当分布式中节点1刚设置完锁还未设置过期时间,就挂掉了,就会造成资源的死锁其他资源也不能访问。
(2) 使用的del命令解锁:
public static void unLock(Jedis jedis, String lockKey, String requestId) {
//第一步:使用requestId判断加锁与解锁是不是同一客户端
if(requestId.equals(jedis.get(lockKey))) {
// 第二步: 若在此时,这把锁突然不是这个客户端的,则会误解锁
jedis.del(lockKey);
}
}
「代码解释:」
通过 requestId 判断加锁与解锁是不是同一个客户端和 jedis.del(lockKey) 两步不是原子操作,理论上会出现在执行完第一步if判断操作后锁其实已经过期,并且被其它线程获取,这是时候再执行jedis.del(lockKey)操作,相当于把别人的锁释放了,这是不合理的。当然,这是非常极端的情况,如果unLock方法里第一步和第二步没有其它业务操作,把上面的代码扔到线上,可能也不会真的出现问题,原因第一是业务并发量不高,根本不会暴露这个缺陷,那么问题还不大。
代码改进:
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
//释放锁脚本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
//执行脚本释放,保证原子操作
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (1L.equals(result)) {
return true;
}
return false;
}
通过 jedis 客户端的 eval 方法和 script 脚本一行代码搞定,解决上述方法中的原子问题。