之前开发一个视频聊天室服务,碰到许多服务器端高并发性能的问题,所以进行了一些这方面的学习。服务器端在收到客户端过多的请求时,往往会因为过高的cpu或者内存消耗而宕掉。这就有一个原则是服务端要能自我保护,宁可提供受损的服务,也不能不提供服务。在开发高并发系统时有三把利器来保护系统:缓存、降级和限流。
缓存的目的是提升系统访问速度和增大系统能处理的容量。项目中有很多地方用到了缓存。其中一个场景是,客户端会上传一个信令表明身份,而这个信令有专门的服务来维护,我们就需要拿着这个信令调用那个服务的http接口来验证,当并发较多时,http请求就有可能拥堵。考虑到这个信令的存活时间较长,就可以在redis中做一下缓存,定期清理。这样就减少了http请求次数。
降级是当服务出问题或者影响到核心流程的性能时,需要暂时屏蔽掉一些服务,待高峰或者问题解决后再打开。这个方法基本上每一个服务都用到了,高峰时或者cpu、内存占用较高时,减少日志记录、主动拒绝一些请求等等。
限流的目的是通过对并发访问/请求进行限制或者一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务或者排队等待。
令牌桶算法
令牌桶算法是一个存放固定容量令牌的桶,按照固定速率往桶里添加令牌。简单描述如下:
- 假设限制2r/s,则按照500ms的固定速率往桶中添加令牌;
- 同种最多存放b个令牌,当桶满时,新添加的令牌被丢弃或拒绝;
- 当n个请求到来时,从同种删除n个令牌,接着处理请求;
- 如果桶中的令牌不足n个,则拒绝后面的部分
漏桶算法
漏桶作为计量工具时,可以用于流量整形和流量控制。简单描述如下:
- 一个固定容量的漏桶,按照常量固定速率流出水滴;
- 如果桶是空的,则不需要流出水滴;
- 可以以任意的速率流入水滴;
- 如果流入水滴超出了桶的容量,则流入的水滴溢出,被丢弃。
对比这两个算法
- 令牌桶是按照固定速率往桶中添加令牌,请求是否被处理需要看桶里令牌是否足够,当令牌数是零时拒绝新的请求。
- 漏桶是按照常量固定速率流出请求,流入请求速率任意,当流入的请求数量累计到漏桶容量时,则新流入的请求被拒绝。
- 令牌桶限制的是单位时间内的平均流入速率,但是允许突发请求
- 漏桶限制的是流出速度,也就是处理速度,碰到突发请求,也是按固定速率处理这些请求,平滑突发流入速率
我们这次优化只用到了漏桶算法。其实实现很简单,就是用到一个队列。队列的一端不断添加请求,另一端按照固定的速率,例如每秒1个,来从队列中读取请求处理。如果队列满了,可以选择一种策略来处理新来的请求,一是直接丢弃新请求,二是覆盖旧的请求。这个队列也可以改为用栈来实现,具体情况具体分析。
计数器限流
主要用来限制总并发数,对全局总请求数或者单位时间内的总请求数进行限流。这个方法比较简单。
应用级限流
- 限制总并发/连接/请求数。例如Tomcat、Nginx、Redis可以对线程数、连接数做限制。
- 限制总资源数。如果有的资源是稀缺自然,而且可能有多个系统都会使用它,那么需要限制应用。最常用的是池化技术,线程池或者连接池。
- 限流某个接口的总并发/请求数。在Java中可以用AtomicLong进行计数限制。
- 限流某个接口的时间窗请求数。这和上面的相似,可以用一个计数器进行限制。
- 平滑限流某个接口的请求数。上面的两种方法都不能很好的应对突发请求,但有时候会需要对突发请求做一些平滑整形。这时候可以用令牌桶或者漏桶算法。
分布式限流
分布式限流最关键的是讲限流服务做成原子化,而解决方案可以使用redis+lua或者nginx+lua。我对nginx不熟,而且redis性能很好,所以偏向于使用redis。
local key = KEYS[1] --限流KEY(一秒一个)
local limit = tonumber(ARGV[1]) --限流大小
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --如果超出限流大小
return 0
else if current == 1 then --设置2秒过期
redis.call("INCRBY", key, "1")
redis.call("EXPIRE", key, "2")
else --请求数+1
redis.call("INCRBY", key,"1")
return 1
end