今天看到了这样一段代码
//同一个人导出功能生成中的,则报错
String redisLock = "/payment/exportOrderOfTaobaoPayment::" + userId;
本意是希望页面的导出功能同一个用户在上一次导出成功之前无法再次导出,防止频繁导出影响系统稳定,本意是不错,对于这种场景也能保证基本的使用,但这redis锁的实现方式就真有点一言难尽了。
如果不用Redisson,怎实现一个正确的redis锁。
上面的代码最大的问题是判断key存不存在与set key不是原子操作,多个请求可能同时申请到key,对代码进行改造
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
setnx保证了原子和互斥,如果key已经存在则会返回失败。
requestId当前场景可以用userId,用户不能加和释放其他用户的锁。防止误删锁。原代码是拼在key里的,一个道理。
expireTime过期时间是为了防止系统突然崩溃导致锁无法释放的问题。
加锁代码改造完后看看原有的放锁代码
这里放锁其实没有太大问题,采用异步导出,如果超过了限制时长则会抛出超时异常,而且不管导出是否执行成功,最终都会释放锁。而且前面说到userId已经拼在了key里,不会释放其他用户的锁。但仔细看了超时时间我突然发现导出的超时时间和redis锁的超时时间不一样。
可能当初作者觉得导出功能确实比较耗时,但又不想让用户每次等待太长时间,所以锁时间设置了十分钟,导出超时设置了30分钟,这样即使上一次导出没有完成,用户也可以进行下一次导出,对系统也相对友好,而且我也试了下功能,超过十分钟的导出确实比较少。
但这也带来了另一个问题,假设用户第一次导出10分钟没有完成,但锁过期已经释放,这时用户已经发起第二次导出,加锁成功。但如果第一次导出在11分钟成功了,这时会放锁,由于导出功能锁粒度只到用户userId,所以会把第二次导出的锁给释放掉造成误删锁。
还好导出功能对锁的要求不是那么高,这里可以加一个时间判断,如果业务执行时间超过了十分钟就不再执行放锁逻辑。
当然,如果把requestId即这里的userId当做key的值,就得用下面这段代码放锁
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
至此对于导出场景的redis锁已经实现,而且一般并发场景也能使用。
但对于下面这种情况,这种方式还是有点问题。
通常业务我们还会这么写,先查缓存,如果缓存不存在则去查数据库,查到了再回写缓存。但高并发场景下,缓存失效,大量的请求落数据库可能直接宕机。
这种情况下我们会加一层redis,获锁的线程才能去查库,但其他的请求该怎么办,毕竟数据存在,返回空也不正确,这时我们就可以加一层自旋。
try {
Long start = System.currentTimeMillis();
while(true) {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
//查数据库,加入缓存
return true;
}
long time = System.currentTimeMillis() - start;
if (time>=timeout) {
//超出规定时间还没获取数据就返回
return false;
}
try {
Thread.sleep(50);
//睡眠一段时间后去查询缓存是否有值
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally{
unlock(lockKey,requestId);
}
return false;
这样每次只会有一个线程去查库更新缓存,数据库就没有这么大的压力。
当然这是单点情况,如果是集群还是直接用redisson吧。