留心那些潜在的系统设计问题
在系统设计阶段考虑全面很难,有许多人倾向于把整个设计分成若干阶段,在迭代中完成整个设计,这本身是非常好的,但是,就如同“先做出来,以后再优化”这样的经典谎言一样,本身并无错,只是许多程序员都不习惯于真正的迭代设计和迭代优化。举例来说,有一个日益复杂的类,每个人都修改一点点,一直到最后都没有人愿意去做重构,大家的心态都是一样的:“我只修改了一点点,为什么要我去动那么大的刀,于我没有任何好处”。我不在这里谈论这一问题的解决办法,我倒是想说,在开始阶段考虑清楚问题在多数情况下还是很有好处的,设计考虑得越是清楚,在后续阶段代码可以承受越多的变更而不腐朽。
再做系统设计的时候,我们常常会这样说:“一般情况下”、“99%”和“基本上”等等。如果你发现这是在悄悄地,或者潜意识地避谈问题,可就要小心了。有时候你可以找到根据,“事情不会那么坏吧”,“不会那么不凑巧吧”,在系统设计阶段尽把事情往好的方向想可未必是件好事;也许更多时候会觉得这是直觉,总觉得某一处设计别扭,不合理却有说不出强硬的理由来,最多只能抱怨一句“通常它不应该是这样设计的”。这种情况发生的时候,请千万不要放过它,很多次,在系统上线以后,最初的问题或者潜在的问题最终暴露出来,而这样的问题很多在系统设计阶段都是有端倪的。
例子1:用户行为记录的持久化
以前我参与做过这样一个系统,用户的行为需要被记录到数据库里去,但是每条记录发生的时候都写一次数据库觉得开销太大,于是设计了一个链表:
- 用户的行为会首先被即时记录到链表里面去;
- 每十分钟往数据库里面集中写一次数据,然后清空链表内的数据。
看起来确实可以实现需求,可是,这样的设计有什么问题?
这样的设计当时居然没有受到系统设计评审的人的质疑,我实在觉得奇怪。我想很多人都可以看得出潜在的问题:
- 清空链表数据是使用时间条件触发的任务来完成,换言之,无论这十分钟内如果事件暴增,也无法触发链表清空的行为,链表很容易变得非常大;
- 清空链表的任务如果执行过程中出了异常,甚至仅仅是处理速度受到阻塞,将直接导致链表数据无法得到清空;
- 如果往数据库里写数据和清空链表的行为需要锁定链表,倘若链表很大,或者写数据库过慢,都会导致链表写行为被阻塞。
这些问题当然在明确的情况下可以得到规避,但是毫无疑问,这样的设计充满了潜在的危险。事实上,最终这样的问题也确实发生了,导致的结果是链表巨大,撑死了整个系统,OOM,系统失去响应。
例子2:HashMap并发访问导致死循环
非常常见的并发访问HashMap的问题,我也遇到过。有潜在的危险导致HashMap死循环,表现就是CPU占用100%,而且这样的问题是不可逆的,问题的原因分析我相信大家可以在网上搜得到很多文章,我就不啰嗦了。我印象深刻的是当时定位完问题,向犯下错误的程序员解释原因的时候,他居然还说:“这个HashMap的读写很不频繁,哪有那么巧的事?”,这就是侥幸心理,即便知道了问题依然不愿意做出修正。
例子3:摘要算法的冲突问题
类似的问题还有,使用摘要算法的时候,比如MD5,我在做一个系统,使用一个中心集群缓存,使用一个巨长的字符串的MD5摘要来做key,好处在于key的长度可以大大缩短,但我们都知道,任何摘要算法都会使得结果字符串存在冲突(重复)的可能,即源字符串不同,但是摘要字符串相同,虽说用统计的话来说,单纯两个字符串发生这种情况的概率低到几乎不可能发生。但是我们依然需要谨慎,尤其是在数据量巨大的情况下,一旦发生冲突,要有解决办法(比如把源字符串放在缓存条目的结果对象中,在缓存条目命中,正式取出返回前,再进一步比较源字符串以确定100%的准确性),或者至少必须要能够承担风险。
例子4:文件处理后续流程的两个问题
最近有一位同事向我们介绍了他最近处理的一个问题,这个问题是,用户会上传一个多行的文件,比如文件有一万行,每一行都代表一条待处理的数据,在数据正确的时候,一切都正常;倘若有一行数据处理发生错误,会自动发送一封邮件通知,看起来似乎很不错的系统。但是这个时候问题来了,有一次文件的处理错误过多,导致一口气发送了几千封邮件,变成了邮件洪水。而在他介绍这个系统设计的时候,我们留意到了其中存在一个时间条件触发的任务,任务基于两个数据库的数据执行,这两个数据库的数据同步是单独完成的,因此可能存在数据不一致的情况,并且在这里假定在数据更新的一小时以后,两个库的数据就会一致了。这其实就涉及到了两个问题或者隐患,一个是邮件处理和发送的数量缺乏控制,另一个是用假定的时间来保证数据的一致性。
例子5:单点故障问题
单点故障问题也是很常见的会导致服务失去的问题,出了问题所有人都知道原因,但是有时候就是很难在系统设计阶段识别出来。以前我们给电信运营商提供服务,很多电信运营商通常有钱(比如国内的三家垄断巨头),不太在乎成本。服务器用的单板几万块钱一块,备了几十块,文件存储是一个大型的磁盘阵列,数据库是IBM小型机双机备份(PS:IBM的设备其实挺不可靠的,听维优的同学说,保修期内屁事儿没有,保修期一到一台台IBM的机器开始坏,搞得像定时炸x弹似的),当时唯独忽略了单点的负载分担硬件——F5,F5挂掉的时候,工程师都傻了眼。
例子6:文件不断写入导致磁盘满的问题
文件写满磁盘导致空间不够的例子也非常常见,绝大多数写文件的场景大家都会留意到,并且在系统设计评审的时候都会有人站出来问,“xxx的文件写入是否是可控的?”。但是,由于文件写入的场景非常多,还是有很多情况被忽略。比如JVM的GC日志的打印,这样的文件可以协助定位问题,但是如果不设置文件上限大小参数,就有导致磁盘空间不足的风险;还有日志文件,绝大多数系统都有日志文件压缩或者日志文件转移的脚本,但是和前面提到的例子1一样,一方是生产者,一方是消费者,消费者出了问题,就会导致数据堆积。如果这样的文件处理脚本执行出现问题,或者在系统压力大以及系统异常情况频繁的时候,日志疯涨,来不及及时把日志文件转移出去,导致日志文件把磁盘撑满。通常对于要求比较高的服务,磁盘空间监控是必要的。
例子7:服务器掉电以后的快恢复
再说一个问题,这个问题是从一个技术分享中流传开来的。亚马逊网站的数据都是页面服务器先从缓存服务中获取数据,通常这个命中率很高,如果获取不到数据或者数据过期以后再到数据库里查询。这样的模式非常常见,我们也总能看到很多技术报告里面写平均的缓存命中率能够达到百分之九十多,可以飙到多少多少的TPS,为此可以节约多少多少硬件成本。初看这样的设计真不错,但是很容易忽视的一点是,这样的数据是建立在足够长时间,以及足够多统计数据的基础之上的,但是在单个时间段内,缓存命中率可以低到难以承受的地步,导致底层的数据服务直接被冲垮。有一次亚马逊机房突然掉电,在恢复的时候把网页服务器都通上电,这时候缓存服务还几乎没有缓存数据,缓存命中率几乎为零,于是大量的请求冲向数据库,直接把数据库冲垮。外在的表现就是,掉电导致网站无法提供服务,短期内访问恢复,随后又丧失服务能力。
软件当中有些东西和经验有密切关系,不像很相对容易提高的语言技能和算法,系统设计经验,尤其是对问题的预估很需要时间和项目的磨炼。我不知道这样的系统设计经验怎样才能快速积累,但是我想还是有一些常规模式可循,我不知道是否有比较经典的资料可以学习。另一方面,系统设计真是一个细致和谨慎的活儿,不要随意放过那些潜在的问题,有时候甚至就是一点奇怪的感觉,或者是设计图看起来不那么协调和稳当,细究下去,还真能发现陷阱。如果你也有类似的经历,不妨谈一谈。
文章系本人原创,转载请保持完整性并注明出自《四火的唠叨》