【todo..】Redis分布锁处理并发问题

27 阅读4分钟

1. 问题背景

我们项目中会有一些场景会出现并发问题,比如

用户微信支付完成后的【核验流程】,该核验流程简单归为 两查一写操作

两查就是

  • 支付渠道的查询
  • 某中台的查询

一写就是

  • 将结果通过http post json 交互形式 写入某外部系统

那并发体现在哪里呢?

  • 支付渠道下单完后,我们会把订单id放入延迟队列中,延迟队列取出进行【核验流程】
  • 渠道结算完会跳到我们的结果页,会触发这个【核验流程】
  • 支付平台会通知我们系统进行【核验流程】

2. 技术选型

解决并发的话,我们常常会使用本地锁来解决。比如是同一个JVM虚拟机内,使用synchronized或者Lock接口就可以保证同一时刻只有一个客户端可以对共享资源进行操作。

为了考虑到后续服务集群扩容化,本地锁就不满足需求了,也就是多个JVM的情况了,那这时候就是要引入分布式锁来解决并发问题。

分布式锁的实现方式比较常见有

  • 数据库
  • Redis
  • Zookeeper (zk我不是很熟悉了解,所以我先对数据库、redis技术选型进行分析)
2.1. MySQL数据库

方案1:数据库可以创建一张表,加锁就是一条记录,解锁再把记录删除。

方案2:InnoDB引擎利用唯一索引for update进行排他锁,行锁。

放弃原因:

使用数据库方案比较占用数据库连接,会影响其他业务,给数据库造成压力(我们平常

使用redis缓存就是为了减轻数据库压力)。

MySQL自己内部会进行优化,如果MySQL认为全表扫描效率更高就不会使用索引,就不会是行锁而是表锁。

2.2. Redis

因为项目已经引入Redis了,而且Redis性能优于MySQL所以我门用Redis来实现分布锁。

3. 使用Redis实现分布锁

3.1. 加锁的过程

我们使用 setnx 命令(set if not exists),这个命令是具有原子性的。

意思就是redis会在内存中检查key是否存在。

  • key 不存在,设置key指定的值,返回1。
  • key 存在的话,直接返回0。

为了防止死锁,需要给这个key设置过期时间。

为了防止把别人的锁误删,需要设置一个客户端唯一值。

这些命令需要保证原子性,使用lua脚本。

//加锁脚本

private static final String LOCK_SCRIPT =

"if redis.call('setNx',KEYS[1],ARGV[1]) then if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end end";

RedisScript redisScript = new DefaultRedisScript<>(LOCK_SCRIPT, Long.class);

Object result = redisTemplate.execute(redisScript, Collections.singletonList(key), value, expireTime);

该脚本使用SETNX命令尝试为给定的键(KEYS[1])设置一个值(ARGV[1]),如果设置成功,则检查当前键的值是否与设置的值相等,如果相等,则使用EXPIRE命令为该键设置过期时间(ARGV[2]),并返回1,表示加锁成功;如果不相等,则返回0,表示加锁失败。

其实在写 Redis 抽象模板方法中,发现也可以不用设置唯一标识。只要获得锁,进入处理业务的方法执行完的外层加finally去解锁就不会被别人解锁。意思就是只有获得锁的人才能删。

3.2. 解锁

private static final String UNLOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

RedisScript redisScript = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);

Object result = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), "唯一标识");

3.3. 问题

虽然我们使用了 setnx expire lua 脚本实现了Redis分布式锁,但是后来会经常出现一些问题,比如我们设置解锁的超时时间太短,导致锁提前释放。设置过长会使得接口吞吐量降低。之后我了解到 redisson 实现了看门狗机制,会自动帮忙做续期,而且 redisson 也支持可重入。

3.4. 解决

之后引入redisson解决了续期的问题,看了下源码 redisson 锁释放内部会判断是否当前锁。

4. Redisson

Redission 分布式锁的 结构是 hash,key field value。其中value为锁定的次数,Redission支持可重入。

4.1. 加锁

加锁成功 返回 null,加锁失败 返回过期时间。

4.2. 解锁

  • 未持有锁 返回 null 【解锁失败】
  • 持有锁数量-1还剩余 返回0 【未解锁】
  • 持有锁数量-1 = 0,删除key,发布解锁的消息通知阻塞等待的线程,返回1【已解锁】
4.3. WatchDogggggg

Redisson引入watchDog机制帮助Redisson实例被关闭前不断地延长锁的有效期。

WatchDog只有在加不过期的锁才会存在,基于netty的一个后台定时任务,默认情况是每10s做一次续期,续期时长30s。锁被释放会自动停止对应的续租任务。

4.4. 解锁里面的cancelExpirationRenewal

completionStage handle 方法允许你在前一个操作无论是成功还是失败还是抛出异常都能够执行以下逻辑。

所以解锁操作都会执行 cancelExpirationRenewal 方法。也就是说 解锁不管怎么样都会把本地的续期任务停止。

5. RedLock

todo .....