一、需求

酒店资源系统,在下单和查询报价的时候,会调用第三方供应商系统。因用户较多,订单量较大、QPS较高的背景,所以资源系统采用集群部署方式,部署了12台机器,使用nginx做负载均衡,均匀打在每个节点上。但是,为了系统性能因素,供应商接口添加了频次限制,每分钟不能超过2000次。

二、需求分析

资源系统一分钟之内,所有的服务节点加起来的调用供应商接口请求数不能超过2000。

  • 如果是单节点的话很容易实现,只需要加一个计数器,超过范围即停止访问。即使在并发较高的时候,只需要给计数器加一个同步锁,保证同一时间内一个方法只能被一个线程所执行即可。
  • 但是多节点的话,不同节点的请求不属于一个进程,也就不属于一个jvm。在分布式环境下,加锁机制无法对不同机器的线程共享,也不可见。无法保证保证数据的最终一致性。
因此需要添加一个分布式锁,来保证在同一时间内,一个方法只能被一台机器下的一个线程执行。

三、分布式锁应该具备哪些条件

(1)保证在同一时间内,一个方法只能被一台机器下的一个线程执行。
(2)高可用的释放锁和获取锁
(3)高性能的释放锁和获取锁
(4)具备可重入性(可理解为重新进入,一个线程多次调用同一个方法,而不必担心数据错误)
(5)具备锁机制失效,防止死锁
(6)具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败

四、常见的分布式锁的实现

  • Memcached:利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add 成功,也就意味着线程得到了锁。
  • Redis:和 Memcached 的方式类似,利用 Redis 的 setnx 命令。此命令同样是原子性操作,只有在 key 不存在的情况下,才能 set 成功。
  • Zookeeper:利用 Zookeeper 的顺序临时节点,来实现分布式锁和等待队列。Zookeeper 设计的初衷,就是为了实现分布式锁服务的。

五、基于Redis分布式锁的实现原理

1.加锁

最简单的方法是setnx命令,如果key不存在,说明该线程成功获取到锁,添加锁,返回1。如果key存在,说明抢锁失败,返回0。如获取酒店房态接口,key=hoteld + checkIn + checkOut + 入住人数num,
伪代码如下:

setnx(key,value)

2.解锁

有加锁就得有解锁。当得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式是执行 del 指令
伪代码如下:

del(key)

3.锁超时

如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住(死锁),别的线程再也别想进来。所以,setnx 的 key 必须设置一个超时时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放。setnx 不支持超时参数,所以需要额外的指令,伪代码如下:

if(setnx(key,value) == 1){
    expire(key,30)
    try {
        do something ......
    } finally {
        del(key)
    }
}

上述代码存在的致命问题:

现象一:setnx和expire的非原子性

·
节点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”
现象二:del导致误删

假如某线程获取到锁之后,设置超时时间为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”
现象三:并发的可能性,扔存在节点2和节点1同时访问一个资源的可能性

与现象二类型相似
节点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();
    }