作者:三哥
网站:j3code.cn
入职新公司有一段时间了,也渐渐的开始着手实现领导分配给我的需求了。其中有个需求实现起来稍微有点复杂,就是需要确保生成的数据中,数据编号在当天是唯一的,且同一父级下面的子级数据的序号是唯一的。
解释一下两个关键点:
1、当天编号唯一,编号生成策略是年月日 + 六位自增数字(整个系统唯一)
2、同父级的子级中序号唯一,序号生成就是阿拉伯数字自增(同一父级唯一)
简单理解之后,我能想到的就是加分布式锁来实现这个需求了,也就是说我在生成编号之前加一把锁,在生成序号的时候也加一把锁,这不就能实现需求了嘛!真是机制如我😀。
正好我接手的系统中封装了分布式锁的工具类,所以我只需要按照如下的伪代码进行实现即可:
public Boolean insert(){
// 第一次加锁(同一父级层面的锁)
1、访问数据库,确定好最新的序号并加一,设置到数据中
// 第二次加锁(整个系统层面的锁)
2、访问数据库,确定好最新的编号并加一,设置到数据中
// 第二次解锁
3、保存数据
// 第一次解锁
}
ok,这里我没有将序号和编号生成的细节给写出来,这也不是我们关注的重点。也不用纠结这里为什么加两把锁,你可以理解第一次加锁是我需要控制序号唯一,而至于第二次加锁,是调用生成编号的方法,这个方法内部加了一把锁,确保当天生成的编号唯一,所以就是加了两把锁。
功能写好之后,经过我的测试发现,第一次保存是没有问题,数据正常保存,但是第二次保存的时候,问题就出现了,提示一直获取不到锁,这我就纳闷了~
为什么获取不到锁呢!明明第一次保存数据都成功了,按理说该释放的锁肯定都是放了呀,此时我是完全没有怀疑是这个分布式锁工具问题。当我 DEBUG 到获取锁的代码处,发现确实获取不到锁,最后为了最终确认锁是否依然存在,我直接通过客户端连接 Redis 数据库,发现 key 确实存在,肯定获取不到锁。
奇怪,真是奇怪!
既然这样,那我就手动将 key 删除了,再一次进行 DEBUG。这次我是一步步的进行排查,从第一把锁是怎么加锁的,到最后怎么释放锁的,全都一步步走了一遍,终于是找到问题的关键点了!!!!!!
就是这个分布式锁工具类有问题,真是一个大坑,被我踩中了~
下面是我简化后的工具类代码,不知道你们可以找到问题的关键地方不:
@Slf4j
@Component
public class RedisLock {
/**
* 通过lua脚本释放锁,来达到释放锁的原子操作
*/
private static final String UNLOCK_LUA = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
private ThreadLocal<String> lockFlag = new ThreadLocal<>();
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public Boolean lock(String lockKey, long expireTime) {
String uuid = UUID.randomUUID().toString();
lockFlag.set(uuid);
return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey, uuid, expireTime, TimeUnit.MILLISECONDS));
}
public Boolean unlock(String lockKey) {
// 释放锁的时候,有可能因为持锁之后方法执行时间大于锁的有效期,此时有可能已经被另外一个线程持有锁,所以不能直接删除
// 创建DefaultRedisScript实例,并指定返回类型
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(UNLOCK_LUA);
redisScript.setResultType(Long.class);
Long resultSize = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), lockFlag.get());
return resultSize == 0 ? Boolean.FALSE : Boolean.TRUE;
}
}
关键点:ThreadLocal
这里,我解释一下为什么加锁和解锁都用到 ThreadLocal 。当线程 A 执行加锁逻辑后,会将线程 A 生成的 UUID 存入 ThreadLocal 中,当然线程 B 也是如此。之后当线程 A 进行释放锁的时候,那么线程 A 在进行 key 删除的时候,会比较 key 中存入的 value 和 Lau 带过来的 value (从 ThreadLocal 中获取)参数是否一致,如果一致才允许删除,这样做的目的是放置其它线程误删别的线程 key 导致锁失效。
再清楚其作用后,是不是有一种成也萧何败萧何的感觉。虽然你解决了其它线程误删的问题,但是不是又暴露了同一个线程多次加锁导致只能是否最近的一次加锁,其它的锁释放不掉的问题呢!(大家细品)
这里,我画个流程图,帮助大家理解:
哎,咋办呢!这里,我不能说这个工具类有问题,只能说我的用法不对(是我不会用)。当然,我也想对这个工具类进行一点修改,就能达到我所需要的效果,但是改是不可能改的,这辈子都不可能。
为啥!
我只是个新兵蛋子,万一因为我的一点改动,导致整个系统出现问题,我是不是要背锅。
最后,没办法了,只能加一次锁了,那另一个唯一值如何保证呢!伪代码如下:
public Boolean insert(){
1、访问数据库,确定好最新的序号并加一,设置到数据中
// 第一次加锁(整个系统层面的锁)
2、访问数据库,确定好最新的编号并加一,设置到数据中
// 第一次解锁
try {
3、保存数据
} catch (DuplicateKeyException e) {
// 序号重复了,抛错
}
}
是的,我在数据库层面加了唯一键,通过数据库层面确保数据的唯一性。
在结束的最后,我们来分析分析如何调整上面的分布式锁工具,来达到同一个线程多次加锁,都能够将其释放掉。
这里,我的做法是,在进行加锁的时候,先判断 ThreadLocal 中是否有值,如果有则使用存在的值,如果没有在进行生成,这样就可以达到我们想要的目的啦~
当然了,在认真阅读这个分布式锁工具类的时候,发现这个工具写的确实是很简单,而且使用场景还非常单一,即同一个线程只能加一次锁。可能以前这个项目使用分布式锁的场景就不是复杂,所以没有对这个工具类做相关的升级,或者说不了解市面上的一些开箱即用的分布式锁组件(Redisson)从而没有进行项目进行使用。
我以前自己都写过分布式锁的工具类,有锁失效、续约、重入,等功能,可以是写的非常完善了,大家有兴趣的话,可以访问下面连接进行了解。
好了,今天就简单和大家聊到这里了,其实我工作中碰到的问题还有很多:多数据源事务不生效、分页溢出(前端 or 后端 问题)等。但碍于时间实在是有限,每天到家都是快 22 点了,视频 和 文章更新只能是随缘了~