酒店资源系统,在下单和查询报价的时候,会调用第三方供应商系统。因用户较多,订单量较大、QPS较高的背景,所以资源系统采用集群部署方式,部署了12台机器,使用nginx做负载均衡,均匀打在每个节点上。但是,为了系统性能因素,供应商接口添加了频次限制,每分钟不能超过2000次。
资源系统一分钟之内,所有的服务节点加起来的调用供应商接口请求数不能超过2000。
(1)保证在同一时间内,一个方法只能被一台机器下的一个线程执行。
(2)高可用的释放锁和获取锁
(3)高性能的释放锁和获取锁
(4)具备可重入性(可理解为重新进入,一个线程多次调用同一个方法,而不必担心数据错误)
(5)具备锁机制失效,防止死锁
(6)具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败
最简单的方法是setnx命令,如果key不存在,说明该线程成功获取到锁,添加锁,返回1。如果key存在,说明抢锁失败,返回0。如获取酒店房态接口,key=hoteld + checkIn + checkOut + 入住人数num,
伪代码如下:
setnx(key,value)
有加锁就得有解锁。当得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式是执行 del 指令
伪代码如下:
del(key)
如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住(死锁),别的线程再也别想进来。所以,setnx 的 key 必须设置一个超时时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放。setnx 不支持超时参数,所以需要额外的指令,伪代码如下:
if(setnx(key,value) == 1){
expire(key,30)
try {
do something ......
} finally {
del(key)
}
}
·
节点1执行完setnx,成功获取到锁之后,还未来得及执行expire,节点1挂掉了,这样一来就没有设置过期时间。其他节点也就无法获得这把锁,造成死锁
解决办法:
用set指令代替setnx,或者使用lua脚本,保证任务的原子性
set(lock_sale_商品ID,1,30,NX)
lua脚本:
eval:“if redis.call('set',KEYS[1],ARGV[1]) then return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end”
假如某线程获取到锁之后,设置超时时间为30秒,但是由于某些原因业务执行的很慢,30秒都没有执行完。这时候导致节点1释放锁,节点2获取到锁。节点1在执行完业务代码之后,仍然会执行del操作,实际上这时候删除的是节点2的锁。
解决办法:
在执行del删除之前,加一个值判断,验证当前的锁是不是自己加的锁。
lua脚本:
eval: "if redis.call('get',KEYS[1]) == ARGV[1] then return
redis.call('del',KEYS[1]) else return 0 else”
与现象二类型相似
节点1:获取锁,执行业务代码,超过超时时间
节点1:释放锁
节点2:获得节点1释放的锁
节点1:执行完业务代码,,释放锁(删除节点1的key)
节点2:释放锁
此时避免了节点1不释放节点2的锁,但是并不能避免节点2获得节点1因执行时间过长释放的锁。这样也不是我们想要的结果。
解决办法:
(1)释放失败节点1业务回滚记录日志,
(2)节点2启用一个守护进程对超时时间的续期,从而防止在业务未执行完成时错误获得锁。另外守护进场续期次数应该有一定限制,避免长时间占用资源。
lua脚本:
eval:" if redis.call('set',KEYS[1],ARGV[1]) then if redis.call('expire',KEYS[1],ARGV[2])
end local ttlTime = redis.call('ttl',KEYS[1]) if ttlTime == -1 then
redis.call('expire',KEYS[1],ARGV[2]) end”
回到本次需求,想用redis的自增计数器来达到频次限制的需求
每次在调用供应商方法之前,使用redis+incr命令,对存储在指定key的数值执行原子的加1操作。
如果指定的key不存在,那么在执行incr操作之前,会先将它的值设定为0。来进行频次限制的代码:
KEYS[1]:key
ARGV[1]: 过期时间
ARGV[2]:频次限制
lua脚本:
“local count = redis.call('incr',KEYS[1]) if count == 1 then redis.call('expire',KEYS[1]
,ARGV[1]) end local ttlTime = redis.call('ttl',KEYS[1]) if ttlTime == -1
then redis.call('expire',"KEYS[1] , ARGV[1]) end return count”
使用AOP环绕切面来动态设置key,value,以及频次限制,redis来实现分布式锁的
@RedisAPILimit(apiKey = “Key”,limit = count,sec = 60)
public String getCtripRatePlan(Req req) throws Exception {}
自定义注解:
@Target(value = {ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisAPILimit {
//限制数量
int limit() default 5;
//标识 时间段 5秒
int sec() default 5;
String apiKey() default "";
}
AOP+Redis:
@Around("@annotation(redisAPILimit)")
public Object around(ProceedingJoinPoint proceedingJoinPoint,RedisAPILimit redisAPILimit) throws Throwable {
if (redisAPILimit == null) {
return proceedingJoinPoint.proceed();
}
int limit = redisAPILimit.limit();
int sec = redisAPILimit.sec();
String apiKey = redisAPILimit.apiKey();
Integer currentCount = (Integer)redisTemplate.opsForValue().get(apiKey);
if (currentCount != null && currentCount>limit) {
Long expire = redisTemplate.getExpire(apiKey, TimeUnit.SECONDS);
log.info("{} 到达限制,查询次数{} ,限制频次为:{} ,剩余时间{} 秒",apiKey,currentCount,limit,expire);
//这里可以抛自定义异常,或者设置休眠
//throw new CtripException
return null;
}
String script = "local count = redis.call('incr',KEYS[1]) if count == 1 then redis.call('expire',KEYS[1] , " +
"ARGV[1]) end local ttlTime = redis.call('ttl',KEYS[1]) if ttlTime == -1 then redis.call('expire'," +
"KEYS[1] , ARGV[1]) end return count " ;
RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Long incNum = (Long) redisTemplate.execute(redisScript, Collections.singletonList(apiKey),sec);
return proceedingJoinPoint.proceed();
}