这个主题,我已经构思了很久了。程序员经常会用“码农”“搬砖”等等来自嘲:戏虐自己是用别人写好的组件搭建一个业务要求的流程模型,没有什么技术含量。但是,从另一个角度想,假如所有的业务都要求必须是自己敲出来每一个字符,那么不又退化到了从前的手工业时代?

在如今现代工业时代下,每一个人都可以被合理的分工,按排在不同的领域。所有人一起合作,共同推动行业的进步。从宏观的角度来,任何一个工业行业都可以粗分为两个领域:理论与工程。

那么编程究竟是什么?

理论与工程

一个简单的模型是:

  • 理论领域能够抽象出各种理想化模型,天马行空地尝试不同的解决方案,直到能够被工程领域接纳。
  • 工程领域则负责将理论领域给出的解决方案实施落地,切实的解决现实问题。

理论领域在尝试解决方案的之前,首先必须明确一个信息:问题是什么。无论理论领域如何抽丝剥茧,这个问题终究来源于实践,而非凭空设想。因此说,工程领域首先肩负着一个责任:识别问题。正确的识别问题,才能给理论领域给出正确的努力方向。否则,便是资源的不合理利用。

理论领域给出了方案,工程领域能够立即投入实施吗?也不能。理论领域,可以测试各种解决方案,因为失败的代价较为轻微。而工程实施则需要慎之又慎。同一个地点,只能架一座桥梁;同一个山头只需要一个铁塔;同一个公司最终也只需要一个解决方案,可能是某一套具体的设备,也可能是某一套完整的规范。后者保证了供应商的可替换性,不过这属于另一个话题了。

理论领域可以给出无数种选项,工程领域只能选择其中之一。那么,选择的依据是什么?

工程的实施是受到各种成本和条件限制的:经济成本、时间成本、维护成本、容量需求、可持续性需求、健康环保要求、能耗要求等等:

  • 经济成本:实施方案的经济代价。人力薪酬开销,机房或云计算服务器的租赁开销等等。
  • 时间成本:实施方案所需要的时间。
  • 维护成本:方案施工结束后,仍然需要一定的维护。例如代码需要改善一些细节,必须保证这些调整的成本足够低。
  • 容量需求:解决方案要满足一定的容量要求。例如过去服务器领域的C10K问题,要求服务器能够每秒钟处理1万个连接。
  • 持久性要求:方案施工结束后,必须保证在一定的时间内保持有效。应当竭力避免:团队用了10个月写好的业务系统刚刚上线3个月,就发现性能冗余不足,被迫发生重大结构调整,甚至推倒重来。
  • 健康环保能耗要求:在其他成本大致相同的情况下,尽量选择能耗低的方案。例如,优先使用性能比较好的方案,减少服务器用例数量或CPU、内存的占用。
  • 其他要求:每一个问题都有自己具体的场景,很有可能存在独特的要求。

面对如此多而繁杂的要求,解决方案却只有一个。工程方需要考量诸多条件和限制,选择出最适合的方案。比较有意思的是,只有时间才能证明实施方案的选择的正确性。无论前期论证时,如何有理有据,后期仍然有可能会打脸。俗称,“薛定谔的方案”?从这一点来说,工程领域需要具备权衡的能力。

理论和工程没有绝对的界限

按照上面这个思路,我们再考虑一下程序员这个行业如何分工。

乍一看,程序员中,能够划入理论领域的,通常都在大学或者某些大公司的研究部门。比如:

  • BSD操作系统、大数据领域的Spark,诞生在加州大学伯克利分校;
  • Linux操作系统是Linus上大学的时候写的;
  • Intel公司的软件程序员,日常工作便是参与编译器和操作系统开发,构建新模型,尝试提高它们的性能。

而工程领域呢,就是诸多公司的业务部门了:可以是Apple、腾讯这样的大公司,也可以是身边五花八门的外包小公司。

有意思的是,工程领域的公司,因为各自的领域不同,也有自己不同的“理论领域”和“工程领域”。

例如,对于常见的小公司来说,数据库的实现是一个理论领域的问题。他们自己没有资源去开发完美满足自己的数据库,只能够根据市场给出的各种选择,权衡利弊之后选择较为适合的。但是数据库开发本身又是一个工程问题。而开发语言(C/C++/Rust)、数据存储模型(行式存储/列式存储)、索引的数据结构和算法,等等,这些又是他们的理论领域问题。

回过头来再去想一想前面的“理论领域”的程序员。其实他们也需要足够的工程思维,来理解工业界的问题。否则实现的解决的方案就难以被采纳。全世界上万所大学,实现的通用操作系统绝非Linux和BSD这两种,然而得以流行者却寥寥无几。这是因为他们在正确的时间解决了正确的问题。

理论领域和工程领域,不存在绝对的界限。这一点,非常类似于“分形”这个概念。

工程能力

工程领域应当具备以下能力:

  1. 识别问题
  2. 评估容量
  3. 权衡选择

识别问题

实际生产,遇到问题是很常见的事情。举个例子,双十一的时候,网络购物流量极大,对后端服务能力构成了挑战。

那么对这个问题,应该怎么描述?产品经理可以说:我们需要保证后端服务能够保证客户有良好的购物体验。后端程序员看到这句充满主观修饰词的要求,绝对是一头雾水:“保证”是什么?“良好体验”是什么?没有一致的评判标准下,后端程序员的产出质量又如何保证呢。

量化问题

假如把这句话改成:我们要求后端服务能够处理500,000 QPS的请求,同时99.0%请求必须在300ms,99.9%必须在700ms内返回,最大不超过2s。那么这个评判标准就很具体了。后端程序员甚至可以在产品发布之前预先使用自动化工具,进行模拟测试。而不需要:在产品上线并被客户使用之后,向一肚子怒气的客户发放问卷调查收集意(lao)见(sao)。

能够量化的问题,清晰明了,是好的问题。

没有量化标准,我们甚至难以判断它是否为一个问题。例如,某微服务系统,多实例横跨DC部署在北京上海两地,RTT为20ms。假如业务要求,P999延迟为25ms。那么就完蛋了,因为留给系统内部的时间不多了,只有5ms,还有服务内部调用时间,大概率不够了。假如业务要求P90延迟要求是850ms,那不是很好吗。这么大的余量,甚至可以满足异地容灾需求。

同一套解决方案,在不同标准下是完全不同的评判结果,可见清晰的量化标准有多重要。

深入追问

摆在明面上的问题A,其根因往往是其他的问题B造成的。

这种情况下,草率的做出决定,妄图快速解决问题A,实际上成了一个掩耳盗铃的措施。深层次的问题B被掩盖了起来,很大概率会造成另外的损失。

写一个真实案例。

某天,前端报告在预发布环境中后端API会持续间歇性报服务端错误。经过查看Metric Dashboard,发现JVM的Committed内存极大,达到了3GB多。K8s Pod报OOM,反复重启。

这个例子看上去好像是JVM程序占用过量内存,某处发生了泄露,导致崩溃。“OOM”“内存占用超多”等等字眼无不印证了这个问题。可惜的是,假如朝着这个方向走下去,恐怕是一头栽入框架源码中,再无回头路。

仔细看看Dashboard里的内存占用曲线:

  • 看内存占用曲线,每次都是从底部以一条平直的斜线直达顶峰,并不是常见的锯齿状上升。
  • 不是每次内存占用达到某一时间段的峰值就会直接重启。某些时候,峰值经过数分钟的持平之后,会有一个立即而显著的降低,变为仅仅100MB。这个数字,与本地启动之后的内存占用别无二致。

内存占用能够显著降低,而没有发生JVM崩溃,显然说明JVM正常运行。再仔细想想,这OOM是K8s Pod报出来,并非JVM的OOM。就是并不是内存泄露导致的。再反复查看K8s Pod的前次退出原因,发现竟然还有第二种报错偶发性出现:Pod健康检查失败。

其实这些信息通通汇总到一起,就会发现:

  1. 内存占用直线到达顶峰才会下降,说明此时才发生了一次minor GC,也就是Eden区+Survior区一起竟然有3.x GB之多。
  2. Pod健康检查失败恰恰是因为,GC停顿过久,导致健康检查接口失去响应,K8s发生误判,以为进程异常,所以Pod重启。
  3. Pod OOM,则是K8s内部的机制,占用了超出声明资源量的Pod会排在OOM列表的高优先级位置,整个Node上的节点被轮流挨着OOM!

最终与运维团队沟通后确认,问题的来源是JVM版本过老,未能识别新上线Node所采用的CGroup V2 API所定义的资源限量,所以将整个Node上的内存都识别为可用内存。所有Pod里的JVM均是如此,所以完全乱了套。

明面上的内存泄漏问题,其实并非根因。新上线的Node才是问题的直接原因。

可是,我们就能卡住Node的CGroup API永远保持在V1吗?恐怕不行。直接这样解决问题,不过是只把头埋进沙子里的鸵鸟。如此下去,有一天K8s的版本都得被固定在某一个版本上,无法前进。

所以我们真正需要的,应该是:安排各个后端应用,能够保持稳健的节奏,持续升级JDK版本,而不是永远固定成JDK8。

评估容量

工程领域也需要具备评估当前成果的实际容量能力。以保证在实施过程中,不会有大的偏差。

例如,业务要求请求在500 QPS下满足P90延迟<=100ms。后端程序员在实施中,采用了微服务架构,按设计,每一条请求需要经过3次服务间API调用,发起了5次数据库请求。

为了评估这个系统能够否满足要求,后端程序员必须清楚在500 QPS下:

  1. 服务间网络性能,例如RTT等指标;
  2. 每个微服务处理请求的延迟,包括所采用的服务端框架处理HTTP请求的延迟L1,以及业务代码的实际运行延迟L2;
  3. 数据库处理请求的延迟L3。

从而请求总延迟 L = RTT *8 + (L1 + L2) *3 + L3 *5

在评估技术选择的时候,如果能够保持对每一个参数有足够的了解,那么技术团队就能够做出更适合的决定。

同样的道理,假如后端程序员经过分析可以发现留给数据库方面的P90请求延迟只剩下10ms的余量了。同时经过测试,所使用的数据库理论上最快P90延迟也只能达到15ms。那么就要考虑变更数据库选型,或者优化网络架构,降低网络延迟。避免在无谓的方向上浪费过多的时间。

评估容量的一个要求是必须对系统的实际运行过程有一个准确的认识。认识越准确,评估结果越接近实际表现。

权衡选择

工程中总是充满了权衡。很难有什么银弹方案能够一劳永逸的解决问题。

追求开发进度,就免不了架构设计的简单些,否则,feature开发占用大部分时间,就来不及测试,上线以后bug难免满天飞。

想要满足高并发负载的请求,就必须把系统设计的可以横向扩展。状态外置,还要满足高可用。系统变复杂了,对开发和运维的要求就会比较高,人力成本也就高了起来。

尽管需要考虑的限制和条件要求有很多,但是也不会所有的都需要绝对的重视。否则各方掣肘,进度很难推进。

工程领域需要权衡各个限制条件的优先程度,剔除低优先级限制条件带来的影响,保证结果绝对满足高优先级限制条件,基本不违背低优先级限制,尽量满足及优先级的条件要求

一种权衡优先级的办法就是,将各个条件限制按照紧急程度和重要程度分为四个象限。其中紧急问题需要尽快解决,重要问题需要持续投入资源。怎么看都不优先的任务则可以“见缝插针”地完成。

“在正确的时间解决正确的问题”,非常重要。解决过去的问题,只是家庭作业。而解决未来的问题,又可称“过度工程”(over-engineering),通样是不划算的,因为当下一定有更为紧迫的任务。

不合理的权衡也是浪费资源。

量化意识

做工程应该具备量化意识,尽可能得将问题量化,竭力减少经验参与决策的比例。

网上都流行以拧螺丝举例子。那这里也可以说说。

问一个问题,怎么样可以叫做螺丝拧紧了?螺丝拧不紧是一件很可怕的事情。螺丝松动,导致机械结构负载不均衡,某一侧就会发生机械强度过载,疲劳断裂,从而引发更大的风险。

普通工人师傅拧螺丝,全凭经验。胳膊拽着手,用力扯一下扳手,如果螺丝没用再次旋转,就说明拧紧了。

但是,如何让每一颗螺丝都能够拧得同样紧。如果拧的不一样紧,在极限情况下,不同螺丝会承受不同的机械负载,直接造成应力集中和短板效应。所以依赖工人师傅的经验来拧螺丝,产生了一系列问题:

  • 同一个机械构件上的每一颗螺丝都是同一个师傅拧的么?
  • 怎么保证每一个螺丝拧紧的程度都一致呢?
  • 当机械检修的时候,发现某一颗螺丝松动了,检修人员还要找到原来的师傅来拧吗?

对于这个问题,工程师给出的方案就是扭矩扳手。扭矩扳手可以保证每一颗螺丝的拧紧程度达到工程意义的一致。超量的扭矩,都会随着扳手的“咔咔”声得到释放。有了扭矩扳手,施工手册甚至可以定义部分螺丝拧的稍稍松一些,以保证有足够的机械强度冗余。

写代码也是同样的。运用量化意识,抽象问题,可以更容易地讨论和解决的问题。这需要程序员多做测试与测算,用数据说话。

总结

程序员在本质上就是工程师,都应当具备良好的工程能力和量化意识。