后台服务器开发领域,还有什么值得爬的山

作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢!


因为我自己的水平还在山脚下,所以我只看见了眼前的这几座山......

二十年前我刚入行的时候,我的岗位被称作"后台服务器开发",如今可能有很多叫法:后端工程师,云原生工程师,微服务开发工程师......等等。为了纪念我逝去的青春,文中仍然沿用了"后台服务器开发"这个古老的词汇。

后台服务器开发这个领域还值得去做吗?

假设一个计算机专业刚刚毕业的师弟师妹,对你问出标题中的这个问题,你会如何回答?

服务器开发工作,有这样一些特点:

  • 服务器硬件的标准统一,结构简单;特别是相比于 IOS/Android 等手持设备,实在是简单太多了。

    • 在腾讯内部,后台服务器开发工程师把后台服务器的主要硬件称为四大金刚,即:CPU,内存,网卡,磁盘。服务器开发的核心任务,就是合理调度和优化这四大资源,以保障服务的稳定与高可用性。
    • 同时,服务器开发工程师也会虔诚地向四大金刚祈祷,以保佑服务不出问题。

    • 从硬件环境看,后台服务器开发一点也不难
  • 服务器硬件的资源相对充裕,不太可能在开发中遇到捉襟见肘的情况。动辄上百核的 CPU,接近 TB 的内存......这是嵌入式开发工程师羡慕不来的。

  • 后台服务器的虚拟化水平已经非常成熟:且不说什么 KVM / 虚拟机 / Cgroups / Docker 了,类似 K8s 这样的云端资源管理和调度系统已经可以把超大规模的集群管理得很好了。

  • 服务端的各个层次的组件也非常成熟

    • 接入层有 LVS, Nginx等
    • CACHE 层有:redis, memcached 等
    • 存储层更是多入牛毛:MongoDB, MySQL(各种 SQL, NoSQL, 结构化的,非结构化的)....等
    • 后端组件种类丰富、发展成熟、易于使用且性能优越。
  • **云服务的成熟,让后台服务器开发面对海量请求和广阔地域分布这样的高难度任务也变得简单。**当然,前提是你舍得花钱。

  • 后台服务器开发工程师,在整个后端的技术层次中,其能做的事情被压缩到了业务逻辑这一层。大多数服务器开发工程师只需要做做业务逻辑,而其他层次的任务都有强悍的后端组件来解决了。

    • 后端组件的成熟,即便是面对高性能海量数据处理也变得简单。

    图中的架构图源于我总结的后端的教程:《海量后台开发-从入门到放弃

    从架构的层次上看,各个层次都有非常好的组件:

  • 从技术发展曲线上看,后台服务器开发领域已经进入了"生产力高原"

    • 如图:服务器开发的技术成熟度曲线
  • 当前的后台服务器开发,其实仍在实践二十年前就应被广泛认知并执行的基本原则。
    • 大约在 20 年前,腾讯的 CTO 张志东(内部尊称他为Tony大师兄)提出了后台服务器开发领域的"海量服务价值观"(或叫做"海量服务之道")。在 2016 年左右,为了配合腾讯云的技术战略,又提出了"海量服务之道 2.0",对之前的理念做了增补和延展。我认为"海量服务价值观"是服务器开发领域了不起的方法论和思考框架,用如此简洁和直达要点的方式,指导我们如何去做好服务器开发的工作。
    • 这些耳熟能详后端设计原则有:立体监控、柔性可用、负载均衡、过载保护、灰度发布......等等
    • 请看:
    • 后台服务器开发领域,成熟的不仅仅只是硬件、组件、工具......,还有如同"海量服务之道"这样的成熟的思维框架。
    • 关于海量服务之道,我想要致敬的内容实在太多,可以跳到"附录:我所理解的海量服务之道"
  • 做好服务器开发的工作,似乎与技术水平的关联度越来越低,而是更需要工程师的"意识"
  • 随着 AI 的不断发展,服务端业务逻辑代码"越来越没营养",越来越多的代码可能都将由 AI 生成
    • AI 会取代大量的开发工程师,当然也包含后台服务器开发工程师这个职业。这个趋势会愈演愈烈,See: 《微软裁员风暴:软件工程岗成为AI 冲击的重灾区
    • 未来的程序员更像是全栈的角色,并且向架构专家、业务专家、技术管理等角色延伸。未来人类程序员可能都会升级到现在的 team leader的位置,他的下属就是 AI,他会以技术管理者的身份把 AI 提供的各种代码片段整合起来。到那时,传统意义上的"服务器开发工程师"这一工种,或许将逐渐被重构为更偏架构设计与技术整合的角色。

总结起来,服务器开发这个开发工作看起来已经没什么搞头,后面又有 AI 的围追堵截,服务器开发工程师应该怎么办?

服务器开发工程师面对 AI 的态度

服务器开发工程师不会等着 AI 来革他们的命。他们对于 AI 挤压他们的生存空间这件事情,下面这张图或许可以用来表达他们的态度:

服务器开发工程师将不断攀登,永不屈服。
他们只会自己卷死自己,没有人(和AI)可以淘汰他们!

值得再爬一爬的技术之山

前面说了非常非常多悲观的话,但对于服务器开发工程师而言,那个一直维持着这个职业存在的理由依然还在,那就是:这个世界对计算的需求仍然非常强劲。

需要计算,就会需要服务器开发工程师。

下面,我设想了一些能够显著提升服务器计算能力的技术,有了这些技术加持,服务器开发工程师可以更深入地挖掘算力潜力,推动后台开发技术迈向新的高度。

JIT 编译服务器

从 Sonic 这个 JSON 解析库谈起

先谈一谈我非常喜欢的一个 golang 组件: Sonic (https://github.com/bytedance/sonic). Bytedance 开源了一个 JSON 序列化/反序列化的库,目前看起来是 golang 领域中用于 JSON 解析组件中的世界第一。

推荐一篇介绍 SONIC 库的文章:《【后台技术】为字节节省数十万核的json库sonic

我看到了一句对这个库很经典的评价:如何用 golang 写出性能极高的代码?答案是不用 golang.

可以发现,汇编代码占了很大一部分。

虽然 sonic 库的性能非常强悍,但是可以预见到,随着这个库持续的维护,将会出现这样一些难题:

  • 适配不同体系结构的工作量会很大
    • 目前支持 AMD_64,如果要支持 ARM64,工作量不亚于重新写一遍
  • 开发上,为了避免复杂的 go build 配置,核心代码只能是与golang兼容的plan9汇编,工程师开发起这个库会特别憋屈。库的维护,以及引入新特性的开发成本很大。
    • 先用 c/c++ 写一遍,确保性能优化的手段有效
    • 把 c/c++ 编译成 AT&T 汇编
    • 手动把 AT&T 汇编写成 plan9 汇编,然后持续测试

以解析 JSON 这样的需求为例,如何写出最快的解析代码

假设某个大数据业务的网关,上游会传来一个 1MB 大小的 JSON。99% 的情况,这个 json的格式(key的数量和类型)不变,连 KEY 的顺序都是固定的。然后,定义了一个与 JSON 格式对应的 struct,要把 JSON 中的值解析到 struct 的每个字段中。

对于这样的场景,如何写出一个性能极高的 json 解析程序?

json 复制代码
// 例子
{
  "key1":"value1",
  "key2":"value2",
  "key3":1234,
  // .....
}

对于一个 json.Unmarshal() 程序,内部一般会这么写:

go 复制代码
// 伪代码
type Target struct{
  Key1 string `json:"key1"`
  Key2 string `json:"key2"`
  Key3 int    `json:"key3"`
}

func parse(jsonAst, targetStruct){
  // 遍历所有的 key 和 value
  for key, value := range jsonAst{
    // 根据 key,在 struct 中找到 tag; 根据 tag,找到偏移量
    offset := find_field_by_tag(key, targetStruct)
    // 根据偏移量,解析 value,然后填充到 struct 中
    // 这里还需要考虑 value 的数据类型
    fill_struct_by_offset(offset, value, targetStruct)
  }
}

因为条件中提到, 99% 的情况,这个 JSON 的格式都是固定的。因此,针对这个特定的 JSON 格式来写解析代码,性能一定更好:

go 复制代码
// 伪代码
func fastParse(jsonAst, targetStruct){
  key, value := jsonAst.Next()
  if key!="key1"/*unlikely*/{
    // 99% 的情况,第一个字段是 key1,因此不会走到这个分支
    // 对于 1% 的情况,走回到慢解析的兜底逻辑去
    return slowParse(jsonAst, targetStruct)
  }
  // 填充字段的时候:
  //  1 因为这段代码是专门针对 key1 的,因此直接写常量就行了
  //  2 同样,数据类型也是预先知道的
  fill_string_field_by_offset(0, value, targetStruct)  // todo: 为了预防数据类型不匹配,也可以在此处进行检查
  key, value = jsonAst.Next()
  if key!="key2"/*unlikely*/{
    return slowParse(jsonAst, targetStruct)
  }
  fill_string_field_by_offset(24, value, targetStruct)
  key, value = jsonAst.Next()
  if key!="key3"/*unlikely*/{
    return slowParse(jsonAst, targetStruct)
  }
  fill_int_field_by_offset(48, value, targetStruct)
}

通过上面的例子,我们可以领悟这样一些情况:

  • 专门针对某个特定的格式来解析 JSON,与一个通用的解析 JSON 的程序相比:

    • Key顺序、字段类型、字段偏移量等很多信息,是编译期就可以获得的
      • 进一步:如果很多格式的size提前就知道大小,则很多局部容易使用 SIMD 指令集来加速
    • 如果被解析的数据与这个专用解析程序完全匹配,则程序会执行到函数的最后一行而没有跳转。从 CPU 的角度而言,这会提升代码cache的命中率,并且整个取指/译值/运行的流水线会很好的配合起来。
    • 无分支跳转可以优化程序性能,这里推荐一篇帖子详细介绍这种优化方法:《性能优化--无分支编程(branch-less programming)

    • 如果二进制代码布局能够识别 likely / unlikely 这样的提示,那么 99% 情况下的执行代码都是紧凑排布的,相信对分支预测有帮助。
  • 如上代码的缺点是:

    • 整个函数只能解析特定的 JSON 结构,不是这个结构则都会降级到 slowParse() 的通用 JSON 解析上面去
    • JSON 的格式成千上万,没有通用性的代码谁愿意写......

其实,通过一种机制来动态生成代码就好了:对于特定的格式,生成针对这个格式专门代码,使得世界上已经不可能出现比这个专门代码性能更好的代码。这便是 JIT ------ JITJust-In-Time Compilation 的缩写,中文叫做即时编译

JIT 编译服务器实现细节

我相信 Sonic 库内部也实现了某些 JIT 的能力,不过读懂这个库实在太困难了;同时,JIT 这项能力也无法简单地从 Sonic 库中扣出来并应用到其他项目上。特别是 CPU 体系要更换为 ARM64 时,只能另寻它法了。

我们期待这样一种使用 JIT 来提升服务器性能的组件:

  • 针对特定的格式或者特定的任务来生成高度优化的代码
    • 格式方面,可以是:JSON的序列化/反序列化,protocol buffers / thrift / flat buffers 等常见二进制序列化框架的高性能序列化 / 反序列化。在确定格式的场合,JIT 生成的代码性能高于通用的解析库。
    • 特定任务方面,可以是:某种特殊格式或大小的矩阵运算,特定图像的某种像素变换等。
      • 例如,我训练好了一个 LR 模型,我可以把这个模型通过 JIT 编译成高度优化的机器码,从来带来极好的预测性能。等到模型更新时,再编译一次就好。
  • 使用端的 SDK 小巧且轻量
  • JIT 的能力能够线上热更新,可以持续调优,而不必引起使用端的重新编译和发布
  • 可以支持更多的端和体系结构
    • 例如:支持 windows, macOS, linux, android, IOS 等主流的操作系统
    • 体系结构上,支持 X64 和 Arm 64

为了满足以上要求,可以专门做一个独立的服务来进行 JIT 编译。架构图如下:

JIT 编译器服务器有这样一些特点:

  • 使用 LLVM 这个神器来编译出各个操作系统/各个硬件体系的机器代码
  • JIT 的程序逻辑,以 LLVM IR 来作为描述语言,然后通过 LLVM 的程序库进行编译
  • IR 语言有两种生成模式:
    • 可以根据请求端提供的数据格式和内存布局,以一定的规则来生成 IR
    • 可以通过完善的提示词,来使用大语言模型来生成 IR

使用端的 SDK 非常轻量,仅需要:

  • 把特定的数据格式和反序列化后内存的结构信息作为输入,提交到 JIT 编译服务器
  • JIT 编译服务器完成编译后,把机器码返回给调用端
  • SDK 使用 mmap 申请内存,并对内存加上执行权限
  • 在 mmap 内存中拷贝入编译好的机器码
  • 定义函数指针,指向 mmap 中的代码
  • 业务端使用函数指针来完成高性能的数据解析
  • SDK 可以动态 profile,把并能数据上报给 JIT 服务器,最终达到线上的实时的 PGO 效果
  • SDK 可以定期访问 JIT 服务器,从而可以做到热更新

本质上,对特定场景的 IR 代码生成,相当于实现了 llvm 体系中的一个前端。

综上所述:

  • 通过 JIT 编译服务器,可以把微服务中令人头疼的数据序列化环节的性能损耗减小,并可以持续调优
  • 所有存在性能热点的环节,都可以根据上下文来动态生成所处环境中最优执行策略的代码

由近及远,分层通讯

或许在未来,再没有什么"网络",只有访问时存在不同延迟级别的内存而已。

微服务架构是基于 IP 网络来设计的,看似这样的架构连跨大洲的分布式环境也能适应。然而,我们无法忽视不同硬件层级、不同地域距离所带来的延迟问题。比起直接了当的使用IP网络,未来如果需要在服务器性能上深耕,就应该根据不同的延迟级别来构建不同的访问模式,总有比直接使用 IP 网络更优的场景。

延迟级别方面,由快到慢,可以这样划分:

通讯范围 特点 延迟级别
同 CPU Core * 数据或许在 CPU cache 中就完成了交换 * 完全不用考虑加锁 * 不建议使用物理线程,而是使用 event loop 或者协程的模式来做并发 * L1 Cache: ~1 ns * L2 Cache: ~3-5 ns * L3 Cache: ~10-15 ns
同 NUMA * 为了使用多核,必然使用多线程 * per core 架构可以很好地减少锁的开销 * 内存:50-100 ns
同物理服务器 * 不建议跨 NUMA 访问;建议把一个 NUMA 虚拟化为一个完整容器来使用; * 仅仅只是公共的 Agent 存在跨 NUMA 访问的可能:例如 metrics 上报 agent,日志 agent 等。 * 跨 NUMA 访问内存:100-200 ns
同交换机 * 同交换机可以考虑使用 RDMA 一类的技术来通讯,最大限度地利用带宽 * 如果确定是同交换机,则网络中的流量协调变得简单,不用担心交换机本身过载的情况 1000--5000 ns
同机房 * 绝大多数的微服务架构处于这一层,但是完全可以使用 RDMA 或者 XDP/DPDK 等能够使用高带宽网络的技术。 * 高带宽通讯要注意交换机层面过载的可能 5000--30000 ns,跨交换机访问
同城 * 超出同机房的范畴时,有必要根据网络的特点来决定是否要开启压缩 1 km 单程 ≈ 5 µs 10 km 来回 ≈ 100 µs 50 km 来回 ≈ 500 µs
同省 * 例如云游戏等业务,受限于光速,只能在同省的级别运营云游戏业务。否则延迟无法满足流畅游戏的体验。 200--300 公里, 2--4 ms
同大洲 * 网络的质量、延迟等难以把控,使用 TCP 是稳妥的方式,且需要根据实际情况调整 TCP 协议栈参数来平衡延迟与吞吐量。 亚洲东 → 亚洲西(专线), ~9000 KM, 80 -- 100 ms
跨大洲 同上 亚洲 ↔ 美国东海岸(如纽约), 10000KM 以上, 200--250 ms
卫星通讯 同上 GEO(地球同步轨道), 轨道高度 ~35,786 km, ~500--600 ms

如同 DeepSeek 开源的 3FS 文件系统,就是利用了 RDMA 等高带宽低延迟的技术,在特定的 IDC 环境里,来为大模型训练加速。随着业界对于更大量数据更低延迟更低计算成本的需求的增加,微服务架构这样基于 IP 网络来组织计算的方式已经越来越无法满足需求了。我之前的一篇文章《反微服务架构》也提供了很多思路,以应对微服务架构的不足。

从云服务厂商的角度出发,微服务架构是非常好的可以在商业上成功的技术标准 ------ 这是一种很容易让用户掏更多钱的技术。

回想微软当年力推的 Web Services,所有通讯的数据都要被又庞大又慢的 XML 包装起来。理念看起来很漂亮,结果是用的人越多,比尔盖茨越有钱。

因此,未来的计算框架,需要做出这样一些特性:

  • 注册-寻址-负载均衡-容灾调度的基础组件需要强化,不仅仅只是为微服务提供地址的注册和查询,还需要上报服务的延迟层级属性。

    • 一个服务如果想访问另一个服务,它可以知道:哪些下游服务在同 NUMA,哪些在同机房......等等。
  • 一个服务访问另一个服务,其优先级是通过延迟级别的逐层下降的,以便最大化地利用局部性原理来提升性能。

    • 先访问同 NUMA 的服务,如果没有或者繁忙,再试着访问同服务器的服务,然后是同交换机上的服务......以此类推。
  • 为了支持分级通讯,通讯手段可以概要地分为三类:

    • 进程间通讯:依靠共享内存队列来实现,不加锁,不使用系统调用,不拷贝数据。
      • 主要用于同NUMA 和同服务器的情况
    • RDMA 通讯:基于支持 RDMA 的网卡和交换机,可以不需要消耗 CPU 来实现高带宽低延迟的通讯。
      • 主要用于同交换机和同IDC 的情况
    • TCP/UDP 通讯:与传统的微服务通讯相同,但是在新的硬件水平上,可以拓展一些新技术:
      • 使用 XDP 、DPDK 等技术来做高带宽低延迟的技术
      • 使用用户态的协议栈来代替内核协议栈,在大量数据的情况下节约 CPU
      • 使用 QUIC / UDT / KCP 等基于 UDP 通讯的技术,一方面可以通过 0RTT 等特性降低延迟,另一方面可以通过冗余发包来代替重传。

总结起来,未来的服务器端计算框架,需要通过延迟分级来进一步提升服务器的计算能力。

  • 名字服务等基础设施,需要支持延迟分级这一属性的上报
  • 彻底计算框架抽象通讯模块,主要使用 共享内存队列 / RDMA / IP网络 等实现就近访问

如同rust中的生命期管理一样的编译器优化技术

观点:编译器技术的提升,通过变量的生命周期的跟踪,通过牺牲编译时间,换取运行时减少 GC 的效果。

未来,各种语言的编译器都可以抄袭 rust 的变量生命周期管理功能。这种技术可以理解为"编译期的GC":

复制代码
每编译完一行代码,就运行一个扫描程序:
所有使用的变量,到这一行为止,哪些再也不需要使用了?
如果不需要使用了,编译器为这个变量生成一段在堆内释放资源的代码。

这样的工作无疑会大大增加编译时间,但是原理上其实并不难。

下面通过一个例子来说明,编译器的声明周期管理如何提升程序性能:

go 复制代码
// 假设业务中有一个特殊的 hash 函数,把两个值组合成特定格式,然后计算这个格式对应的 CRC64 值
func hash(a int, b int) int64{
  str := fmt.Sprintf("a:%d/b:%d", a, b)
  return Crc64(str)
}

上面的例子中,变量 str 分配到堆上。当函数结束后, str 需要等待 gc 来收集。

如果 golang 编译器做了 rust 一样的变量生命周期的跟踪,可以发现 str 的生命周期结束于运行函数 Crc64 之后。

那么,编译器完全可以在代码生成时,插入一段从堆中删除对象的代码:

go 复制代码
func hash(a int, b int) (out int64){
  str := fmt.Sprintf("a:%d/b:%d", a, b)
  out = Crc64(str)
  // runtime.delete_from_heap(str)
  return
}

由此,GC 扫描时就可以少扫描一个对象。所有类似这样微小的优化积累起来,整体的性能一定会有提升。

以 golang 为例,修改编译器并非易事,但其实已经有很多团队在做了:

  • 我总结的文章里提到了很多编译器优化的案例:

  • TinyGo (https://tinygo.org/) 这个项目让人眼前一亮:可以把go代码编译为在嵌入式设备上运行的二进制,或者生成更加精简和高效的X64可执行程序。本质上来说,它是沿用了go的语法,使用了自己的标准库和自己的编译器来达到目的。在一些特殊的场景,可以考虑引入这样的编译器来提升性能。(需要小心的是标准库的支持有限,且go的一般经验可能在这个编译器下不适用。)

  • 这个 Uber 开源的go编译器:https://github.com/uber-research/go . 通过硬件计数器来提升 pprof 的精度。通过深入研究编译器的细节,做特定场景做出比官方更优的编译器是完全可能的。

  • 字节跳动也做了编译器优化的工作:

    • 这篇公开的文章里做了介绍:《Go 生态下的字节跳动大规模微服务性能优化实践

    • 编译器 Beast mode

      Go 编译器虽然编译速度很快,但是并没有选择生成性能最高的代码,因此字节跳动基础架构语言团队研发了一个额外的编译模式,即编译器 Beast mode。正如隐身战斗机会有个额外的 Beast mode 用于火力压制,编译器 Beast mode 拥有更多的优化手段,执行效率更高。我们选择在开发阶段使用标准编译模式,提高开发效率;发布到线上时使用 Beast mode 编译生成性能更高的二进制。

AI加持的数据结构

有时我在思考程序的细微之处的优化:

  • 一个 hash 表的桶应该多长,才能在大多数情况下避免因为数据超过容量而发生 rehash ?
  • 分配的数组应该多长,才能在大多数情况下避免扩容数组?

有时候阅读大神的代码,总看到很多令人惊叹的 magic number. 高手在代码中设定的某些常量,就比如我们喜闻乐见的 hash 表的桶数量/数组的初始化长度这类微小的问题,高手们丰富的经验和长期的实践,总是让某些局部的代码"恰到好处"的好,初级工程师难以窥探这些奥妙。

最经典的 magic number 的故事:《魔法数字开根号的传说

约翰·卡马克("第一人称射击游戏之父")(John Carmack的全名是John D. Carmack II) ,他生于1970年8月20日),是享誉世界的著名程序员,在电视游戏领域被尊为偶像。Carmack是id Software的创始人之一,id是一家专门开发电子游戏、电视游戏的公司,成立于1991年。

卡马克真正牛B的地方是他选择了一个神秘的常数0x5f3759df 来计算将一个数开平方并取倒那个猜测值, 那一行算出的值非常接近1/sqrt(n), 这样我们只需要2次牛顿迭代就可以达到我们所需要的精度.

AI 能根据多个变量自动探索解空间,从而找到最优或近似最优的解决方案。

于是,我们可以利用 AI 来帮我们找到编程中的最优 const 值,从而让普通工程师也能轻易做到经验丰富的老工程师才能做到的事情。

最终......嗯,加速了让 AI 淘汰整个行业的进程 ------ 不只是卷架构,数据结构这种细微的地方也要卷起来。

ClickHouse 凭借其独特的列式存储架构,加上对 SIMD 和 JIT 技术的深度融合,成为分析数据库领域的一匹黑马。它不仅在社区和企业中迅速崛起,在性能上也实现了质的飞跃------通过 SIMD 提升数据并行处理效率,通过 JIT 编译技术动态优化表达式执行路径,使得数据分析查询速度实现数量级的跃升。

就如同深入使用 SIMD 和 JIT 一样,部分看起来已经没什么新花样的技术,在新的技术组合下实现了数量级的提升。

或许,下一个通过数量级的性能提升而让传统产品带来新生的理由,就是因为深入地在数据结构中融入了 AI 的能力。

附录:我所理解的"海量服务之道"

以下内容来自互联网上已经公开的资料