linux分布式系统开发心得

时间:2022-04-05 13:38:34

关于开发和生产环境

对于linux平台的程序来说,开发和生产环境的问题非常突出。由于 linux 的开放,造成市面上存在大量的发行版。这些发行版在二进制层面几乎都不兼容(主要是采用的 glibc 等基础库的版本不一致)。如果开发环境和生产环境使用不同的发行版,甚至相同发行版的不同版本。都有可能出现开发环境编译出来的程序在生产环境无法运行的情况。

稍轻微一点的情况下,可以通过手动 COPY 一些开发环境中的.so 到生产环境解决。复杂的情况下几乎就无法工作。如果在这种事情上耗费过多的精力实在是得不偿失!
解决的办法有:

  1. 最简单的解决方案是统一开发环境和生产环境的OS 版本。
  2. 利用 docker 技术,给开发和生产环境提供统一的镜像。至于原生的 OS 只要选择 docker 支持的版本即可。

关于技术选型

技术选型方面可以参看另一篇笔记《关于项目技术选型的思考》。
要补充一点的是,技术选型还要考虑团队的人员状况。不仅仅是现有成员对技术的掌握情况,还要照顾大家对新技术的接受情况。如果一项技术不能得到大多数成员的认同,那么使用这项技术开发的产品可能会面临没有充足人员来的维护的状况。一旦最初研究使用这项技术的人员发生人事变动,将会很麻烦。

关于问题排查

debug 是一个宏大的话题,一般来说当发现 BUG,最重要是搞清楚 BUG 的复现条件。如果 BUG 无法复现起码要保护好发现 BUG 的现场环境。除此之外,我在这里只能提供一些最基本方法:

  1. 模块划分尽管清晰,降低耦合。模块划分合理的软件在 BUG 的定位和修复方面比混乱的条件有先天的优势。
  2. 利用 core dump
  3. 分析日志
  4. 利用二分查找的思想,逐步定位问题的范围

关于LOG

前面提到的问题排查方法中,分析日志是实际工作中利用率最高的方法。日志除了可以用于排查问题,也是反映系统运行状况的重要手段。所以非常值得重点说一说。

每一条 LOG 必须包含足够的信息独立描述任务的状态

日志如此重要,但实际工作上还是碰到过面对BUG和日志却无从下手的情况。当你发现你面对着这种困境时,就说明你打 LOG 的方法可能不合适。
不合适的日志有几种情况:

  1. 日志打少了。信息量不够,无法定位问题。
  2. 日志打多了。存在大量对定位问题无用的干扰信息。甚至有效信息被过期淘汰。
  3. 日志格式不合适。千头万绪,查找阅读时耗费大量精力。

所谓久病成医,结合这些年来碰到的各种问题和与日志做斗急的经验。我总结了一些符合我自己工作方式的最佳实践。
首先,日志每一条LOG 都独占一行。在这一条 LOG 里必须要包含关于程序正在处理的任务的基本上下文,如事件 ID,序列号,用户 ID,业务 ID 等。除此之外,这一条 LOG 要能反映任务的当时状态,比如当前正在进行的操作,以及和这个操作有关一些具体的变量值。同时必须要包括时间戳,源文件及代码行。能包含函数名当然更好。概括起来就是每一条 LOG 都是相对独立的,它必须包含尽可能丰富的信息。
其次,日志应该避免重复!在实践中要做到这一点的难度远超过第一点。日志中的重复是指对于同一个状态,在多条 LOG 中都进行了描述。这些冗余的信息在繁忙的系统中将会占据大量的磁盘空间导致服务器能保存的有效信息数量锐减,当排查 BUG 时发现有用的信息刚好被过期清除时,你就会对这明白我为什么这么啰嗦了。最常出现的重复信息一般是在一个功能模块的每一层函数都在输出类似的内容。另一种情况当发生错误时,每一层函数的返回值检查处都输出了类似的错误描述。关于这一点只能根据实际情况做取舍。我的做法是:

  • 对于第一种情况,每个函数只输出只和自身相关的信息。不描述输出上一层函数的信息,也不预先输出即将调用的函数的信息。
  • 对于第二种情况,只在捕捉的错误的第一现场,执行序列的重要分支和从处理模块返回的最外层函数处输出错误信息。这样其实还是存在一定的冗余,但对于定位问题非常有帮忙。

可以将一条 LOG 想像成文章的一个段落。每一个段落都应该描述了足够的信息。段落之间会有承接的关系,但不应该是啰嗦重复的。

日志的格式

关于日志的格式,最重要的一点是:一条 LOG 中的所有信息都应该在一行中输出。如果分成多行输出的话。在查找匹配的时候会相当麻烦。就算用正则表示式可以匹配到行,在 grep 的输出中也会出现无法完整显示日志内容的情况。用 vim 或者其他文本编辑器来处理这种分多行打印的 LOG 也会相当的麻烦!!!除此之外,LOG 中的信息应该有合理的分隔,比如用分号隔开描述和变量值。我一般的LOG 格式如下:

[15-07-22 16:44:18.447][ INFO][tid:47142521234912] DataAgentReport; throughput=0, cache_hit_rate=0, cache_exhaust_milliseconds=0, cache_processed_count=0, cache_waiting_process=0, is_failed_rate=0,       is_exhaust_milliseconds=0, pipes_num=80, waiting_process=0, waiting_deliver=0, mysql_processed_count=0, avg_mysql_latency= 0, timeout=0, failed=0, discard=0, mongo_pipes_num=32, mongo_waiting_process=0 [Application.cpp:156]
[15-07-22 16:51:29.798][DEBUG][tid:140583720834816] {BodySize:6,BizId:0,CmdCat:CAT_DS_USER,CmdId:ID_GET_USER_EXTENSION_RSP,TransId:18635885,Result:0}; starting handle task. [MysqlActor.cpp:139]
[15-07-22 16:51:29.799][DEBUG][tid:140583332710144] {BodySize:6,BizId:0,CmdCat:CAT_DS_USER,CmdId:ID_GET_USER_EXTENSION_RSP,TransId:87937685,Result:0}; Request Body as follow: user_id=354166672 [FsmDef.cpp:569]
[15-07-22 16:51:29.918][ INFO][tid:140583720834816] {BodySize:31,BizId:0,CmdCat:CAT_DS_USER,CmdId:ID_GET_USER_EXTENSION_RSP,TransId:18635885,Result:0}; task finished, ready send response. [FsmDef.cpp:57]

以上的{BodySize:31,BizId:0,CmdCat:CAT_DS_USER,CmdId:ID_GET_USER_EXTENSION_RSP,TransId:18635885,Result:0}就是任务的上下文信息。当要查看一个任务在处理过程时,只要按任务 ID 过滤就能看到这个任务完整的处理过程。

在业务流程产生的源头分配全局唯一的业务 ID

说起任务 ID,一般异步模式的程序都会给每个任务一个 trans id 用于标记任务,在收到的应答时通过 trans id 可以继续之前的处理流程。所以往往会把 trans id 做为LOG 中的唯一标识。这样做可以工作,但还不够好。
因为一个分布式系统中,一个业务的处理往往要在多个服务进程之前传递,每个服务都是自己分配 trans id. 当一个业务的调试涉及到多个服务时,要想串起整个流程就要在每一个环节找出前后两个 trans_id的对应关系。整个过程效率十分低下。也许你会想到,后一个环节直接使用前一个环节的 trans_id 当做自己的 trans_id好了。这样一个业务流程全程使用同一个 trans_id 问题就搞定了。但是这个方法的必须要建立所有服务产生的 trans_id 都是全局唯一的前提下。否则在一个面向多个前一个环节的后端服务的 LOG 中,会出现多个任务使用相同的 trans_id 的可能。这将使问题的查找几乎无法进行。
我认为较好的办法是在业务流程产生的源头分配全局唯一的业务 ID。这个业务 ID 应该做为任务的一个属性在所有的流程中传递。每一个服务在 LOG 中输出的任务上下文中都应该包含这个业务 ID,那么在查找问题时,不论从哪个环节开始都能准确迅速的串起整个处理流程。

如何设定LOG 的等级

一般 LOG 会分成 FATAL ERROR WARN DEBUG TRACE INFO 等级别。
- FATAL
- ERROR
- WARN
- INFO
- DEBUG
- TRACE
以上几个级别优先级依次递减。在工作场景中,FATAL, ERROR, WARN 这三个级别还比较直观。当程序中发生不可逆转的错误,将导致程序再也无法正常工作的时候可以用 FATAL 输出LOG,FATAL 输出后程序将会退出。ERROR 表示任务处理过程中发生错误,但其他的任务可以继续进行,程序还是可以正常工作的。WARN 表示任务处理过程发生了不寻常的事情(如内存占用较高,任务处理时间过长等),应该引起注意。
INFO 是为了输出任务执行的状态,DEBUG 是要输出一些具体的变量值以利于排查 BUG。那么 TRACE 是用来在各个函数的入口和出口输出 LOG 用于跟踪程序执行的情况。
实际工作中往往会碰到发现一个 BUG,结果在日志里一无所获的情况。这往往都是由于生产环境的 LOG 级别设置导致一些能够用于定位问题的关键 LOG 没有输出导致的。但在生产环境中设置较低的 LOG 级别大部分情况下不会用到这些信息。
所以我们首先要使用正确的 LOG 级别来输出 LOG,对于出错的情况一定要用 ERROR 级别的 LOG。同时程序应该可以动态的调整 LOG级别。当万一出现由于级别不够没有足够的信息来分析问题是,可以临时调低 LOG 级别以产生更详细的 LOG 用于排查 BUG。(可以通过信号让程序重新设定 LOG 级别)
生产环境的运维工作中应该把监控程序的 ERROR,WARN LOG数量放在重要的位置。对于 ERROR LOG 应该积极认真的对待。如果程序每天都产生大量的 ERROR 应该想办法消除 ERROR 产生的原因。如果由于特殊原因导致一些 ERROR 无法从系统中消除。那么应该考虑降低这一事件的 LOG 级别。这样 ERROR 才不会变成喊狼来了的孩子。才不会让一些严重的问题被这些“无关紧要”的问题掩盖!
最后还要说说 FATAL。在c++, java 这种提供了异常机制的语言中,程序员大多会倾向于选择捕获所有的异常。我更倾向于速错的理念。当程序已经运行到无可救药的地步的时候,就应该放手让它去死。因为即使捕获了异常,业务还是无法正常进行。还不如用 crash 这种方式给运维和程序一个有力的冲击。这样能够让问题更快的被发现和解决。真正做到来一个灭一个,来两个灭一双。

分多个文件输出 LOG?

有些程序会产生多个不同类型 LOG 文件。对此我的看法是,对于相关性不高的内容可以分别往不同的文件中输出。既不能出现要查看一个事件的 LOG 时要分别打开多个文件的情况!在这个前提下,分成不同类型的文件是有利于后期的 LOG 分析的。因为每一个文件相对比较小,而且可以多个分析任务并发的执行。
另外一种分多个文件输出 LOG 的情况就是 LOG 文件超过一定大小后,LOG 转向新的文件输出。这几乎是所有 LOG 库的必备功能。在这里我要指出的是,单个 LOG 文件的大小上限设置要合理。文件太大的话,一般的文本编辑器根本无法应付。文件太小的话,一个任务的 LOG 可以分散在多个文件中,而且在生产环境中还会出现文件下标漂移的情况(就是,你的任务 LOG 本来在 001 002 003 这三个文件中,在你查看完 001 之后,002 003 已经变成了 004 005 了)
那么LOG 文件的上限设多大合适呢?这主要取决于产生 LOG 文件的机器配置,因为 vim在打开大文件时还是很消耗内存的。对于 IO 和内存比较好的机器可以设大一些。对于弱一点的机器就应该小一些。同时还要考虑打开大文件时占用的内存会不会给机器带来性能压力,导致运行中的程序受到影响。
我在工作中就碰到过 1G 大小的 LOG 文件。每次用 VIM 打开都要等上好几个世纪。查找内容时也慢得叫人吐血。像Dell PowerEdge R610: Xeon E5606 2.13GHz 4 Core, 8G 内存的机器。 LOG 文件的大小不应该超过 200M。

LOG 应该保存多长时间

一般情况下服务上应该要保存一周的 LOG。保持 LOG 的循环更新。有集中统一的 LOG 中心的系统应该在 LOG 被清理之前把关键的 LOG 信息发送到 LOG 中心。用于进行大数据分析

利用周期性 LOG 产生报表

通过 LOG 产生报表以了解系统的运行情况,是 LOG 的主要用途。一般有两种策略。
- 程序实时输出各种数据到 LOG,通过独立的分析程序从这些 LOG 中得出统计数据生成报表
- 程序内部自己实现各种数据的简单汇总输出到 LOG,直接或者通过分析程序间接的生成报表
前一种对主要程序影响较小,但会产生大量LOG,对后一阶段的分析程序来说会有一些压力。磁盘 IO 方面的负担也是一个缺点。后一种的好处就是性能上比较好。但是程序除了关系实现业务功能外,还要在统计方面做一些工作。

关于报表

报表对于一个软件系统来说实在太重要。小到性能分析,故障排除。大到业务调整及公司的经营策略。报表都是这些工作的有力工具。大方向的报表需求不在讨论范围内。对于性能分析和故障排除有帮助的报表是我关注的重点。
一个系统大致上应该有这几个方面的报表:
- 业务吞吐量
- 各环节的处理时长
- 不同种类业务的处理量占比
- 各类错误发生的数量
- 各类警报的数量
- 物理机的 CPU,内存,IO 等基本性能数据

在业务正常运行的时候,报表只能看个潮涨潮落。像这样:
linux分布式系统开发心得
linux分布式系统开发心得


当出现问题的时候:

linux分布式系统开发心得
linux分布式系统开发心得

通过和正常时段的报表对比。就能够从宏观上知道现在系统的状况。帮助你大致的定位问题的范围。
总结下来,报表应该从多个角度来展示系统的状况。当出现问题时,通过多个角度的观察和与历史数据的对比,就能快速定位问题。甚至对问题发出预警。

关于测试

测试是个老话题,但也是程序员都很头痛的问题。乐意写测试的程序员很少。没吃过测试不充分的亏的程序员几乎没有。因为测试永远不可能发现所有的问题,总有一些BUG 会在版本上线后跑出来四处放火。
这个现象不能成为不做测试,少做测试的理由。但也不应该走向过度测试而影响进度的极端。对于测试我觉得应该执 scrum 方法的态度。够用就好。
可是怎样才叫够用就好?我觉得测试应该覆盖所有的功能点,所有的模块接口。测试用例除了正常请求外,还应该包含一些典型的错误场景。

功能/接口测试

对于接口级别的测试应该尽量实践 TDD(测试驱动开发)。关于测试驱动开发网上详细很多。

压力/性能测试

压力测试应该在每一次大的改动后都进行一次。有时间的话应该每一次版本发布前都做。
在如何做压力测试这个问题上,我纠结了好久。尝试过用 python 写过一版压力测试框架,采用多进程+协程的方式来产生压力,同时能够实时的压力曲线,性能曲线。最终以失败收场。
原因是对于不同的业务,很难做到通用。有的业务只要一个典型的请求就可以,有的业务要一组请求才行。实在没有足够的精力来做到足够好。还有就是 python 自身的性能并不适合产生压力。经常出现压力测试工具想压死被测试服务,结果自己先吐血身亡的情况。
现在番然醒悟,对每个程序都可以量身打造一个小巧的压力测试,跑在和测试程序相同配置的机器上,一对一PK,就足够检验程序的性能表现了。

关于版本发布

版本更新和发布一般都要重启服务程序。
什么叫一般都要?还有不要的?
嗯,如果程序是运行在应用服务器中,是可以做到热部署。但也很难做到对业务完全无影响的更新。对于有严格的无故障要求的系统是可以通过一个精细的控制做到这一点的。这个可以另开一篇专门讨论。
版本发布应该尽可能采用灰度发布的方法。要采用灰度发布,对分布式系统是有一定要求的。系统必须能够灵活控制业务流的 hash 规则。从产品用户群中按照一定策略选取部分用户,让他们先行体验新版本,通过收集这部分用户对新版本显式反馈(论坛、微博)或隐式反馈(应用自身统计数据),对新版本应用的功能、性能、稳定性等指标进行评判,进而决定继续放大新版本投放范围直至全量升级或回滚至老版本。

当程序员客串消防员

常在河边走,哪有不湿鞋的。做为一个服务端的程序员,总有半夜被夺命 call 叫起来救火的机会。这个时候你不再是一个程序员,你是一名消防员!
当出现紧急故障的时候。首先是故障的范围和大致的原因。然后是保存现场(备份 LOG,镜像文件等等)以备事后分析。接下来就应该努力恢复。
可是怎么努力?如果是因为环境或者配置的原因还有可能快速搞定。如果就是程序的 BUG 呢?如果这个 BUG 不是一下子就能修好的呢?怎么办??只能眼睁睁看着报警吗?如果故障不仅仅是功能性错误。还对核心业务产生影响呢?
这个时候就要看你的程序在开发的时候没有没对各个功能模块设置开关了。如果有,恭喜你!如果没有?还不赶快去加!!
当你有各种功能开关的时候。处理紧急故障时,在确定了故障的范围后,你可以气定神闲的关闭出故障的功能模块。发出公告表示工作人员正在全力抢修。这时候你可以不用背负太大的压力好好的修复 BUG. 验证无误后更新版本再重新打开开关。这个时候你就不用客串消防员了。你是一名苦逼的程序员。