上周面试一个人,聊到了服务性能优化。

他:线上的推送服务使用的单机单线程,性能撑不住。于是就改成了多机,还加了线程池。
问:撑不住是什么表现?什么原因?
他:因为是短信验证码服务,短信通道堵塞,所有请求都堵在vm里,引发了OOM。
问:看起来缓存使用内存不够,跟线程池有什么关系?
他:呃。。。正好当时在试用一个线程池库,就用了。

当时的对话没这么简单,因为并不是所有人都能说明遇到的问题,更不是所有人都能坦诚面对自己的折腾,况且我还要考虑提问的方式和方法。但情况大抵如此,在服务研发中最不愿看到的场景,恰恰是最经常发生的。

A:线上服务有bug,我hotfix一下。唔,正好还优化了xxx,一起上线了。
B:这次增加了一个新功能,顺手也把vm参数改了,上去看看效果。
C:感觉请求处理的地方有点问题,我改了下,应该会起作用。
。。。

每次听到“正好”“顺手”“感觉”这样的词,我都得浑身一个激灵。天上要掉馅饼了?

真实情况往往相反,天上掉下来的是石头。这说起来也是不幸,要不墨菲不会那么出名。

要控制问题域

Person of interest

其实互联网服务做久了,线上问题处理早已变成了家常便饭。因为用户不能忍受宕机,所以你有问题查起来基本是不眠不休。个中滋味,只能自己慢慢体味。

这问题有时候是新问题,你刚改的代码有个bug,有时候是老问题,但是是暴露了老代码里的bug。多数情况下,都可以通过回滚代码来解决。个别情况下,你需要增加一个fix版本重新上线。这些都简单,难的地方在于定位问题。

定位问题最怕问题域太大。因为你得一步步分析,将可能发生问题的范围缩小。从现象出发分析,从改动入手检查算是一个捷径。如果改动太大,无疑会增加思考的成本和定位的难度。

而紧跟着的问题就是,如果作为一个fix版本上线,是否可以带入其他改动。我的回答是不行,因为也有太多次的失败来源于这个hotfix。如果再次出了问题,你需要首先判断是不是老问题没被解决还是引入了的新问题,这又涉及到整个请求处理链的重头分析。

我们不怕优化失败,但是怕优化出来的失败。优化失败最多还是老样子,但优化出的失败很可能是一次雪崩。

有些优化不要做

回到最开始的那个例子,他的问题表现出来是盲目引入新库,做法与我们常规的处理方式相背。根源则在于对问题的分析不足,不清楚问题的原因,当然可怕的地方在于(也是不可避免的),还使用了试试看的方式来修复问题。

应该做哪些优化呢,简单来说就一句话,做该做的优化。谈这个问题很多人都有经验,就算刚才说的“试试看”,在某种程度上也算。不过我今天想说的是不应该做的,一些经常被忽视的事情。

  1. 不要用不需要用的东西

    第一个事是不要用不该用的东西。一般来讲,互联网服务的峰值会很明显,峰值压力会是平时的十倍甚至百倍。应对请求洪峰,简单的方法就是用队列削峰。但是很多人用惯了队列后,只要设计服务,都会使用队列分离。但队列一旦进来,不仅会增加服务的复杂度,而且割裂了前后端之间的联系。

    由于已经跟前端解耦,很多时候在处理请求的时候,前端已经返回了。这时候发生错误的话,已经没办法通知用户,导致服务只能偷偷失败。这是很容易气坏用户的处理方式。

  2. 可以失败不要犹豫

    另一个事是可以失败不要犹豫。很多时候服务都是分层的,外层服务访问内层服务,需要应对各种失败,有环境问题像网络闪断机器故障等,有服务内部故障像空指针虚拟机崩溃等。那么问题来了,内层服务失败了怎么办?举例来说,如果用户注册时数据库出现问题,该返回错误还是重试?如果重试次数设多少?如果是超时怎么办?超时要设多久?

    除去数据问题,这里面经常被人忽视的却是性能问题。假设正常情况下10ms返回的请求,超时是1s,那么遇到超时的时候,请求处理速度其实下降了100倍,而如果还有重试,那就会继续降低。性能下降的直接结果是导致后续请求要么堆积要么丢弃。堆积会占内存,很多情况下会引发服务本身OOM退出,让服务质量雪上加霜。

    其实开始的时候直接失败,也就没有后续这么多的失败了。或者超时短一些,性能也不至于下降太多。看起来设计很完善的错误处理策略,很可能不过是往背篓里加了无用的石头而已。

  3. 不要修不该修的东西

    还有个事就是不要修不该修的东西。Wikipedia里一直有句提醒,“If it ain’t broke, don’t fix it”,但是没说原因。这里说两个例子。

    一个是虚拟机调优,不管是Java还是Erlang都会涉及。Java里多的是GC参数,Erlang里一般都搞调度器,情况差不多。如果哪一天你翻看参数说明,发现某个GC算法看起来更好或者新生代比例好像不太合适,或者发现CPU利用率一直不高而调度器线程池貌似可以调大,千万不要手一抖改了线上服务。因为线上服务实际运行情况复杂,很可能因为你的调整效果反而下降。GC调完引来了虚拟机OOM,调度器调完了请求超时不断。

    另一个是数据一致性问题。举一个最近的例子。Mnesia是Erlang自带的数据库,速度非常快,跟OTP集成度高,但它在事务处理的时候会出现死等情况。由于两个节点通讯是请求应答模式,请求节点在发出请求后等待,等待的过程会定时检查对端节点的存活情况。问题出在这个定时检查上,它假定对端只要在线就肯定会回应。但如果对端节点在这个检查中间死而复活,那么它即使在检查的时候活着,也不会再回应了,请求节点就因此一直等下去。同时由于所有的消息都是在一个事务管理进城内,后续的所有请求都无法处理了。要不了多久,这个节点就会因此挂掉。

    解决这个死等问题,一个想当然的方案是增加节点启停时间记录,检查的时候多检查一些东西。这也是我们开始时候想的方案。但是然后呢,这个请求该怎么处理?想想刚才讲的失败策略,该丢还是该继续呢?如果丢了,数据一致性怎么保证?如果你的场景像我们一样不希望丢,那么你很可能把原来已经可以常规处理的问题,变成了一个新问题。我们暂时是没有处理这个问题,还因为暂时还没想好要维护一份自己的Mnesia代码。

这三件事只是典型情况,还有很多情况,需要在做之前全面思考。毕竟成果是以产出来衡量,而不是付出,要看你解决了多少问题,而不是做了多少事情。

很多时候,做得少就是做得多。

Behavioral Economics: Why More Is Not Better https://stevensonfinancialmarketing.wordpress.com/2012/10/30/behavioral-economics-why-more-is-not-better/

给小团队的特别建议

小团队的普遍现象在于人力紧张,不管是在创业公司还是在大公司内。对于不写代码就手痒的技术人员,如果再在技术上有点儿完美主义情节,那真是可以为代码鞠躬尽瘁的。稍微一整理,事情恨不得已经排到一年后。但是大公司有绩效压力,小公司有生存压力,怎么办?这个事儿就是优先级处理,处理不好,就会变成目标管理,然后慢慢你就跟真的工地搬砖无二,天天还得受工头的鞭子。

如果你像我一样喜欢轻松的自组织团队,喜欢以一当十的挑战,那你更需要注意这点。如果你只是埋头做一件事,很可能错过了系统改进的时间窗口,等系统压力上来或者问题到来的时候措手不及。你只能过四处救火火不断,缝缝补补又一年的生活。而所有的补丁,最后又变成了你彻底修复时要处理的历史包袱。

你最需要的可能是坐下来,想想该做什么,想想先做什么,把有限的精力用在最值得用的地方。

有些时候,做得对才可能做得少。