先描述下基本场景:
系统API接口日均调用次数预计1亿次,提供5台服务器。
需要做两种层面的控制:
> 单IP、单应用每小时调用次数不超过10000次
> 单应用、单用户、单接口每小时调用次数不超过1000次
要求每次对频控系统的调用的响应时间在20ms内。
此外,应用开发者和开放平台所属公司关心调用次数统计数据,如当天某应用所有接口被调用总次数、当天某应用某接口被调用次数、当天某应用用户使用数等。
根据上面,我们可以直接得到系统响应度要求和计算得到系统吞吐量要求,计算公式如下:
1
2
|
频控系统吞吐量(系统每秒能够处理的请求数)
= 80% * 1亿 / (24小时 * 60分钟 * 60秒 * 40% * 5) = 4630tps
|
80%、40%是指一天中有80%的请求发生在40%的时间内,是粗略的估算值。5是服务器数量。所以得到吞吐量要求为4630tps。前期设计系统时必须参考这些性能指标,后期压测系统时必须根据这些指标设计测试计划。
总结下系统设计需要达成的目标:
-
请求的响应足够快
-
能支撑4630tps
-
占用的CPU、内存等硬件资源不能太夸张(隐性设计目标)
A、数据结构设计
计数是典型的key-value数据结构。
可能想到的最简单最自然的方式是下面这样的:
1
2
|
K(app_id, ip) => V(count, startTime, lastTime)
K(app_id, uid, interface_id) => V(count, startTime, lastTime)
|
startTime记录的是第一次调用的发生时刻,lastTime记录的是最近一次调用的发生时刻,它们用来判断是否应该重置计数值count和是否该拒绝调用。
为了节省内存,有必要对key和value做特殊设计,最次的方案当然是直接拼接上面各个字段。但这是非常不合理的,我们来简单估算下:
假设应用有10,000个,平均每个应用的用户数为100,000,接口数为50,独立访问IP地址为1,000,000,那么数据项总共为:
10,000 * 1,000,000 + 10,000 * 100,000 * 50 = 600亿
那么如果每个数据项节省1个字节,能够节省的总数据存储是600G,这是非常可观的。
对于Key,一种更优方案是先拼接Key的字符串,然后MD5得到32位定长字符串作为Key,Key定长的话或许对性能提升也会有一定帮助。
对于Value,count、startTime、lastTime信息不能丢失,那么或许可以考虑下面两种优化方案:
-
无损压缩Value字符串,比如使用Snappy字符串压缩算法,但压缩和解压缩会带来额外的CPU计算消耗,需要权衡
-
计数不需要太精确,所以可以牺牲一定精确度换取空间节省。或许我们可以利用 CountingBloomFilter?Key需要重新设计为:MD5(app_id, interface_id, 现在距离1970年1月1号的小时数),Value就是CountingBloomFilter数据结构了,每个调用先根据app_id、 interface_id、现在距离1970年1月1号的小时数计算32位MD5值,然后得到所属的CountingBloomFilter(如果没有就 创建),然后每次先检查是否已达到最大插入次数,如果是则直接返回,如果不是才插入。但 是我们别忘了一点:CountingBloomFilter支持最大重复插入次数为15,远小于这里的1000次和10000次。所以很残 酷,CountingBloomFilter不适合这种方案。但这是一个很好的起点,Value的数据结构虽然不能用 CountingBloomFilter,但或许可以用其他的优化数据结构,可以参考:http://blog.csdn.net/hguisu/article/details/7856239 ,还有一篇文章标题大概是用1k数据表示海量数据,找不到了
另外频率控制一般可以采用“令牌桶算法”,这里不再深入,可以参考:
http://en.wikipedia.org/wiki/Token_bucket
B、数据存储设计
考虑到性能要求,肯定需要用到Cache,这里打算选用Redis。再根据上面的估算,数据项总共有600亿,所以不可能把所有数据项全部放到Redis Cache中(假设每个Cache项占100个字节,估算下需要多少内存,O(∩_∩)O)。
所以我这里采用冷热数据分离方案。有这么三类数据:
-
冷数据存放在MySQL数据库,按照app_id、uid进行水平Shard
-
不冷不热数据采用Hash结构压缩存储在Redis,具体结构下面会提到
-
热数据放在另外的Redis库中,并且“展开式”存储以改善访问性能
先简单说下不冷不热数据的Redis Hash结构,Key是app_id,Field是MD5(ip, interface_id, uid, 现在距离1970年1月1号的小时数),Value包括两个计数值,即单IP、单应用已调用次数和单应用、单接口、单应用已调用次数。这样设计相当于把本来应该存成两项的数据合并到了一个缓存数据项中。
热数据的所谓“展开式”结构是指将上面两个维度的计数分开,即存成类似下面这两种结构:
1
2
|
K取MD5(app_id, ip, 现在距离1970年1月1号的小时数),V取一个长整型值表示计数
K取MD5(app_id, interface_id, uid, 现在距离1970年1月1号的小时数),V取一个长整型值表示计数
|
Redis Cache失效时间:
所有Redis Cache数据的失效时间设置为1小时到1小时1分钟之间的某个随机值,这样能某种程度上避免缓存集体失效引起的“雪崩”。
冷热数据迁移过程:
数据的冷热一直在发生着改变,所以冷热数据之间需要进行迁移。
第一种方案是由后台进程定期将
-
热数据中符合冷数据标准的数据移动到不冷不热数据缓存
-
将不冷不热数据中符合热数据标准的数据迁移到热数据缓存
-
将不冷不热数据中符合冷数据标准的数据迁移到MySQL数据库
-
将MySQL数据库中符合不冷不热数据标准的数据迁移到不冷不热数据缓存
判断冷热的标准是基于每天计算一次的历史平均每小时调用次数。
第二种方案是在调用时主动进行迁移,基于最近50次调用的平均时间间隔来判断(也就是对于每一个数据项还要存储一个它最近50次调用的平均时间间隔),迁移过程同第一种。
C、借债机制
比如限制某应用某个接口单用户每小时调用次数不能超过1000次,那么是否严格按照1000次的标准来哪?
答案是否。因为用户调用接口的频率可能并不平均,比如像下面这样:
第1小时 | 第2小时 | 第3小时 |
1200 | 700 | 950 |
如果严格按照1000次标准来的话,那么第1小时内就有200次调用被拒绝。而如果采用简单的借债机制,即允许超出1000次往上300次,那么在第一小时内的1200次调用都能成功,但欠下200的债,第2小时的调用额度就变成800了,以此类推
D、滑动窗口算法
我最早听到滑动窗口算法是在大学的计算机网络课程上,它是TCP协议用来保证数据帧顺序和流量限制的一个算法。Storm中貌似也用到了滑动窗口算法来限制调用频率。所以或许可以迁移应用到这个系统的设计中。
滑动窗口算法可以参考:http://blog.csdn.net/thisispan/article/details/7545785