前言
今天是2025年1月28日,除夕夜晚上十点半。今年家里是十分的冷清,没有什么过年的氛围。
不过,可能向来如此吧。
除夕夜还在看这些,莫名有些伤感。
想起一句话:我们是除夕夜街头,即将放飞理想的有志青年。
也许吧,还是看书吧
第五章 - 大规模并发
异常传递
编写并发代码,特别是在分布式系统中,你的系统中非常容易出现一些奇怪问题,并且难以理解为什么会发生这种情况。
为了将你自己、你的团队、你的用户从众多的痛苦中拯救出来,你需要仔细考虑异常(error)是如何通过分布式系统传递的,以及问题最终将如何呈现给使用者。
异常处理十分的重要,首先来明确异常是什么,什么时候发生,提供了哪些好处。
出现异常表示你的系统进入了一个无法满足用户操作的状态,这个操作可能是显式的,也可能是隐式的。
这时系统需要传达几个信息:
- 发生了什么
这部分异常信息包含了对异常事件的描述。例如:“磁盘已满”,“连接被重置”,“证书过期”。这些信息可能是被一些代码隐式的表达出来的,你可以用一些上下文来修饰这些信息来帮助用户理解发生了什么问题。 - 发生在什么时间、什么位置
异常应当总是包含完整的栈轨迹信息,从调用的启动方式开始,以异常的实例结尾。栈轨迹信息不应该包含在异常消息中(这一点尤为重要),但当需要处理栈中的异常时应该很容易被找到。
更进一步讲,异常应当包含有关其内部运行的上下文信息。例如,在分布式系统中,异常应该有一些字段用来识别发生异常的机器。发生异常后,这些信息会对你诊断系统故障原因非常有价值。
此外,异常还应包含对应机器上的时间,并且最好是UTC时间。 - 对用户友好的信息
应当对展现给用户的异常信息进行自定义,以适应你的系统和用户。这些信息应该只包含前两点的概述以及相关信息。对用户友好的信息是从用户的角度考虑,给出一些信息,说明这些问题是否是暂时的,并且最好是行以内的文本。 - 告诉用户如何获得更多的信息
在某些情况下,用户希望知道当异常发生时,具体发生了哪些故障。展现给用户的异常信息应当提供一个ID,利用这个ID可以查询到对应的详细日志。
这个详细日志应显示异常的完整信息:发生异常的时间(而不是异常记录的时间),异常创建时完整的堆栈调用。包含一个堆栈轨迹的hash也有助于聚合这些异常,就像bug追踪器那样跟踪问题。
默认状态下,如果你不介人,异常信息不会包含上述所有的信息。因此,你应当保持这样一种观念,任何展现给用户的异常信息如果没包含这些信息,不是出错了就是有bug。
这引出了一个可以用来处理异常的通用模型。所有的异常都几乎都能归为以下两种分类之一:
- Bug
- 已知信息(例如:网络连接断开,磁盘写入失败等)。
Bug是一些你未在你的系统中定义的异常,或者一些“原生”的异常,就是那些极少遇到的情况。
有时这是有意为之的,在你系统最初的几次迭代中,一些罕见问题展现给用户是可以接受的。还有些时候这是意外发生的。
总之,如果你同意我所提出的方法,即“原生”异常总是bug。在确定如何传播异常时,在系统随着时间的推移如何增长以及最终向用户展示什么时,这种区别被证明是非常有用的。
当我们面向用户部分的代码收到一个格式良好的异常信息时,我们知道在代码的各个层面上,我们都小心的处理了异常,我们可以将其记录下来并打印出来供用户查看。确保异常类型的准确有效是非常重要的。
当不规范的异常或bug传递给用户时,我们也应该记录异常,但是应该向用户显示一条友好的消息,指出发生了意外的事情。如果我们在系统中支持自动的异常报告,则应该将这些问题报告为bug。如果我们不这样做,我们应该建议用户提交一个bug反馈。
请注意,不规范的异常实际上也可能包含用的信息,但我们不能保证这一点,我们唯一能确认的是异常没有经过我们格式化。因此我们应该直截了当地展示一段人类可解读的信息,来展示刚刚发生的事情。
请记住,在这两种情况下,如果出现格式不规范的异常,我们将在消息中包含一个日志ID,以便用户在需要更多信息时可以查询到相关的内容。
因此,如果bug确实包含了有用的信息,有需要的用户仍然有可追踪的线索。
作者给了个简单的包装异常的例子,这里就省略了。
我通常处理异常的方式和上面是一样的,分为自定义异常(不符合业务逻辑的异常、可以预料到的系统异常)和意料之外的异常。
具体处理方式在不同的场景下区别是比较大的,这块后续会去学习errors包的处理方式。另外还有分布式的异常处理方式。
超时与取消
在并发代码运行时,超时(Timeouts)和取消(Cancellation)会频繁出现。
超时的处理对于创建一个易于理解的系统是至关重要的,进程被取消是其发生超时时的自然反应。
那么,为什么希望并发程序支持超时呢?
- 系统饱和
即系统的处理能力达到上线,希望超出的请求返回超时,而不是花很长时间等待响应。- 请求在超时时不太可能重复
- 没有资源存储请求(内存队列内存,持久队列磁盘)
- 如果系统对响应或请求发送数据有时效性要求。
- 如果一个请求可能会重复,超时会额外增加一个请求和超时的消耗。
- 如果开销超过系统容量,可能会导致系统宕机。
- 陈旧的数据
数据通常有一个窗口期,一般是在这个窗口中必须先处理更多的相关数据,或者处理数据的需求已经过期。
如果一个并发进程处理数据需要的时间比这个窗口期更长,我们会想返回超时并取消并发进程。
例如,如果我们的并发进程在长时间的等待之后响应请求,则在排队中的请求或其数据可能已经过时。
如果事先知道这个窗口时间,那么将context.WithDeadline或context.WithTimeout创建的context.Context传递给我们的并发进程是有意义的。
如果事先不知道窗口,我们希望并发进程的父节点能够在请求不再需要时取消并发进程。context.WithCancel是达到这个目的的最佳选择。 - 试图防止死锁
在大型系统中,尤其是分布式系统中,有时难以理解数据流动的方式,或者可能出现的罕见情况。
为了保证系统不会发生死锁,建议在所有并发操作中增加超时处理。超时时间不一定要接近执行并发操作所需的实际时间。
不过超时的目的只是为了防止死锁,所以需要它足够短,使死锁的系统在合理的时间内解除阻塞即可。
尝试通过设置超时可以将一个死锁系统转变为一个活锁系统。不过,在大型系统中,由于存在更多灵活的组件,在系统死锁后,你的系统更可能会遇到时序配置不同步的情况。
因此,最好是在允许的时间内尽可能修复活锁,好过发生死锁后只有通过重新启动才能恢复系统。
如何建立一个并发处理来优雅地处理取消。并发进程可能被取消的原因有很多:
- 超时
超时是隐式取消。 - 用户干预
为了获得良好的用户体验,通常建议维持一个长链接,然后以轮询间隔将状态报告给用户,或允许用户查看他们认为合适的状态。
当用户使用并发程序时,有时需要允许用户取消他们已经开始的操作。 - 父进程取消
对于这个问题,如果任何一种并发操作的父进程停止,那子进程也将被取消。 - 复制请求
我们可能希望将数据发送到多个并发进程,以尝试从其中一个进程获得更快的响应。当第一个回来的时候,我们就会取消其余的进程。