分布式系统中,面对高并发场景,又对数据一致性有一定要求的情况下,使用分布式锁。例如商城中下单扣库存这种情况。
基于数据库
例如:
select * from mall_spu where id=111 for update
例如:专门建一张表用来实现。例如以类名、方法名、数据ID作为唯一主键,org.leo.mall.order.OrderServer.addOrder.skuId.111,方法执行的时候,如果能插入成功,代表拿到锁,如果报主键冲突,则拿锁失败。
基于Zookeeper
以类、方法、数据ID作为目录,请求取顺序节点&节点列表,如果自己的节点最小,说明拿锁成功。而且还可以通过watch,在锁释放的时候重新拿锁。因为是临时锁,所以主动释放,或者session失效都可以释放锁,避免死锁产生。
性能差点,因为Zookeeper的操作都在主节点上。
基于redis
本文主要讲讲应用的一些变革。
1、加锁。
原来的做法是:
public static boolean getLock(String key,int expireTime){
Long result=RedisClient.setnx(key,"");
if(result!=1){return false}
RedisClient.expire(key,expireTime);
return true;
}
setnx加锁,成功后用expire加上超时时间。
问题在于:sennx和expire不是原子操作,万一expire的时候崩了,这条命令永远不过期了。
所以后来基于Redis的升级,有了下面正确的加锁方法:
public static boolean getLock(String key,String requestId,int expireTime){
String result=RedisClient.set(key,requestId,"NX","PK",expireTime);
if(result.equals("OK")){return true;}
return false;
}
其实就是用Redis提供的一条set命令,替代了前面的setnx、expire两条命令,保证了原子性。
NX是指Key不存在就新增。PX是指设置超时时间。
requestId是为了后面解锁用。
2、解锁
解锁看着最简单,其实蛮复杂。
脑子里第一想法就是:
public static void releaseLock(String key){
RedisClient.del(key);
}
这个危险性在于任何人都可以解锁!比如A请求加了锁:spu_id_111。B请求也要对111进行操作,一看锁被占了,直接del,然后自己拿锁——虽然在程序开发上讲,没有哪个傻子会这么干!!
所以这才有了第二种做法:
A请求加锁的时候,通过UUID、Random等方法生成随机数requestId。
public static void releaseLock(String key,String requestId){
String result=RedisClient.get(key);//步骤1
if(result.equals(requestId)){//步骤2
//二者相等,说明加解锁的请求是同一个
RedisClient.del(key);//步骤3
}
}
看似很严谨,但是问题出在哪呢?还是出在操作不是原子性上。
A请求执行步骤1、2完毕,还未执行步骤2时,锁过期了,自动解锁!这时B请求加锁必然成功,而A请求继续执行步骤3,把B请求的锁给删了。
正确的做法如下:
public static boolean releaseLock(String key,String requestId){
String luaCommand="if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
List<String> keyList=Lists.newArrayList(key);//这里我用的是Guava
List<String> valList=Lists.newArrayList(requestId);
Object result=RedisClient.eval(luaCommand,keyList,valList);
if(result.equals(1L)){return true;}
return false;
}
利用的就是Redis通过eval命令执行LUA脚本是原子性的特性。
再然后就是使用Redisson实现了,这个适用于集群部署的Redis。
我在实际使用Redis分布式锁的时候遇到过一种情况。使用分布式锁后,要调用第三方接口,从而导致整个流程时间偏长,锁过期的情况下还没有执行完,当时的处理方式是加大了过期时间。
如果使用Redisson,因为有看门狗机制,就很好地解决了这个问题。看门狗会定时去检查,如果请求实例还在则自动去延长超时时间。不过这带来的问题一定是性能的下降,所以当时我们还是采用了粗暴的延长设置过期时间来解决此类问题。
Redisson也是个可重入锁,因为锁的内容除了key、实例ID之外还有数字Value,这样一来同样的实例多次拿锁,Value+1,释放锁,Value-1即可。