python热更新的随笔

现有的热更新比如IPython的autoreload.py、PyDev的pydevd_realod.py
但是这两个都有缺陷。
姑且认为有 old 和 new 这两个目录分别有着热更新前后的代码,old正在运行中
python的内置的reload做了什么
将指定模块的源码重新编译了一遍,并执行可执行的代码
相当于把xxx.XXX全部重新赋值了一遍
from xxx import XXX 是无效的
因为相当于给xxx.XXX了一个XXX的别名并引用,即使你import xxx之后reload(xxx)也不会生效
所以实例和一些对象,容器依然全部引用的旧的类,函数,变量,所以需要我们自己做替换
一个python模块中 可能有这么几种东西
①全局变量(实例)
②函数
③类
###函数的更新
函数的更新最为简单,只需要替换掉__doc__, __dict__, __defaults__, __code__即可

def update_function(old_func, new_func):
    old_func.__doc__ = new_func.__doc__
    old_func. __dict__ = new_func. __dict__
    old_func. __defaults__ = new_func. __defaults__
    old_func. __code__ = new_func. __code__

但是这样是不支持decorator的

def decorator(func):
  def _(*args, **kwargs):
    return func(*args, **kwargs)
  return _


@decorator
def old_foo_with_decorator():
  return 'old_foo'


@decorator
def new_foo_with_decorator():
  return 'new_foo'


class ReloadTest(unittest.TestCase):
    def test_update_function_with_decorator1(self):
        self.assertEqual('old_foo', old_foo_with_decorator())
        update_function(old_foo_with_decorator, new_foo_with_decorator)
        self.assertEqual('new_foo', old_foo_with_decorator())

    def test_update_function_with_decorator2(self):
        self.assertEqual('old_foo', old_foo())
        update_function(old_foo, old_foo_with_decorator)
        self.assertEqual('new_foo', old_foo())

运行即可发现两个case都不对,为了解决这个问题可以改为(目前没发现能解决第二个case的办法

def both_instance_of(first, second, klass):
    return isinstance(first, klass) and isinstance(second, klass)

def update_function(old_func, new_func):
    # 这里两个检查是因为python在funcobj.c的func_set_code里面进行了强制检查
    # 也就是在执行old_func.__code__ = new_func.__code__的时候会报错
    # 所以在这里如果碰到前后有无装饰器的函数只能先跳过了
    if not both_instance_of(old_func, new_func, types.FunctionType):
        return
    if len(old_func.__code__.co_freevars) != len(new_func.__code__.co_freevars):
        return
    old_func.__code__ = new_func.__code__
    old_func.__defaults__ = new_func.__defaults__
    old_func.__doc__ = new_func.__doc__
    old_func.__dict__ = new_func.__dict__
    if not old_func.__closure__ or not new_func.__closure__:
        return
    for old_cell, new_cell in zip(old_func.__closure__, new_func.__closure__):
        if not both_instance_of(old_cell.cell_contents, new_cell.cell_contents, types.FunctionType):
            continue
        update_function(old_cell.cell_contents, new_cell.cell_contents)

###类的更新
一个类可能有这些东西
①方法(包括普通方法,静态方法,类方法,属性)
②成员变量(包括普通成员变量和基类)
类的method有
im_class(指向类)
im_func(指向某个函数,这个函数实际上定义在了内存某个地方,不会和全局的冲突)
im_self(对类的method来说始终为None)
实例的method有
im_class(指向类)
im_func(指向某个函数,这个函数实际上定义在了内存某个地方,和类的方法的im_func一致)
im_self(指向实例本身)
事实上一个类实例化会将成员都指向类的成员,当类成员变动时,实例成员也会变动,但实例成员变化后,实例成员和类成员无关,以下为更新类的方法(类的__slots____metaclass__如果发生了变化,无法正确更新,待解决

def update_class(old_class, new_class):
    for name, new_attr in new_class.__dict__.items():
        if name not in old_class.__dict__:
            setattr(old_class, name, new_attr)
        else:
            old_attr = old_class.__dict__[name]
            if both_instance_of(old_attr, new_attr, types.FunctionType):
                update_function(old_attr, new_attr)
            elif both_instance_of(old_attr, new_attr, staticmethod):
                update_function(old_attr.__func__, new_attr.__func__)
            elif both_instance_of(old_attr, new_attr, classmethod):
                update_function(old_attr.__func__, new_attr.__func__)
            elif both_instance_of(old_attr, new_attr, property):
                update_function(old_attr.fdel, new_attr.fdel)
                update_function(old_attr.fget, new_attr.fget)
                update_function(old_attr.fset, new_attr.fset)
            elif both_instance_of(old_attr, new_attr, (type, types.ClassType)):
                update_class(old_attr, new_attr)

###全局变量的更新
普通的全局变量直接重新赋值就好
更改实例的则需要单独设定

update_instance_class = [(old_class, new_class), ]

def update_instance_class_x(x):
    # 在这里自定义实例迁移方法
    pass

def after_reload():
    # 定义迁移后回调
    pass

# 上面应该都是迁移者定义的内容
# 下面为热更新处理逻辑

def get_reload_modules():
    # 待填充获取所有需要reload的模块的方法 返回形式为[(old_module, new_module), ]
    pass

def update_instance(old_class, new_class):
    for x in gc.get_referrers(old_class):
        if isinstance(x, old_class):
            update_class_x(x)
for old_module, new_module in get_reload_modules():
    update_module(old_module, new_module)
for old_class, new_class in update_instance_class:
    update_instance(old_class, new_class)
after_reload()

###模块的更新

def update_module(old_module, new_module):
    old_module_dict = old_module.__dict__
    for name, new_val in new_module.__dict__.iteritems():
        if name not in old_module_dict:
            setattr(old_module, name, new_val)
        else:
            old_val = old_module_dict[name]
            if both_instance_of(old_val, new_val, types.FunctionType):
                update_function(old_val, new_val)
            elif both_instance_of(old_val, new_val, (type, types.ClassType)):
                update_class(old_val, new_val)

热更新的流程

比较old和new的代码,记录更新的模块
1.重载需要更新的模块
2.查找需要更新的模块的引用(即这个依赖这个模块的模块,例如b.c依赖b)
3.重载依赖更新模块的模块
4.更新全局变量
##现在的问题
如果在init里面增加了属性定义,对于旧对象来说init是不会再次执行的,因此没有机会创建属性。
获取所有需要reload的模块的方法
类的__slots____metaclass__如果发生了变化,无法正确更新,待解决
函数替换如果前后一个一个无装饰器则不能替换,需要绕过python源码限制
inspect.getmro() 获取到的是tuple是一个类的继承顺序

分布式高可用性小结

引言

“高可用性”简称HA(High Availability)通常来描述一个系统经过专门的设计,从而减少停工时间,而保持其服务的高度可用性。
这里讨论一下分布式环境下的负载均衡与一些对过载的处理,有所错漏不足还请各位指正。
本身负载均衡并没有一个恒定的最优解,一个“较优解”也严重依赖于以下几个因素:
1.逻辑层级(全局还是局部)
2.技术层面(硬软件)
3.用户流量的天然属性(延迟敏感还是吞吐量敏感)
故而这里也仅仅讨论某一些较为通用状况,起到一个启发作用,在生产环境中还是需要具体情况具体分析。

目录

一.前端的负载均衡

1.1 DNS进行负载均衡

1.2 虚拟IP

1.3 一致性哈希

二.后端的负载均衡

2.1 客户端判断发往哪一个后端任务的方法

2.2 状态查询子集选择

2.3 轮询策略

三.优雅应对过载

3.1 衡量服务是否过载

3.2 拒绝请求的依据

3.3 处理过载错误

3.4 预防连锁故障

3.5 常见资源耗尽表现


一.前端的负载均衡

这里的前端可以理解为是服务的前端,即去获取服务的地址

1.1 DNS进行负载均衡

最简单的方案当然就是直接在DNS回复中提供多个A或者AAAA记录,由客户端任选,但是这种方案有以下问题:
1.对客户端约束力很弱(可以通过SRV记录指明优先级,但是部分协议暂未支持,例如HTTP)
2.客户端无法知道最近地址(可以通过查询来源DNS地址来回复,也可以通过维护一个网络地址和它们大概物理位置建立对照表解决,按照对照表生成DNS回复,需要维护一个数据更新pipline来保证位置信息的正确性 例如ip.taobao.com

当然 这些问题是很难解决的,因为DNS基本特性就是很少用户直接和权威DNS server联系。解析往往是通过递归解析器代理请求,而递归解释器往往提供一定程度的缓存机制,并根据接受到的回复中的TTL来缓存和发送这些回复。

这样会造成预估某个DNS回复的流量影响很困难(可能影响几个用户,也可能是几万个,可能只发给了某地,但是实际上影响了各地e.g你回复给了电信某个DNS服务器查询结果,这个针对这台服务器最优,但是他可能同步给了同地区甚至跨地区的所有城市的DNS服务器)

所以我们只能通过:
1.分析流量变化,并持续不断的更新已知DNS解析器的用户数量,来预估影响
2.根据数据评估每个已知解析器背后的用户地址位置分布,提供更优结果

1.2 虚拟IP

DNS限制了恢复报文的长度(512字节),对大量服务器这显然是不够的(能回复的地址数量远小于服务器ip数量),所以往往会在DNS负载均衡之后加了一层虚拟IP地址。

虚拟IP很好理解,就是一个ip其实对应的N台真实服务器,这N台服务器对外体现为同一ip,好处很明显就是可以无缝维护了(可以任意增删查改服务器而不影响用户)。

然后关于虚拟IP的网络均衡器到底转发给哪台后端服务器,最简单的做法当然就是,永远发给最不忙的,但是实际上做不到,因为相当一部分协议是有状态的,处理同一个请求的过程中必须使用同一个后端服务器。也就是需要跟踪所有转发过的链接(也就是保存每一个来源或者理论上为同一个请求的请求标识符和发往的后端映射表),以确保同一来源的数据包都发给了同一后端。

替代方案是使用数据包中的某些部分创建出一个连接标识符(比如请求来源IP或者某次请求的uuid取哈希),通过标识符来选择后端服务器,简而言之可以理解为hash(package) mod N,其中N为服务器数量,hash为哈希算法。

但是这就有一个问题,当你的N要加减(也就是增删服务器)的时候怎么办,所以就有了一致性哈希算法(consistent hashing)。所以往往处理方法就是,在低压力的时候使用内存跟踪每一个连接,压力高的时候使用一致性哈希算法。(详细见下一小节)

然后回到负载均衡器怎么将数据包发给特定机器的问题上来,业内的解决方案也很多,主要是这两种
1.修改数据链路层,通过修改转发数据包的目标mac地址,这样后端服务器可以拿到来源和目标地址信息,后端可以直接响应给用户(DSR)适用于请求很小,回复很大,而且不需要保持状态(问题在于不是所有服务器都可以在数据链路层相通)。
2.包封装(路由封装协议GRE)封装到另一个IP包中,使用后端服务器作为目标地址,后端服务器接到后拆掉IP和GRE层直接处理内部IP包(问题在于封装成本需要24字节,导致数据包超出可用传输单元大小需要重组,往往需要硬件支持更大的单元)

1.3 一致性哈希

以下转载自某篇讲得很好的博客
转载请说明出处:http://blog.csdn.net/cywosp/article/details/23397179
环形Hash空间
按照常用的hash算法来将对应的key哈希到一个具有2^32次方个桶的空间中,即0~(2^32)-1的数字空间中。现在我们可以将这些数字头尾相连,想象成一个闭合的环形。如下图

把数据通过一定的hash算法处理后映射到环上
现在我们将object1、object2、object3、object4四个对象通过特定的Hash函数计算出对应的key值,然后散列到Hash环上。如下图:
Hash(object1) = key1;
Hash(object2) = key2;
Hash(object3) = key3;
Hash(object4) = key4;

将机器通过hash算法映射到环上
在采用一致性哈希算法的分布式集群中将新的机器加入,其原理是通过使用与对象存储一样的Hash算法将机器也映射到环中(一般情况下对机器的hash计算是采用机器的IP或者机器唯一的别名作为输入值),然后以顺时针的方向计算,将所有对象存储到离自己最近的机器中。
假设现在有NODE1,NODE2,NODE3三台机器,通过Hash算法得到对应的KEY值,映射到环中,其示意图如下:
Hash(NODE1) = KEY1;
Hash(NODE2) = KEY2;
Hash(NODE3) = KEY3;

通过上图可以看出对象与机器处于同一哈希空间中,这样按顺时针转动object1存储到了NODE1中,object3存储到了NODE2中,object2、object4存储到了NODE3中。在这样的部署环境中,hash环是不会变更的,因此,通过算出对象的hash值就能快速的定位到对应的机器中,这样就能找到对象真正的存储位置了。
机器的删除与添加
普通hash求余算法最为不妥的地方就是在有机器的添加或者删除之后会照成大量的对象存储位置失效,这样就大大的不满足单调性了。下面来分析一下一致性哈希算法是如何处理的。
1. 节点(机器)的删除
以上面的分布为例,如果NODE2出现故障被删除了,那么按照顺时针迁移的方法,object3将会被迁移到NODE3中,这样仅仅是object3的映射位置发生了变化,其它的对象没有任何的改动。如下图:

2. 节点(机器)的添加
如果往集群中添加一个新的节点NODE4,通过对应的哈希算法得到KEY4,并映射到环中,如下图:

通过按顺时针迁移的规则,那么object2被迁移到了NODE4中,其它对象还保持这原有的存储位置。通过对节点的添加和删除的分析,一致性哈希算法在保持了单调性的同时,还是数据的迁移达到了最小,这样的算法对分布式集群来说是非常合适的,避免了大量数据迁移,减小了服务器的的压力。

平衡性
根据上面的图解分析,一致性哈希算法满足了单调性和负载均衡的特性以及一般hash算法的分散性,但这还并不能当做其被广泛应用的原由,因为还缺少了平衡性。下面将分析一致性哈希算法是如何满足平衡性的。hash算法是不保证平衡的,如上面只部署了NODE1和NODE3的情况(NODE2被删除的图),object1存储到了NODE1中,而object2、object3、object4都存储到了NODE3中,这样就照成了非常不平衡的状态。在一致性哈希算法中,为了尽可能的满足平衡性,其引入了虚拟节点。
——“虚拟节点”( virtual node )是实际节点(机器)在 hash 空间的复制品( replica ),一实际个节点(机器)对应了若干个“虚拟节点”,这个对应个数也成为“复制个数”,“虚拟节点”在 hash 空间中以hash值排列。
以上面只部署了NODE1和NODE3的情况(NODE2被删除的图)为例,之前的对象在机器上的分布很不均衡,现在我们以2个副本(复制个数)为例,这样整个hash环中就存在了4个虚拟节点,最后对象映射的关系图如下:

根据上图可知对象的映射关系:object1->NODE1-1,object2->NODE1-2,object3->NODE3-2,object4->NODE3-1。通过虚拟节点的引入,对象的分布就比较均衡了。那么在实际操作中,正真的对象查询是如何工作的呢?对象从hash到虚拟节点到实际节点的转换如下图:

“虚拟节点”的hash计算可以采用对应节点的IP地址加数字后缀的方式。例如假设NODE1的IP地址为192.168.1.100。引入“虚拟节点”前,计算 cache A 的 hash 值:
Hash(“192.168.1.100”);
引入“虚拟节点”后,计算“虚拟节”点NODE1-1和NODE1-2的hash值:
Hash(“192.168.1.100#1”); // NODE1-1
Hash(“192.168.1.100#2”); // NODE1-2

二、后端的负载均衡

后端可以理解为服务后端,即已经确立了客户端和客户端拥有的服务端选项

定义任务:服务器上的一个进程,定义为一个任务,包括客户端任务和后端任务

后端实际上对客户端和服务端都是可以控制的(至少代码可控)

在一个复杂系统中,客户端往往也是其他客户端的服务端

负载均衡的目标是资源的可用率要尽量高(最好能同时跑满所有后端服务器80%的CPU,内存和IO)

再次重申具体问题具体分析,考虑运行成本和维护成本,并不是越“好”的策略就越合适生产环境的

这里仅仅讨论C/S模式Client主动调用Server的情况

2.1 客户端判断发往哪一个后端任务的方法

1.判断活跃请求的数量,发往活跃请求少的后端任务(不复杂的情况可以使用,但是一个请求处理时间长并不意味着消耗资源多,例如一些重网络IO的,往往耗时长但是实际上CPU和内存消耗都不大,这样会造成资源充足的后端服务器被闲置,处理请求少的也可能在达到设定极限之前就过载了)

2.判断后端任务状态
定义后端任务的状态
①健康:初始化成功,正在处理请求
②拒绝链接:无响应,可能是正在启动或者停止,也可能是处于异常状态(不再监听端口了)
③跛脚鸭状态:还在监听端口,也可以服务请求,但是已经明确要求客户端停止发送请求了。
当后端任务进入跛脚鸭状态的时候,会向所有已连接的客户端广播,不活跃的客户端(没有建立连接的)也应当定期发送UDP健康检查包,通常在1-2个RTT内就可以传达给所有客户端,同理,后端任务进入健康状态的时候,也应当通知所有客户端。
客户端遍历所拥有的处于健康状态的后端任务的列表,依次发往。

2.2 状态查询子集选择

然后为了实现上述的做法,肯定是需要让客户端与服务端保持连接的(为了后端任务状态改变时及时通知,可以是TCP/UDP等),但是显然不应该让每一个客户端任务和每一个服务端任务都连接,这就要获取选择连接的服务器任务子集,最常见的想法是随机选择(事实证明不行,子集越小分布越不均衡,必须保证75%的子集大小,才能让负载较为平衡)。
所以一般的做法是确定性算法,代码如下(python)

def subset(backends, client_id, subset_size): 
    subset_count = len(backends) / subset_size 
    round = client_id / subset_count 
    random.seed(round) 
    random.shuffle(backends) 
    subset_id = client_id % subset_count 
    start = subset_id * subset_size 
    return backends[start:start + subset_size] 

subset_count是子集大小(可以根据你的需求设置),backends为后端任务list,client_id为客户端任务标识符

2.3 轮询策略

如何将任务分发给客户端已知的健康的后端任务?上述做法是不考虑后端负载的(只考虑健不健康),实际上往往生产环境也就是不考虑的直接轮询,当然也有考虑的做基于后端状态的做法(最小负载轮询啊,或者带权重的轮询)。
事实上,简单轮询有很多问题
1.处理请求的成本其实是很不相同的,一个请求成本最高的可能是最低的上千倍(比如获取XXX的全部邮件对用了十年邮箱的用户和对刚使用的用户成本就完全不同),而且在成本事先无法预知的情况下,轮询策略是很难负载均衡的,当然你可以说你只做分页查询这种请求服务(比如返回最近100封邮件),但是语义改变往往很困难,不仅要改客户端,还需要考虑一致性因素(在查询中增删了,会导致视图不一致,当然这是分页的问题了不详细讨论)。
2.物理服务器其实有差异(最简单的配置可能不同,配置相同可能因为同一个物理服务器上跑着的邻居进程不一样导致差异,不详细解释了,解决往往靠虚拟化资源)
3.无法预知的性能变(不相关的邻居进程啊,比如的redis拿主线程刷盘抢占IO,任务重启的时候,比如java往往要动态优化代码导致后端任务处理性能下降)

所以有了最闲轮询策略
客户端去跟踪子集内每个后端任务的活跃请求数量,然后选择数量最小的进行轮询(因为负载高的往往延时高)(注意别踩坑,如果后端任务不健康,可能100%返回我不健康的错误,然后这个回复往往会比正常回复快多了,简单有效的解决方法是把最近收到的错误信息计算为活跃请求),但是这个策略还是有两个限制
1.活跃请求的数量不一定是后端任务容量的代表(比如A后端任务能用的资源是B的两倍CPU和内存,但是任务因为网络IO决定耗时,耗时也差不多)
2.客户端活跃请求的统计不包括其他客户端发往同一个后端任务的请求

最后还有加权轮询策略
很简单,就是客户端为每个子集中的后端任务维持一个能力值,请求依旧轮询分发,但是按照能力值权重比例调节,在收到每个回复之后(包括健康检查),后端任务会提供当前自己的请求速率,错误值,目前的资源占用率。客户端任务根据目前请求成功的数量和服务端返回的内容进行周期性的调节。

三、优雅应对过载

首先负载均衡就是用来防止过载的,一个可靠的系统在真的是可调度资源不足之前,是不应当出现过载的(当然软件工程毕竟理想和现实总是很不一样的),过载的情况总是无法避免的会出现,下面介绍一下整理的应对过载的内容。

给一些定义:
最优负载:处理请求速率最高(即是能实际处理的QPS最高)
极限负载:能同时正常处理的请求最多(可能处理速度已经大幅下降了) 这个时候同时正常处理的流量最多
过载错误:包括配额错误和服务整体过载错误
服务降级:返回一个精度降低的回复,或者省略回复中的一些需要大量计算的数据(比如使用本地缓存回复,或者搜索只搜索部分数据)

3.1 衡量服务是否过载

用QPS来衡量负载能力其实是非常有局限性的(上一篇已经提到过了,不同请求的资源消耗可能相差上千倍),所以往往用资源来衡量可用容量,业内的常见方案是仅使用CPU时间来衡量负载能力(要考虑不同CPU类型的性能问题),因为在有GC的环境里面,内存的压力通常自然变成CPU的压力,在其他编程环境里面,可以通过某种比例增加其他资源(往往过量)。(当然有的情况上述做法实在不可行,那就只能在衡量压力的时候将其他资源分别考虑在内)

衡量服务的负载的指标应当是平滑的,意思为,并不一定当以当前时刻的CPU使用(或者其他关心的资源使用)作为衡量指标,应当以一段较短时间的均值作为衡量指标,这种做法的目的是为了平滑某些任务突然展开带来的突发性资源增长(例如某个客户是一个定时脚本,并发的扔了上百上千个请求过来,实际上这种突发只是相当小的一瞬间,服务完全有可能抗住这一些突发请求,防止瞬间突发请求被认为过载了造成服务降级等问题)。

3.2 拒绝请求的依据

根据重要性处理不同的请求

重要性基本可以分为四类
①最重要的(拒绝会造成非常严重的用户可见的问题)
②重要的(生产任务的默认的请求类型,拒绝了可能会造成用户可见的问题,但是没有那么严重,一般来说要求服务必须为重要的和最重要的分配资源)
③可延迟的(批量任务的默认类型,允许某种程度的不可用性,比如过几分钟或者几个小时重试)
④可丢弃的(经常遇到部分不可用或者完全不可用)
如果条件允许,可以将重要性集成到RPC系统中作为一级属性。重要性往往是递归传递的(直到被显示设置覆盖为止,所有最重要的请求调用导致或者产生的的子RPC或者类似调用重要性也应该为最重要的),注意的一点是,重要性和延时的容忍程度不一定相关,例如搜索关键词推荐,事实上是属于可丢弃的(即使没有也不造成大问题),但是延迟的容忍程度非常低(过了零点几秒返回也没意义了)。
对重要性的处理往往有很多种方法,举例几种
①当配额不够的时候,服务会按照优先级顺序分级拒绝请求
②某个服务过载的时候,低优先级的优先拒绝
③自适应的节流对每个优先级分别计数

3.3 处理过载错误

在极端过载下,服务降级之后,正常返回也无法生成和发送,不可避免的需要返回错误,这种情况下就需要尽量能够妥善的处理和返回适当的错误。(当然除非极端情况本身就不应该调度超过承受峰值的流量到该服务上,事实上大量的服务甚至都没有经过压力测试不知道最优负载和极限负载,也没有做降级方案,当不可预知的大量流量来临时是极端危险的。)

下面讨论一下常见的实际处理方案

服务端一侧的节流机制

服务端给每个客户端来源(指的是来源方,比如某某服务,实际上往往是clients,也就是一个来源方会有很多很多client,这些被视为一个客户端来源)设置用量限制(比如10wQPM或者1000QPS)。
尤其是在基础服务上面(举例储存服务),当流量过载(往往来自于某一或少数几个来源的流量过载)的时候,只针对某些“异常”来源返回错误是非常关键的。若不然,则会影响其他来源的服务可用性下降甚至导致其他业务也跟着过载最后形成雪崩。
所以往往基础服务团队会与“用户”(来源)约定合理的使用配额,并配置相应的资源(往往这些配额的总和是超过该基础服务的极限负载的,因为所有用户一起用完资源配额是非常罕见的情况)。实际如何从后端实时获取用量信息,并将配额调整信息推送给每个用户的做法(google目前是实现了这一个系统的)已经超出了讨论范围,事实上大部分的情况都还是靠人的衡量来决定配额的(google声称这系统的最大难点和坑在于:对于某些不是按照一个请求一个线程模式设计的软件非常难实时计算每个请求所消耗的资源,尤其是CPU异常困难,这种软件用非阻塞API和线程池模式处理每个请求的不同阶段)。

客户端一侧的节流机制

当某个用户超过资源配额时,服务可以直接迅速拒绝请求返回配额不足的错误,但是前提在于返回错误的成本不能太接近请求执行的成本(比如执行一个简单的内存查询,因为主要的消耗是在应用层协议解析中,产生结果其实很简单,比起不停的拒绝不如直接执行请求,因为即使拒绝了这些流量日后很有可能还会被重试,导致一边大量拒绝消耗资源,一边处理能力大幅下降能正常处理的请求大幅减少)。
在客户端使用节流机制(当最近收到大量配额不足,或者服务端处理能力不足的错误的时候,自行限制请求速度,自行限制自己能生成的请求数量,超过这个数量的请求直接在本地回复失败,不要发到网络层),这种做法适用于请求速率高的客户端(低客户端对后端状态的记录有限),这里介绍一种经过考验的自适应节流算法如下:
max(0, (requests-K*accepts)/(requests + 1))作为客户端请求拒绝的概率,requests为某段时间请求数量,accepts为该段时间请求接受的数量,常规情况下两个值相等,K值越高自适应越缓和,越低越激进,一般的推荐值是2,可适当按照拒绝资源消耗和处理资源消耗的比例进行调节。requests计算的时间间隔越短越激进,越长越缓和,至于如何计算以当前时间为起始,往前若干时间的请求数量的统计,我的做法是内存常驻一个简单的类window的数据结构(对当前时间取模,以一秒为一个window的计数计数数组,并提供一个整数代表总和,这种做法也可以用来例如统计不同ip的XX时间内的访问次数来限制或者反爬虫等等,好像google还是哪家还把这个当设计题考过我这个)

对过载错误的处理逻辑

当因为过载错误被拒绝请求的时候,客户端针对被拒绝请求的最好方法往往是,直接重试,因为事实上,服务的某一后端任务处于过载状态是很常见的(突发的某大资源消耗请求或者邻居任务产生的毛刺),该服务的其他后端任务往往并没有过载,重试通过负载均衡器即可解决,当后端任务数量足够多的时候,发往已过载后端任务的可能性是很小的,多次重试形成了一种天然的负载均衡,使得不可用基本认为可能性为0。但是也要设置一个阈值(当后端任务数量不是过于稀少的情况下一般给3次),当多次重试都被拒绝的时候(意味着服务彻底不可用,服务整体过载),应当一路传递给上层请求者(最终往往直接发给实际用户错误信息),上层不应该重试(不然调用链那么深,一个实际用户的请求最终可能对某基础服务重试了几千上万次)。(如果实际接触过大公司业务的应该知道这个依赖关系是很恐怖的,往往十几层依赖,中间还有循环甚至相互依赖)
对每个客户端做重试限制,当很糟糕的情况发生的时候(大量服务不可用),各类服务都过载,总重试次数不应该超过总请求次数的10%,这样就可以在灾难发生的时候不至于因为重试造成太大麻烦,此外如果客户端是中间层(例如一些中间件RPC服务等等)的话,往往可以设置不再重试,具体的设置不应该教条主义。

3.4 预防连锁故障

过载可能不可避免,但是过载最害怕的甚至不会是整个服务down掉,而是带着其他依赖和被依赖业务一起过载引发雪崩(连锁故障)。一个设计良好的后端,应当在过载的时候尽可能的继续接受请求,在有可用资源的情况下尽可能正常处理请求,基于可靠的负载均衡策略支持,应该仅仅接受它能处理的请求,优雅的拒绝其他的请求。当然实际生产中,应当深入理解一个系统它处理的和它请求处理的请求的处理语义,据此来进行负载均衡和过载保护设计。

连锁故障怎么产生的

故障产生(过载状态)的服务,抗并发能力是比最优负载时候差的,所以服务单个后端任务过载处理能力下降,往往会造成其它该服务后端任务负载增大,引发过载,导致服务整体过载,服务过载导致依赖该服务或者该服务依赖的服务(大量长延时,甚至无效请求发往所依赖的服务)甚至部署在同一物理机器上的不相关服务(因为资源抢占)的服务处理能力下降,引发其它服务也过载,最终连锁崩坏,又称为雪崩。这个过程对设计不够好的服务往往仅仅发生在数分钟内,人无法反应的过程中就全线服务崩溃,过程中任意服务单个重启继续被还没彻底崩溃的服务请求淹没过载崩溃,导致修复困难。

连锁故障怎么预防

基本上只有两个方案,流量抛弃与服务降级
流量抛弃属于重型手段了,不到迫不得已一般不会使用(除非重要性标注为可以抛弃),当然也要根据队列长度,请求重要性,决定什么抛什么不抛,这里就不再赘述,常见的做法有标准FIFO改为LIFO以及使用可控延迟算法。
关于服务降级,我们评估降级方案的时候,往往需要考虑以下几点(以什么作为自动降级的指标或者还是直接人工干预?当服务降级的时候需要执行哪些操作,会在方案启动前,启动中,运行中,结束中,结束后带来哪些影响?应该在业务的哪一级进行降级?)降级并不是万能的解决规划不足或者过载的良药,要注意的是,由于降级属于不常见方案,大部分代码是没有身经百战的,这个模式下面的运维经验也会很少,如果这个模式出了问题,往往更难恢复和造成不可知的影响。

3.5 常见资源耗尽表现

名词解释看门狗(watchdog)往往是一个定期唤醒的线程,会检查上次醒来的时候是否完成了任何工作,如果没有就会假设服务器卡住,杀掉自己从而杀掉进程。

CPU

CPU资源不足时,一般来说所有请求都会变慢,这个场景会导致包括但不仅限于
①正在处理的请求数量上升 必然带来包括影响其他所有资源(内存,活跃线程数,文件描述符,后端服务器资源)的后果,容易引发连锁反应
②队列过长,延迟上升
③线程卡住(因为死锁或者长时间等待锁)但是服务器无法在合理的时间内处理心跳健康检查请求(往往会被视为服务器挂了,如果有自动化的处理比如干掉服务器就会导致。。。)
④CPU死锁或者请求卡住(watchdog可能因为长时间等待干掉进程,如果watchdog是远程的话就会导致因为等待无法处理)
⑤RPC超时(服务可能是客户端的RPC调用,因为超时自动断开重试造成请求处理资源浪费和更严重的过载)
⑥CPU缓存效率下降(任务容易被分配到多个CPU核心上,CPU核心的本地缓存时效,降低CPU效率)

内存

场景包括且不仅限于
①任务崩溃(可能超过资源限制被容器干掉,或者被自身逻辑干掉,或者OS直接oom干掉想干不相干的进程)
②GC频率加快(导致CPU使用率上升,结合CPU使用率上升导致请求变慢,内存上涨)
③缓存命中率下降(应用层可用缓存减少,导致命中率降低,导致对依赖的服务比如db请求变多,导致所依赖的服务过载比如db被打穿)

线程

线程不足可能导致错误或者健康检查失败。

文件描述符

导致不能建立网络连接导致健康检查事变。

资源之间相互依赖导致定位困难

因为资源相互依赖的原因,往往会出现一堆次级现象看起来像是根本问题,而且你根本没有什么好办法查到是什么资源最开始出问题的(因为服务器健康监控也是有间隔的),看起来就是突然一大堆参数都飚高了,而且他们互相影响飚的更高最后导致服务挂,而由于人的思维惯性,极大可能是从最开始的故障现象,比如内存最先oom了杀掉了不相关进程导致乱七八糟的业务或者服务崩溃了,往往惯性的就会去找gc或者内存泄漏的问题,事实上可能是因为CPU不足或者whatever的原因导致的内存飚高,只不过最先体现在了外在而已。

理论上来讲,应当对所有系统都进行大流量压测,测量最佳负载,极限负载和过载情况的表现,然后需要做降级,在过载的情况主动拒绝请求,进行容量规划(实际上不可能,时间精力财力都不允许),所以最好优先盯着重要的(出问题很严重的系统进行处理)

一些小tips:
快速失败往往比等待恢复要好
timeout不应该比平均延迟大几个数量级
设置timeout是一个RPC的常见防止卡死和服务不可用的手段,这里推荐一种比较优雅的方式,从请求的最入口处指定一个超时时间,将时间戳RPC传递下去,每一层同样对自己的处理设置超时,超时则放弃,同时每一层和同一层的不同任务均可使用这一最终超时时间,这可以避免无谓的超时重试和复杂的层层超时时间设置,当然,也需要能够允许手工重置这一超时时间(对于某些即使请求失败了也要继续执行的或者本身就不被依赖返回数据的任务)