关于高并发下请求第三方API情景的思考和优化方案

众所周知 web API的使用已经越来越广泛了,比如令牌申请API,通过申请一次令牌,可以以这个令牌为身份凭证,调用其他的API。

本文记述的内容就是由“申请令牌”引申出来的。

各大网站对API的调用次数都作了限制,这是为了让开发者们不要写出'bad code',无意义地去请求api,造成网络资源的浪费。

以申请令牌为例,一个令牌的有效期一般为几个小时,在有效期内,我们是没必要去申请新令牌的。于是我们很自然地,会想到用缓存或者中转服务器来解决这个问题。

if(本地未缓存令牌){
    获取并使用新令牌,同时缓存它
}else{
    直接使用缓存的令牌
}

如上,我们用伪代码来简单的叙述这个存储逻辑。大部分令牌是有有效期限制的,但是我们也可以为我们的缓存也添加相应的有效期限制(应该略小于实际的有效期)。

下面我们来考虑一下这种逻辑在高并发下会有什么表现呢?

在本地缓存有效的情况下,不论并发多少个请求,都可以相安无事地去获取到缓存令牌。

目前为止,还不错。

当缓存刚好失效的瞬间,又会发生什么呢?那些同时运行的代码,都检测到缓存失效了,然后都去请求新的令牌,导致瞬间消耗大量的api请求次数。而一个令牌的有效期是N个小时,一天只需要申请若干次就够了,瞬间申请很多次,这明显是不合理的。

因次,我们必须对这种行为加以控制。这里使用高并发下进行协调的常用方法,两个字:加锁!

lock{
    if(本地未缓存令牌){
        获取并使用新令牌,同时缓存它
    }else{
        直接使用缓存的令牌
    }
}

如上,我们把整个获取令牌的动作,加一个排他锁,让同一时刻,这一段代码不会并发执行。也就是说,就算并发了一堆请求,执行到获取令牌这一步时,也是排着队来的,这样就不会发生明明有新令牌,还去申请新令牌的傻x事件了。

事情到了这一步貌似已经没啥问题了,但是我们群里的小伙伴们却并不容易被满♂足。他又提出了这种方案的不足:在缓存有效的情况下,由于这个排它锁,所有并发的请求在执行到这一段代码的时候,实际上是排队进行的,这就大大降低了并发数。

为了解决这个问题,偶又想了一套优化方案:

if(有缓存令牌){
    直接用,没毛病
}else{
    lock{
        获取并使用新令牌,同时缓存它
    }
}

这样检测到没有缓存的时候,再加锁,平时就不用加锁了,岂不是美滋滋?

(是很美,美如删库跑路。)

实际上这种方案是行不通的,想象一下同时有100个请求都正好碰到缓存失效,然后它们排队执行了刷新令牌的操作。。。嗯,我们的api使用次数-=100,蠢的不行。

当然,本人最后还是提出了一套不错的方案:

if(有缓存令牌){
    直接用,没毛病
}else{
    lock{
        if(有缓存令牌){
            直接用,没毛病
        }else{
            获取并使用新令牌,同时缓存它
        }
    }
}

现在我们再来想象一下100个请求都发现没有缓存的情况————
第一个获得排它锁的请求,刷新了令牌。
第二个请求得到排它锁,执行if(有缓存令牌)的时候,发现已经有缓存了,直接使用,并不去刷新。
第三个请求也是如此。。。
以此类推。。。

如此,在有缓存的情况下,并不去执行排它锁代码,这样不会影响并发效率。在缓存失效的情况下,第一个排队的请求去刷新令牌,其他排队的请求直接读取缓存令牌。总之,我们成功的保持了缓存有效场景的并发数。

下面贴出一部分实际测试的php代码(常见的MVC框架控制器代码)以及测试结果

public function index1()
{
    $this->testCache();
}
public function index2()
{
    $this->testCache();
}
private function testCache(){
    \Yuri2::echoTagP('测试开始');
    if (maker()->cache()->has($this->cacheKey)){
        \Yuri2::echoTagP('已有缓存:'.maker()->cache()->get($this->cacheKey));
    }else{
        \Yuri2::echoTagP('未发现缓存,加锁');
        maker()->locker($this->cacheKey)->exclusive(function (){
            if (maker()->cache()->has($this->cacheKey)){
                \Yuri2::echoTagP('已有缓存:'.maker()->cache()->get($this->cacheKey));
            }else{
                \Yuri2::echoTagP('刷新缓存');
                sleep(3);//这里卡顿3秒,来模拟网络传输的时间损失
                maker()->cache()->set($this->cacheKey,'666');
                \Yuri2::echoTagP('刷新后:'.maker()->cache()->get($this->cacheKey));
            }
        });

    }
    \Yuri2::echoTagP('测试结束');
}

public function clear(){
    \Yuri2::echoTagP('清楚缓存');
    maker()->cache()->rm($this->cacheKey);
    \Yuri2::echoTagP('缓存已清除');
}

执行结果如下

TIM截图20170504103637.png

TIM截图20170504103658.png

TIM截图20170504105908.png
TIPS:这里我使用index1和index2两种url的原因是:本地浏览器测试时,浏览器会自作主张地阻塞相同url的并发请求,造成请求排队的假象。(这个坑爹的设定浪费了我30分钟,一度以为是我的缓存策略有逻辑错误Orz)。

有时候几行代码就能带来极好的优化效果,这正是编程的魅力之一,不是吗?