在性能优化时,如何避免盲人摸象

简介: 盲人摸象最早出自于《大般涅槃经》,讲述一群盲人触摸大象的不同部位,由于每人触及部位不同,却各自认为自己摸到的才是大象的全部,并为此争吵。比喻对事物了解不全面,以偏概全。

盲人摸象最早出自于《大般涅槃经》,讲述一群盲人触摸大象的不同部位,由于每人触及部位不同,却各自认为自己摸到的才是大象的全部,并为此争吵。比喻对事物了解不全面,以偏概全。


过去一年,一直在想一个问题,平时所做的那些性能优化是否真有必要?


有没有可能是系统本身可观测性不够,这些只是优化了可观测部分指标,比如某一段的延迟或者吞吐,但并没有真正改善用户实际体验,甚至为此,还引入新的复杂度,使得系统不断熵增。


Fred Brooks 在“没有银弹”文中指出,软件工程的本质是复杂性的管理。复杂性无法被消灭,只能转移或隐藏,而抽象是控制复杂性的主要手段之一。技术发展也是如此,框架和工具的抽象程度越来越高,出现大量成熟的开发框架,甚至业务框架,开发借助 Quick Start 工具可以快速搭建站点,甚至不熟悉 TCP/IP,也能借助 Netty 完成高并发服务上线,开发成本似乎在变得越来越简单,却往往把复杂性转移至系统运行期。若能够深入一线去排查问题,很多时候,你会发现排查问题的时间,以及最终问题的解决的成本都在变高。


All non-trivial abstractions, to some degree, are leaky.-- in 2002 by Joel Spolsky 译:所有复杂的抽象或多或少都存在泄露。


日常经常收到业务上报的工单,大多是实在定位不到原因,只能怀疑是基础网络问题。一通排查下来,真正是网络原因导致的不足三分之一,多数是业务本身问题,当然也还有少部分是因为安全限制,本文着重探讨前两者。有时问题解决后,会发现用户侧指标并没有很明显变好,这种情况就是问题没找对地方。阿姆达尔定律指出,性能优化要找出全链路中真正有性能瓶颈的地方,找出哪些无法并行,或者最耗时的串行部分。


若将简单将系统分为三层,自下而上分别是资源层、中间件层和业务逻辑层,越往下资源越共享,隔离程度越低,相互影响机会越大。这里有个明显的设计“鸿沟”,从资源层过渡到中间件和业务逻辑层时,操作系统颁发资源(比如内存、CPU 和网络连接等)的对象是进程,而中间件和业务逻辑本身往往运行在同一进程内(比如 JVM),且容器往往也只运行一个应用,这使得很多看似有效的隔离措施,实际上并不起作用。


这也是为什么性能问题要引起重视的主要原因之一,性能问题若不能尽早被识别和解决,后续碰上预期外其他问题,就会触发故障。以慢 SQL 为例,数据库连接池默认为 10,如果 SQL 平均耗时 11 秒,QPS 哪怕仅是 1,这个应用所有数据库请求就开始排队,甚至还会一层层传导至上游系统。


事后看,很多故障产生的根因,往往并不是由变更内容本身引起,而是经过层层传导,影响被逐级放大,最终失控。性能问题有时不易被引起重视,和故障定责也有一定关系。现行定责过于强调变更方责任,而忽略提供方系统鲁棒性方面的要求。


1. 业务活动,未通知或者业务流量评估不足(误差 20%),导致现有系统能力不能承受,业务评估方负责;

2. 服务方或引用方存在已经评估出来的隐患,未告知或告知对方的业务影响不明确或内容错误,则方案评估方负责;

3. 服务方或引用方存在已经评估出来的隐患,如双方已知风险,没有在约定时间内弥补,谁触发谁负责;-- 某故障定责说明


这篇文章把近一年碰到的印象较深的问题排查笔记整理后放至一起,并额外加了一些关联知识点,但因涉及领域实在太广,以及个人能力、时间等原因,未能全部一一校对,此外考虑数据敏感性,还删除了一些内容,所以部分地方逻辑会有些跳跃。一些排查时的日志、截图未能及时保存,有些是写文章时补的,因此会出现一些对不上情况。若有冲突,均以文字描述为准。


一、对于延时的直观感受

性能优化前提是能够先识别出问题,而识别最有效率的方式,是先建立对不同场景延迟敏感度。对于资深一些的程序员来说,甚至可以在发布前,就能够算出不同地区、网络环境以及终端类型下,用户请求页面耗时的大致区间。

  • 纳秒级 (ns) - 处理器与内存的世界
  • 1ns
  • CPU 时钟周期,对于 3 - 4GHz CPU(Apple M2 Pro 3.49GHz),一个时钟周期约为 0.25 - 0.33ns,一条加法指令 1-2 个周期。
  • 1 - 10ns
  • CPU 高速缓存,L1 ~ L2,几乎瞬间完成,除非涉及硬件相关开发,日常一般接触不到。
  • 10 - 100ns
  • L3 缓存访问延迟
  • DDR4/DDR5 访问延迟
  • 100 - 1000ns
  • 机架内一台服务器到另一台服务器的单向网络通信,对应 RTT 1μs 左右。
  • 少量数据(<1KB)序列化/反序列化耗时。
  • 微秒级 (µs) - SSD 与数据中心网络的世界
  • 1 - 10μs
  • 在数据中心内部(跨机架,同可用区)典型 RTT,这是微服务之间调用的理想延迟。
  • 从 SSD 中随机读取 4KB 数据块。
  • 操作系统上下文切换开销。
  • 10 - 100μs
  • 典型的磁盘数据库(如 RocksDB)从 SSD 读取时间。
  • 连续读 1MB 内存仅需 ~100μs(DDR4 带宽 50GB/s → 1MB ≈ 20μs,实际受内存控制器延迟影响)。
  • 通过本地网络回环 localhost 的 Redis GET 请求耗时,如果留意过生产环境读耗时,会发现在 0.7ms 左右,考虑网络传输耗时,这已经接近性能极限。
  • 100 - 1000μs
  • 从内存中连续读取 1MB 数据。
  • 毫秒级 (ms) - 机械硬盘与广域网的世界。
  • 1 - 10ms
  • HDD(机械硬盘)寻道时间,物理机械寻道是其性能差的根本原因。
  • 在同一个城市或一个区域内部(比如华东,杭州-上海)数据中心之间 RTT。
  • 10 - 100ms
  • 国内跨区域请求延时,比如杭州至张北在 30ms 左右,此外杭州至香港、新加坡、首尔以及东京的延迟在 80ms 左右。在海外不能以物理距离线性折算耗时,要充分考虑当地网络状况以及海底光缆位置。
  • CDN 边缘接入延迟通常期望小于 30ms,比如 g.alicdn.com 在杭州区域小于 10ms。
  • 正常情况,用户首次与页面交互(点击/输入)到浏览器实际响应的延迟(FID,First Input Delay)应该小于 100ms。
  • 100 - 1000ms
  • 跨国请求延迟,比如从杭州至美西、伦敦 RTT 在 250ms 左右。如果留意过海外网络质量,就会发现我国基建尤其沿海非常牛。
  • 典型 4C8G 容器(JVM 4G) CMS 或 G1 FGC 耗时。
  • 秒级 (s) 及以上 - 人类交互与批处理的世界
  • 1 - 10s
  • 数据库异地多单元同步延迟。
  • 页面中最大内容元素(如图片、视频、块级文本)渲染完成时间(LCP ,Largest Contentful Paint)应该低于 2.5s。


二、从一个堆外 OOM 谈起

某天群里报了一个 FGC 报警,一看有点震惊,该机器每分钟 FGC 耗时都在 2s 左右(这里口径是 SWT 总耗时,一次 CMS FGC 有 2 次 STW 阶段),且从趋势图上看,该问题至少持续了一周以上,但是一直未产生上层业务报警,业务接口 RT 应该也有些反应才对。一脸懵逼,但考虑影响,直接重启应用,问题消失。

通过对 gc.log 中 5次 CMS 的 Pause Initial Mark 和 Pause Remark 两阶段耗时相加得到 1845.634ms(即监控图表中 1.84s,计算方式像是向下取整),其中一次完整 GC 日志如下,不难发现这里面有几行透着诡异,这显然不是一个典型垃圾回收过程。

  • [2025-06-08T10:04:13.982+0800] GC(169660) Pause Initial Mark 665M->665M(3925M) 55.301ms #STW 时间过长。
  • [2025-06-08T10:04:16.017+0800] GC(169661) Pause Young (System.gc()) 783M->235M(3925M) 12.427ms #显式调用 GC 干预 JVM 回收时机。
  • [2025-06-08T10:04:19.273+0800] GC(169660) Concurrent Abortable Preclean 5014.646ms #耗时极其长
  • [2025-06-08T10:04:19.559+0800] GC(169660) Pause Remark 461M->461M(3925M) 283.823ms #STW 时间过长。
  • [2025-06-08T10:04:19.680+0800] GC(169660) Old: 234937K->234932K(2097152K) #老年代回收无效果
[2025-06-08T10:04:13.927+0800] GC(169660) Pause Initial Mark
[2025-06-08T10:04:13.982+0800] GC(169660) Pause Initial Mark 665M->665M(3925M) 55.301ms
[2025-06-08T10:04:13.983+0800] GC(169660) User=0.22s Sys=0.00s Real=0.05s
[2025-06-08T10:04:13.983+0800] GC(169660) Concurrent Mark
[2025-06-08T10:04:14.245+0800] GC(169660) Concurrent Mark 261.650ms
[2025-06-08T10:04:14.245+0800] GC(169660) User=0.33s Sys=0.03s Real=0.26s
[2025-06-08T10:04:14.245+0800] GC(169660) Concurrent Preclean
[2025-06-08T10:04:14.258+0800] GC(169660) Concurrent Preclean 13.275ms
[2025-06-08T10:04:14.258+0800] GC(169660) User=0.02s Sys=0.00s Real=0.02s
[2025-06-08T10:04:14.258+0800] GC(169660) Concurrent Abortable Preclean
[2025-06-08T10:04:16.004+0800] GC(169661) Pause Young (System.gc())
[2025-06-08T10:04:16.005+0800] GC(169661) Using 4 workers of 4for evacuation
[2025-06-08T10:04:16.017+0800] GC(169661) ParNew: 566961K->6390K(1922432K)
[2025-06-08T10:04:16.017+0800] GC(169661) CMS: 234937K->234938K(2097152K)
[2025-06-08T10:04:16.017+0800] GC(169661) Metaspace: 313898K(324184K)->313898K(324184K) NonClass: 276843K(283772K)->276843K(283772K) Class: 37055K(40412K)->37055K(40412K)
[2025-06-08T10:04:16.017+0800] GC(169661) Pause Young (System.gc()) 783M->235M(3925M) 12.427ms
[2025-06-08T10:04:16.017+0800] GC(169661) User=0.04s Sys=0.00s Real=0.02s
[2025-06-08T10:04:19.273+0800] GC(169660) Concurrent Abortable Preclean 5014.646ms
[2025-06-08T10:04:19.273+0800] GC(169660) User=6.00s Sys=0.30s Real=5.01s
[2025-06-08T10:04:19.275+0800] GC(169660) Pause Remark
[2025-06-08T10:04:19.559+0800] GC(169660) Pause Remark 461M->461M(3925M) 283.823ms
[2025-06-08T10:04:19.559+0800] GC(169660) User=0.37s Sys=0.00s Real=0.29s
[2025-06-08T10:04:19.560+0800] GC(169660) Concurrent Sweep
[2025-06-08T10:04:19.675+0800] GC(169660) Concurrent Sweep 115.559ms
[2025-06-08T10:04:19.675+0800] GC(169660) User=0.18s Sys=0.02s Real=0.11s
[2025-06-08T10:04:19.675+0800] GC(169660) Concurrent Reset
[2025-06-08T10:04:19.679+0800] GC(169660) Concurrent Reset 3.997ms
[2025-06-08T10:04:19.680+0800] GC(169660) User=0.01s Sys=0.00s Real=0.01s
[2025-06-08T10:04:19.680+0800] GC(169660) Old: 234937K->234932K(2097152K)

如果 STW 时间这么长,按理接口 RT 折线应该不断有毛刺才对,但为什么 Sunfire 上没有体现呢?比如以 gc.log 中 2025-06-08T10:04:19.559+0800 时间点为例,10:04:19 秒 RT 应该有个明显飙升。


后来想了想,正常监控是分钟级平均数,这种几百毫秒毛刺被分母均摊后“消失”。这也是日常经常容易被忽略的点,监控不仅要关注均值,也要留意尾部情况,比如 P95 甚至 P99,但目前监控平台好像并不支持。


直接上机器基于 API 日志统计进行耗时,04 分 RT 变化情况画成折线图如下,确实有一个突峰,类似现象在这台机器上,在过去一周一直有,要不是这个 GC 报警被关注到,应该都不会被注意到。这是一个非常直观的例子,监控指标都正常,但偶尔就有那么几个用户页面刷新会慢一些。因为无法复现,很多时候会归为网络抖动而被忽略。

重启应用后,Direct 内存占用下恢复正常。拉长统计周期后,发现内存一直在缓慢泄漏。看到这里,问题已经明确,就是堆外内存泄露。

重启后 Direct 内存占用快速下降

内存缓慢泄露

其实 Sunfire 已经明确标识是 Direct 内存泄露,当然还可以通过设置内存检测基线 jcmd 4677 VM.native_memory baseline,隔一段时间后关注变化部分 jcmd 168239 VM.native_memory summary.diff scale=MB 也可以找出泄露点,甚至还可以持续监听内存使用情况 watch -n 1 "jcmd 4677 VM.native_memory | grep -E 'Other'"。

Every 1.0s: jcmd 4677 VM.native_memory | grep -E 'Other'                                                                                                              Mon Jun 2322:46:412025

-                     Other (reserved=1022644KB, committed=1022644KB)

排查堆外内存泄露有两个思路:一是直接找出怀疑内存,看里面到底是什么,另外一个是找出频繁操作内存的线程。但更多时候,需要同时使用这两种方法进行交叉验证,比如本例。

  • 找疑似内存
  • 按 RSS 占用降序查找 pmap -x 4677 | sort -k3 -n -r,并记下映射内存起始地址值。
  • 大容量 [anon] 匿名内存段,权限为 rw。
  • 持续增长的 RSS,在不同时间点多次运行 pmap,可通过 diff 命令观察持续增长的内存块。

  • 找疑似线程
  • 通过 strace 跟踪内存操作,尤其关注仅 mmap 而没有 munmap 操作的线程 nid,然后再通过 jstack 关联 Java 线程信息。
  • 开启 NMT,通过 jcmd 4677 VM.native_memory detail | grep -C 20 "Other" 查看内存分配记录。不过这里并没有符号链接,即未显示堆栈,需要通过 gdb 或者。

详细排查过程:

1. 启用 NMT 功能(生产环境慎用,有性能损耗),在 setenv.sh 中增加


-XX:NativeMemoryTracking=detail

2. 查找 Java 进程 ID,通过 jps 或者 ps -ef | grep java (这里是 nid,即操作系统线程 ID,而 JVM 内部维护是 tid,后续使用 jstack 时,注意两者差别。JVM 在内部维护 tid -> nid 1:1 映射关系,即常说的 Java 线程会映射到操作系统线程,前者涉及命令是 jstack,而后者是 pert、gdb 以及 strace)。

3. 找疑似内存,多次采样 pmap -x 4677 | sort -k3 -n -r > pmap_$(date +"%Y%m%d_%H:%M:%S").log 内存映射信息,找出疑似泄露区块并查看其内容。

pmap 各类含义:

  • Address: 起始虚拟地址(十六进制),0000000700000000 是 Java 堆。
  • Kbytes: 占用虚拟内存大小(单位 KB)。
  • RSS: Resident Set Siz,实际驻留在物理内存中的大小(单位 KB)。
  • Dirty: 内存中已被修改但尚未写回磁盘的脏页大小(单位 KB),匿名映射(如堆、栈)的 Dirty 值通常等于 RSS,因为无磁盘文件可同步。
  • Mode: 内存区域的访问权限,rwxsp,rwx 读写执行。
  • s 表示可共享映射,最大程度复用,比如一些代码和只读数据。
  • p 表示私有映射,COW(Copy-on-Write)内核为修改者创建该内存页的独立副本,后续修改只会影响该副本,从而实现进程间隔离,anoe 映射是 p,不过上述示例中未表示。
  • Mapping:映射类型
  • anoe:匿名映射,如堆、堆、栈、malloc 分配的内存。
  • stack:进程栈
  • 文件名:如上述 libnio.so 内存映射的动态库,java 主程序二进制文件。

diff 文件,找到怀疑内存块(可用不同时间文件对比,发现该内存一直被占用且不断增大)。

00007f0b76c00000 2048 1868 1868 rw--- [ anon ]

查看地址范围 grep "7f0b76c00000" -b20 /proc/4677/smaps,也可以直接加,7f0b76c00000 + 2048 * 1024 = 7f0b76e00000),如图中 7f0b76c00000-7f0b76e00000。

然后通过 gdb dump,具体命令 gdb -p 4677 --batch -ex "dump mem.bin 0x7f0b76c00000 0x7f0b76e00000"

最后通过 strings mem.bin > mem.txt 将其中可显字符输出,然后可以分析该问题。如果稍观察仔细一些,会发现转换后的 txt 文件,会比转化前的 bin 文件小很多,是因为 strings 默认只提取连续 4 个以上可显(32-126) ASCII 字符。这种情况下,会发现里面内容不连续,尤其是一些涉及二进制流(比如本例中的 Netty)、文件和图片等场景的业务。

如果是熟悉代码,大都情况下,也会获得一些有效信息。不过因为当时并了解这块业务逻辑,只看到有些零碎的重复 SQL 执行痕迹,未进一步关联代码排查,总想着找一个能一眼看的有效信息。现在回过头来看,当时若去多看几个泄露内存块,以及梳理代码分支,一一排查,也能找到本例中的 ByteBuf 泄露问题。


4. 找疑似线程,追踪内存操作,找出频繁申请内存的线程并查看栈信息。


很多时候,直接从 stack 上并总不能一眼看出疑似调用,因为分配内存操作耗时极短(小于 1ms),并不总能采到调用,但若是排查 CPU 相关问题比如利用率高就会好很多。

最后根据堆栈找到 decode 代码里面 in.readBytes(length) 这行调用,这里未意识 readBytes 方法复制了当前流,且 Netty 并不会主动关闭这条流。Fix 方式也很简单,要么在 read 后,增加 finally 块并主动调用 release 方法,要么避免创建临时 ByteBuf,而是直接调用 in.toString(in.readerIndex(), length, StandardCharsets.UTF_8),让 Netty 统一管理。


in.readBytes(length) Transfers this buffer's data to a newly created buffer starting at the current readerIndex and increases the readerIndex by the number of the transferred bytes (= length).

@Component
publicclassXxxDecoderextendsByteToMessageDecoder {

    @Override
    protectedvoiddecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out){
        ...
      
        // sleep 3s,可以采样到该方法调用
        // try {
        //     Thread.sleep(3000);
        // } catch (InterruptedException ignored) {}
      
        // 泄露点 in.readBytes(length) Transfers this buffer's data to a newly created buffer starting at the current readerIndex and increases the readerIndex by the number of the transferred bytes (= length).
        String message = in.readBytes(length).toString(StandardCharsets.UTF_8);
        out.add(message);
  }
}

5. 后来查看一些关于 Netty 相关文章,发现还有一种更为直接的排查方法,直接启用 Netty 自带的内存泄露检测模式 -Dio.netty.leakDetectionLevel=PARANOID,等于直接在 Netty 内部进行采样。


这是一个因 ByteBuf 使用不当,而导致的内存泄露问题。问题本身并不复杂,但是折射出几个值得思考的点:


1. 内存缓慢泄露,且趋势非常确定,但为什么要到泄露那一刻才被发现。


2. 少量用户接口 RT 有几百毫秒波动,但现有监控手段确无法检出。本质是现行监控更多是平均值,而没有提供一些 P95 或者 P99 监控能力。


3. ByteBuf 日常不常见,CR 未识别可以理解,但是代码扫描没能发现其实有点奇怪的。


想想 AI,再想想基础设施团队既有业务代码,又有各类观测性指标、业务数据,甚至还有中间件以及网络层面的 Trace 日志,视角非常之全,近乎于可以从各个维度洞悉全链路,最关键的是这些数据本身质量还很高。也许再过些日子,各种好用的工具会陆续出现。


三、一些基础知识


为什么基础很重要

第一次真正意识到基础很重要,是有一次在封装 HTTP Client 时,那会非常迷恋代码精简。通过对比不同封装方法,最终选择 Retrofit。Retrofit(底层依赖 OkHttp)完全屏蔽 Bean 定义、HTTP 连接池以及 JSON 反序列化等所有细节,直接注入就可使用。在某种意义上,这种封装方式,实现了类似 HSF @HsfConsumer 效果。


但是代码上线后,发现接口 RT 时有波动,而同期被依赖的服务 RT 表现非常稳定。因为几乎没有定制代码,所以一时竟不知道如何查起。


以请求 Github Ropo API 为例,依赖地方直接加载该 Bean 即可使用。

@RetrofitClient(baseUrl = "https://api.github.com")
public interface GitHubService {

    @GET("/users/{user}/repos")
    Call<List<Repo>> listRepos(@Path("user") String user);

}

后来横向观察了其他应用封装 HTTP Client 例子,比如下面这两种基于 OkHttp 写法也不同,但至少能够感觉到 HTTP 协议本身,并不像 Retrofit 那种封装方式表现出的透明。

publicclassHttpClient {

    privatestaticvolatile OkHttpClient client;

    privateHttpClient(){}

    publicstatic OkHttpClient getClient(){
        if (client == null) {
            synchronized (HttpClient.class) {
                if (client == null) {
                    client = new OkHttpClient();
                }
            }
        }
        return client;
    }

    publicstatic String post(String url, String json) throws IOException {
        RequestBody body = RequestBody.create(json, MediaType.get("application/json"));
        Request request = new Request.Builder()
                .url(url)
                .post(body)
                .build();
        try (Response response = getClient().newCall(request).execute()) {
            return response.body().string();
        }
    }
}

相对于上面的封装方式,下面这种未完全使用默认配置,而是定制了部分参数。这些配置仅在特定场景合适,不能直接复制/粘贴。

  • ThreadPoolExecutor
  • 如果应用中既有后台类(RT 较长)请求场景,也有用户 UI 请求(要求及时返回)场景,利用 CallerRunsPolicy 这种背压机制,一旦后台类请求在某个时间段飙涨,也会使得 UI 请求因为背压,直接传导至调用线程,然后进一步扩散至上游,即便容器本身 CPU 或者内存等使用率并不高。
  • Dispatcher
  • 同样如果调用对象是网关,MaxRequestsPerHost 设置为 40 就不合适,其域名甚至只有 1。
@Bean
public OkHttpClient gatewayClient(){
    // 这里也有坑,线上默认是 CPU Share,正常启动时是满核,运行一点时间后会被缩核。不过影响相对有限
    int cores = Runtime.getRuntime().availableProcessors();
  
    ThreadPoolExecutor executor = new ThreadPoolExecutor(
        cores * 2, 
        cores * 16,
        30, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(cores * 80),
        new NamedThreadFactory("gw-dispatcher"),
        new ThreadPoolExecutor.CallerRunsPolicy() // 背压
    );
    
    Dispatcher dispatcher = new Dispatcher(executor);
    dispatcher.setMaxRequests(cores * 200);       // 全局并发
    dispatcher.setMaxRequestsPerHost(cores * 40); // 单主机并发
    
    returnnew OkHttpClient.Builder()
        .dispatcher(dispatcher)
        .connectionPool(new ConnectionPool(cores * 50, 5, TimeUnit.MINUTES))
        .addInterceptor(new CircuitBreakerInterceptor()) // 熔断
        .eventListener(new ResourceMonitor())            // 资源监控
        .build();
}

上述情况后来排查是因为 Keepalive 未完全生效,会定期重建连接,且由于 Server 和 Client 之间跨 Region,所以建连开销会明显作用在接口 RT 上。这种情况下,显现增加一行 HTTP Keepalive 配置即可。但也不总是有效,HTTP 层 Keepalive 是建立在 TCP 连接之上,若 TCP 无法保持连接,HTTP Keepalive 也就无从谈起。比如:


1. 若在网络设备上运行上述代码,会发现 2min 后还是会重建。


2. 若在一些部署 TCP 状态检测防火墙内执行,有时会发现 2min 不到,连接就会重建,策略更加谨慎。也有时候,中间的一些 NAT 设备也会干预 TCP 连接时间。再仔细想想,Ping、TLS 以及 TTFB(Time To First Byte)这三者通信对象都不一样,而 HTTPS 耗时是建立在这三者之上,这部分网络质量拨测中会详细说明。


3. 若再深入一层,翻看集团镜像,会发现默认是 HTTP/1.1。这意味着即便在机房内部通信,也依然存在 2 层队头阻塞(HTTP 应用层和 TLS 层)问题,请求会进入排队。如果不想排队,选择调高线程数,会导致线程切换成本变高。


4. 若再视角跳出服务侧,放至用户终端,会发现有时用户移动时,页面刷新也会不稳定,究其原因也有可能会基于 HTTP/1.1 或者 HTTP/2 版本,若换成 HTTP/3 会发现好很多。

sysctl -a | grep tcp_keepalive

# 集团生产环境容器
net.ipv4.tcp_keepalive_intvl = 75  # 每75秒一次探测
net.ipv4.tcp_keepalive_probes = 9  # 最多9次探测
net.ipv4.tcp_keepalive_time = 7200 # 最多保持2小时

# 网络设备
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_time = 120  # 明显小于容器中设值sysctl -a | grep tcp_keepalive# 集团生产环境容器net.ipv4.tcp_keepalive_intvl = 75  # 每75秒一次探测net.ipv4.tcp_keepalive_probes = 9  # 最多9次探测net.ipv4.tcp_keepalive_time = 7200 # 最多保持2小时# 网络设备net.ipv4.tcp_keepalive_intvl = 75net.ipv4.tcp_keepalive_probes = 9net.ipv4.tcp_keepalive_time = 120  # 明显小于容器中设值

看似简单的一行 POST 请求,若将问题域展开,会发现非常复杂。把视角跳出相对确定性强的服务侧,各种奇怪的问题就会接踵而出。如果对底层原理理解不够,排查起来会毫无头绪,有时连问题都描述不清楚。关于这部分,后面还会进一步展开。

原图来自:https://pulse.internetsociety.org/blog/why-http-3-is-eating-the-world

HTTP 协议演进就是一个性能不断优化的过程:

  • HTTP/1.1: 针对纯文本且串行请求/响应模式的 HOLB(Head-of-Line Blocking,队头阻塞),并开始默认启用 Keepalive;
  • HTTP/2:引入二进制分帧以及 Stream 这种应用层逻辑上的多路复用、HPACK Header 压缩等看似微小改变,但效果突出;
  • HTTP/3:理解 TCP Sequence 有序性,就不难发现 HTTP/2 并没有解决传输层的 HOLB,如果放弃 TCP 而改用 UDP 呢?这就是大名鼎鼎的 QUIC 协议,加上和 TLS 1.3 高度集成,最终实现将控制权完全交由应用层,相比于 HTTP/1 和 HTTP/2 的 1 ~ 3 RTT(1 TCP RTT + 0 ~ 2 TLS RTT,TLS 不同版本),变为 0 ~ 1 RTT(UDP 无需握手)。简单理解 HTTP/3 ≈ HTTP/2 + QUIC;


Pooling(池化)

池化的本质是对资源的抽象、复用以及规模化,是通过预先创建并维护一个可复用资源集合,将资源的“创建/销毁”与“使用”解耦,将其使用和生命周期解耦,以空间换时间,从而达成降低延迟、提升吞吐、平摊开销的核心目标。这里的“资源”是一个广义的概念,比如:

  • 计算资源:比如线程池,避免频繁创建和销毁线程的开销,涉及内存分配、系统调用和上下文切换等。
  • 连接资源:比如数据库连接池,避免频繁建立和断开连接的开销,涉及 TCP 三次握手、四次挥手、TLS 握手、认证等。
  • 内存资源:比如 Netty PooledByteBufAllocator 每次向操作系统申请 16MB 空间,且利用完并不会及时归,以避免频繁向操作系统申请和释放内存(系统调用、内存碎片),而由 Netty 本身统一管理。


如果将视角进一步延展,本质是对资源的“独占”到“共享”的范式转变,云计算本身也是一种池化实现,以及正在风头上的 CXL 等融合协议 ,也是一种对 GPU 和 CPU 等的更高效率的池化实现。理解这些,再回头看日常代码中 ExecutorService 以及 Netty ServerBootstrap 会有别样理解。

  • IaaS 层是对计算、存储、网络这些物理硬件资源的巨大池化。
  • PaaS 层是对运行时环境、中间件、数据库服务的池化。


说到这里,其实有个有趣的现象。都说关系型数据库是个复杂的领域,但是你如果留意应用侧数据库相关配置,就会发现极其简单,短短几行代码,以及日常如果数据库出现问题,正常业务研发能应急做的事情非常少(想了下,似乎只有 SQL 限流)。


CPU 利用率和 Load

CPU 利用率衡量的是 CPU 忙于执行非空闲任务的时间百分比,表示 CPU 繁忙程度。CPU Load 指系统中处于可运行状态和不可中断状态的平均进程数,表示有多少任务在排队等待 CPU。Load 统计范围相对于利用率,多了就绪和不可中断睡眠两类,后者则是典型的 I/O 密集场景标识。

  • CPU 利用率低,但 Load 很高 -> 排查各类 I/O;
  • 大量进程没有在计算,而是排队等待 CPU,是典型的 I/O 密集型场景;
  • Load 低,但 CPU 很高 -> 排查代码,尤其 for 循环或者算法相关;
  • CPU 繁忙,是典型的计算密集型;

进程状态

  • 运行(R,Running),正在 CPU 上执行指令;
  • 直接影响 CPU 利用率,是计算密集型的体现;
  • 计入 Load 统计结果 + 1;
  • 就绪(Ready),具备除 CPU 之外的所有运行所需资源(如内存、数据),正在等待调度器为分配 CPU 时间片;
  • 所有处于就绪状态的进程,会被放在一个运行队列中等待调度;
  • 计入 Load 统计结果 + 1;
  • 睡眠/阻塞(S,Sleeping / Blocked),正在等待某个事件发生,如等待用户输入、网络 I/O 等;
  • 主动放弃 CPU,并进入睡眠队列,这是 I/O 密集型应用的典型状态;
  • 不可中断睡眠(D,Uninterruptible Sleep),相对于睡眠时间,多了不被中断;
  • 如果这种状态的进程过多或停留时间过长,通常表明硬件(如磁盘)可能存在瓶颈或故障;
  • 计入 Load 统计结果 + 1;
  • 僵尸(Z,Zombie),进程已经终止运行,但其信息仍然保留在系统进程表中,等父进程回收;
  • 本身不消耗 CPU,只是一条标识条目,但如果一直累计,也会导致进程积累,占用进程号资源;
  • 停止(T,Stopped),进程被挂起,通常是收到了一个信号,如 SIGSTOP;



C10K 和 Reactor


C10K(Concurrent 10,000 Connections)即“一万个并发连接”问题,Dan Kegel 在 1999 年提出,探讨如何让一台服务器能够同时处理一万个客户端的网络连接。

能够想到最直接的方法是:

  • 多进程,一个连接 fork 一个子进程,但 fork 操作开销大,切进程上下文切换成本也高;
  • 从多进程优化为多线程,一个连接创建一个线程。虽然比进程轻量,但连接数上去后,线程上下文切换和内存占用(默认栈大小)问题同样存在,只是在一定程度上缓解了这个问题;

所以问题变成了,如何用一个进程/线程来管理多个网络连接(文件描述符 fd),而这就是 I/O Multiplexing,从最开始的 select/poll 到后来的 epoll(kqueue on FreeBSD、IOCP on Windows)。

  • select/poll 典型过程,依赖轮询
  • 服务器进程启动
  • 在用户态创建一个 socket(listen fd)并 bind 443MySQL 端口,调用 listen 开始监听;
  • 创建用来管理客户端连接的 client fd 集合,遍历并将所有已经建立的客户端连接加入到这个集合;
  • 用户态应用程序进行逻辑处理,调用 select 函数,涉及 2 次遍历和内存拷贝;
  • 从用户态拷贝 fd 集合至内核;
  • 内核遍历这些连接,逐一检测状态,复杂度为 O(N),N 是连接数;
  • 更新完状态后,从内核拷贝这些 fd 集合会用户态;
  • 应用程序遍历 fd 集合,找出就绪状态的连接,调用 accept 或者 read 等函数处理;


epoll 典型过程,依赖事件驱动

服务器进程启动,在用户态调用 epoll_create 在内核态创建一个 epoll 实例 epfd,内核会为此维护一个数据结构。同时通过 epoll_ctl 函数创建回调处理,并挂在上述 epfd 上,最后调用 epoll_wait 等待回调触发。


当某个fd(比如网卡收到数据,client fd 变为可读)状态发生变化时,硬件会产生中断,内核的中断处理程序会被激活,内核处理程序直接找到与 client fd 关联 epoll 实例并触发回调。


对比上述过程,会发现 epoll 就是对着 select/poll 性能瓶颈进行了有针对性优化,且一直工作至今。

  • select/poll 性能开销是连接数 O(N),即便只有1个连接活跃,整体开销也是 O(N),在大并发场景下,显然成为了瓶颈;
  • 两次拷贝,每次调用都要在用户空间和内核空间之间来回拷贝巨大的fd集合;
  • 两次遍历,内核需要遍历所有fd,用户进程醒来后也需要遍历所有fd;
  • epoll 允许一个进程或线程同时监视多个文件描述符,一旦某个描述符就绪(可读、可写或出错),内核就通知进程进行相应的读写操作;
  • 内核和用户空间通过共享 mmap 实现零拷贝,且在内核中通过红黑树维护结构;
  • 记录了 fd 状态,避免低效遍历;
  • epoll 除支持 LT(水平触发)外,更是支持了 ET(边缘触发);
  • LT 默认属性,适合大多数常规应用场景, 只要文件描述符(fd)对应的 I/O 缓冲区处于可读或可写状态(即“水平”很高),epoll_wait() 就会持续返回(通知);
  • ET 对高性能有极致最求场景,只有当 fd 对应的 I/O 缓冲区的状态发生变化时(即从不可读变为可读或从不可写变为可写),epoll_wait() 才会返回一次,要求应用程序必须一次性把缓冲区里的数据全部读完或写完,对编码质量要求极高;
  • 不难发现,如果在连接数比较少的场景,比如 MySQL 主备数据数据同步场景,select/poll 依然是优选方案,其整体实现更简单,且是基于 POSIX,类 Unix 上都支持,跨平台能力更好;


单 Reactor 单线程 (Single Reactor, Single Thread)

Redis

  • 数据在内存,操作速度极快,CPU 很少成为瓶颈,瓶颈通常在网络 I/O 或内存;
  • 单线程模型天然保证了所有操作的原子性,无需担心并发锁问题,实现简单;
  • Redis 6.0 引入了多线程,但仅用于处理网络 I/O(异步读/写 socket),命令执行模块仍然是单线程;
  • Redis 定位是垂直场景,且是内存操作(get/set/delete),速度极快,同理下面 Memcached;


单 Reactor 多线程 (Single Reactor, Multiple Threads)

Memcached 默认是多线程,支持单线程。

  • 也即 Memcached -t N(N = CPU 核心数,N = 1 时 单线程模型,同 Redis);
  • Reactor 线程接收 TCP 连接,一旦三次握手完成,随即转交给 Work 线程,后续 I/O 以及数据操作都由 Work 完成;


主从 Reactor 多线程 (Main-Sub Reactor, Multiple Threads)

Netty 主(BossGroup)从(WorkerGroup)多线程。

  • 相比于单 Reactor 多线程,中间增加了从 Reactor 概念,主 Reactor 接受新连接后,随即交给从 Reactor 完成后续 I/O 操作;
  • 考虑 Netty 和 Memcached 使用场景不同,其实不难理解为什么后者是主从,因为 Netty 的业务场景往往不是 CPU 密集型,这种情况下,再抽象一层能够避免线程一直被 IO 占用,而导致吞吐上不去;
  • Redis 定位是通用性高性能网络框架,WorkerGroup 上可以直接挂载 Handler,也可以进一步挂载业务线程池(对 IO 密集任务更友好);


Nginx 是主从 Reactor 多进程,Worker 进程在处理请求时是完全独立的,它们不共享内存数据(例如 HTTP 请求上下文)。这意味着 Worker 内部无需考虑复杂的锁机制来保护共享数据,极大地简化了架构和编码复杂度,减少了死锁风险,并且保证了高性能。


理解 epoll 后,就很容易理解 Reactor,也就很容易理解 Redis、Memcached 以及 Netty 中一些关联概念以及对应配置,以 Redis 配置为例。

# Reactor 监听入口
port 6379

# TCP backlog 全连接队列大小,Reactor 主线程繁忙时,内核暂存连接大小
tcp-backlog 511

# 客户端长时间不活动,主动断开连接,避免连接占用
timeout 300

# 最大客户端连接数
maxclients 10000

# Redis 6.0+ 支持开启 I/O 多线程,而命令执行依然是单线程,类似 Memcached 多线程方式
io-threads-do-reads yes

# I/O线程数量,常设置为 CPU 核心数
io-threads 8

阻塞/非阻塞 VS 同步/异步 这两组概念区别是什么

  • 同步/异步,关注的是调用方如何获取结果
  • 同步:调用者需要主动等待或主动轮询去获取结果,结果通知与调用在同一个调用链上;
  • 异步:被调用者在完成操作后,会主动通知(通过回调、信号、消息等)调用者,结果通知与调用是分离的;
  • 阻塞/非阻塞,关注的是调用方在等待状态时状态
  • 阻塞:调用者发出调用后,线程被挂起,让出 CPU,线程进入休眠状态;
  • 非阻塞:调用者发出调用后,线程立即返回,不会被挂起,可以继续执行其他代码;


其中,同步非阻塞以及异步阻塞这两种一般不太常见,因为这俩概念从底层上,就有点冲突。同步强调调用实时性,期望及时获取到结果,而非阻塞意味着调用方会继续执行其他代码,避免让渡线程进行切换,提高吞吐。但上述分析仅限于微观视角进行分析,若从宏观视角看,日常 HSF 远程调用操作又何尝不是一种同步非阻塞模型,调用端同步发起请求,接收端通过 NIO 将连接转发给 Sub Reactor 处理,而这部分恰好又是非阻塞模型。


若将视角切换至全链路,纠结是同步还是异步,异或阻塞非阻塞意义不大,本身比如中间路由交换设备,也是一样,也区分存储转发,还是实时转发,操作系统本身也是,内核网络栈有一个 Buffer,无论写入还出读取,都得先进入 Buffer,而写操作及时返回本身就是非阻塞操作。从这个意义上看,性能问题本身就是在无数个类似地方,在进行 Trade-Off。在单个节点上进行优化,未必整体性能会有明显提升,需要基于全链路视角看。

理解 epoll 和 Reactor 这两个概念后,绝大部分网络相关性能问题都很好解释。


四、快速识别问题域

收到报警或者日常主动排查过程中,不要立即扎进日志里,尤其是在故障场景,一个报警出现后,往往是一堆报警跟着来。直接去看报警本身,很容易迷失在各类细节中,而错失降低影响面的黄金窗口。先得建立一个宏观认识,请求大致会经过哪些节点,其背后就是日常看见的各类架构图,比如网络架构、部署架构、数据架构、业务架构、安全架构等所呈现出来的数据、控制以及应用平面间依赖关系。


下面从请求从终端发起侧作为起点


1. 终端操作系统以及在至上运行的 EDR 等安全组件;

2. 本地 Wi-Fi 或有线网络、路由器等。这里有些时候容易被忽略,尤其在个例场景,比如用户进入涉密单位、隧道或者家庭无线路由摆放位置不当,导致无法加载或者加载很慢;

3. DNS 解析是否发生漂移;

4. 小区运营商路由以及后续骨干传输,若再复杂一点,还会涉及跨运营商和跨境请求;

5. 部分资源请求会进入 CDN,比如前端静态资源;

6. 一些强调安全以及稳定性性等因素的请求,还会就近接入云厂商或者运营商 Anycast 就近接入点,比如车联网场景,公司 VPN 或者自动入网本身也是一种就近接入场景;

7. 服务侧边界接入,一些核心路由、交换机、防火墙、IPS 等设备;

8. 3 ~ 4 层负载均衡,比如 SLB 和 F5;

9. 应用层接入,最常见的是 Web 场景,如 Tengine(对比 Nginx),本身也充当应用层负载均衡;

  • TLS 证书卸载
  • 反向代理

10. 后端服务,即大多数开发可以接触到的白盒阶段;

11. 各类中间件,比如 HSF、MetaQ 等;

12. 各类数据库,比如关系型 RDS 和 NoSQL Redis;

13. 生产网内部也不一定在一个机房,本质它也是一个 Overlay 网络,考虑性能以及高可用等因素,电商业务本身也是异地多活;

  • 各类混合云子场景,不同网络间,流量怎么进出;
  • 不要忽略主机和容器安全组件,比如 ATP 和 RASP,也需要留意其性能损耗;


在实际工作中,建议按从上而下方式,进行定位,避免直接陷入各类细节中,除了能够快速定位问题外,还能够帮助尽量避免陷入局部最优的陷阱。花了老大力气,局部指标提升明显,但对于整体收益不大,甚至因为方案复杂度上升,而引入新的故障风险点。


快速识别问题域,识别问题是“面”还是“点”,谨慎看待群里面一些措辞,比如大量用户掉线或者延迟飚高。不同团队对阈值理解不一样,有些认为 100 个用户收到影响,天就要塌了,而有些则表现的很淡定,一定要尽可能获得具体影响量化数字,对于后续问题定位非常有帮助。

  • 明确影响范围,是所有用户,还是特定群体用户,比如地域、运营商或者公司园区。又或者是所有接口慢,还是部分特定接口慢;
  • 全局性问题往往出现在基础链路或服务端侧,如机房、云设施或者中间件异常,当然更多时候还是服务端问题,尤其和变更直接相关;
  • 局部性问题更有可能出现在客户端或特定服务上,比如某服务逻辑实现有缺陷;
  • 利用观察指标,判断表现是延迟高,还是吞吐低,或者是两者都有;
  • 关联指标变化情况
  • QPS、TPS 有没有暴跌或暴增,暴增可能引发雪崩,暴跌可能是上游被阻塞或宕机;
  • 关键指标错误率是否暴增,现实中更多是超时,业务逻辑本身错误并不多见;
  • 现有 Sunfire 监控往往是平均值,一些高并发业务局部问题,往往会被隐藏,日常建议也多关注一些 P95 和 P99 指标;
  • 延迟高但吞吐正常,往往指向路径上的某个环节处理慢,但通道本身是通的,比如慢 SQL、依赖 API 耗时高、GC 导致的 STW 等;
  • 吞吐低的时候,延迟往往不会正常,因为资源出现瓶颈,会快速拉低整个系统的处理性能,比如数据库连接池打满、应用线程池耗尽、CPU 或 IOPS 达到上限、网络带宽饱和,这种情况往往后果更严重,这是个雷,爆了就是大问题;


分段逐个排除,沿着请求路径,根据日常经验,不断通过二方查找缩小排查面。


  • 客户端
  • 浏览器 Network Tab 识别问题出现在哪一段,如果是客户端比如手淘 APP,打开 Debug 模式查看;
  • 客户端本身资源(CPU、内存)是否饱和,PC 和移动端差异还比较大,移动端对于个体应用资源消耗会强管控,不太可能出现一个 APP 疯狂占用系统资源,而导致整体不可用;
  • 网络传输
  • 通过各类抓包工具绘制耗时分布,包括 DNS 解析、TLS 握手、TCP 建连以及数据下载等;
  • 结合 ping、traceroute 和 mtr 分析哪一跳出现高延迟或丢包,延迟在进入生产网前就很高,问题很可能在中间运营商网络;
  • 基础设施
  • 正常情况下,很少会有接触到这类问题,因为 TRE 做了一层封装,看到的就已经是形成产品能力之后的控制台 Normany;
  • 混合云之间的 VPN 或专线等不同方案对应延迟和丢包率;
  • 应用服务,也是大部分后端研发擅长的领域
  • 这里的可展开的知识就太多了,无论是 JVM 内存管理,JIT 编译优化,代码本身执行效率,各种池化实现,微服务间依赖等;
  • 配套工具也最完备,比如 Arthas、鹰眼、Sunfire 等;
  • 中间件
  • RPC HSF,大量超时、序列化/反序列化异常、线程池满;
  • 消息队列 MetaQ,堆积、消费延迟、重复消费;
  • 配置中心 Diamond 推送延迟或失败,导致节点间配置不一致;
  • 数据存储
  • RDS 慢查询、锁竞争、连接池、磁盘 I/O 等;
  • Redis 热点 Key、大 Key、缓存雪崩、穿透和击穿;
  • 安全组件
  • 安全组件 ATP、RASP 和 X-Agent 等引入是否会显著增加性能开销。

五、数据层

尽管在日常开发以及运维过程中并不太涉及数据库,但一旦出现报警,还是很被动的,若不能及时应急,还容易引起故障。以关系型数据库为例,正常研发视角是一条条 SQL,但这些 SQL 映射到下面监控上,甚至都不等同于一次逻辑读,或者逻辑写。数据库监控上除慢 SQL 外,也没有针对 SQL 的监控,即便是索引不合理,只要还没触及慢 SQL 阈值,甚至都不容易被发现。


查询优化器并不总是很聪明

有次线上巡查发现有慢 SQL,并且出现罕见物理读页面操作,这是非常危险的信号,意味着这种请求 QPS 只要达到 1( poolSize:10),那么发起该查询所在应用连接池就会开始排队,到时会出现一堆超时,接着应用 HSF 线程池会满,最后一层层传导,最终整条业务链路都会受到影响。

Explain 执行后,对比上面表格,发现预估扫描行数出现严重偏差(关注加粗部分)

对应问题 SQL WHERE 条件,索引情况参考注释。如果选用 idx_sn 作为索引,效率会高出几个量级(从 13 秒降至 6 毫秒)。

# FORCE INDEX(idx_sn)
WHERE type = ?             -- 无索引
  AND lan_id = ?         -- 唯一索引最左列
    AND sn = ?             -- 单列索引
  AND is_reserved = ?    -- 无索引
  AND is_allocated = ?   -- 单列索引
ORDER BY address4_bin ASC  -- 单列索引
LIMIT 0, 1
UNIQUE KEY `uk_lan_id_address` (`lan_id`,`address`(40)),
KEY `idx_cidr` (`cidr`(128)),
KEY `idx_allocated` (`is_allocated`),
KEY `idx_sn` (`sn`),
KEY `idx_peer_id` (`peer_id`),
KEY `idx_ip_address` (`address`(15)),
KEY `idx_address4_bin` (`address4_bin`)

为什么优化器会低概率性判断失误?


若解释为优化器因为统计数据失真,从而误判 idx_address4_bin 索引区分度。这样似乎说不通,因为从业务层 RT 监控看,也仅 0.82% 概率出现该问题,说明优化器并不总是错误。但是若是结合业务层日志,发现只有 2 名(即上述 0.82% 是这 2 名用户带来)用户走到上述异常分支。


问题苗头再次指向优化器,对正常和异常 SQL 分别在 SELECT 前追加 explain FORMAT=JSON ,下图左侧是正常情况,右侧是异常情况。


1. 从执行结果看,优化器并没有做错,选用选用 idx_address4_bin 后,预估会扫描 1252 行(基本准确,新机上线场景,相对于其他设备,数据较少)。


2. 但是优化器忽略了一点,就是 ORDER BY 字段,并未出现在 WHERE 条件中,这样选择排序更优using_filesort=false),但误判全索引扫描得成本(200W+),最新选择执行全索引扫描,是典型基于一定前提假设下的误判案例。

实际业务场景也确实如上面分析一样,相比于其他机器,其行数低至少一个量级。很难想象,这样一个简单的工号灰度功能,会经过一层层传导,最终触发上面这样一个匪夷所思,长达 13 秒的慢 SQL。


最终优化方案就是建立联合索引,避免执行器帮倒忙。关于选择哪些 Key 以及顺序建立联合索引,有几个注意事项:


  • 等值查询列优先(Equality First);
  • WHERE 子句中用 = 或 IN 进行的条件查询,是最高效的。你应该把所有这类条件的列放在索引的最左侧;
  • 范围/排序查询列居后(Range/Order Last);
  • WHERE 子句中用 >、<、BETWEEN、LIKE 'prefix%' 进行的条件查询,是范围查询;
  • ORDER BY 或 GROUP BY 使用的列,也属于这类;
  • 一旦优化器在索引上使用了范围查询,该列之后的所有索引列都将无法再用于过滤数据;
  • 等值查询列区分度高的优先
  • 将区分度最高的列放在最左边,但这不绝对,也要考虑其他查询条件,取一个交集,从而节省索引空间,尤其在一些写多读少的场景中;


下面这三种联合索引方案:一眼看应该选 A,仔细想想 B 似乎更合适,但分析业务特征后,发现 C 才是最合适的。但选择 C 方案是建立在对业务当下以及未来,非常了解的基础之上,在多数情况下,方案 A 仍然是更优方案。


  • A 方案将所有查询以及排序字段都涵盖在内,并且严格执行了上述左值建议,查询效率最高;
  • B 方案考虑其他查询场景,比如仅有 sn 或者 sn + address4_bin 查询条件,将其中区分度最高的两列建联合索引,节省索引维护成本;
  • C 方案最贴近真实业务,is_allocated 是这张表中业务承载字段,IP 会被回收和重新分配,而其他如 type、lan_id 一直不变。针对这三个字段建立联合索引的查询效果,基本等同 A 方案,但存储空间要明显小于 A。
## 方案A
CREATE INDEX idx_office_ipv4 
ON *** (
  sn,           -- 等值查询
  type,         -- 有限枚举
  lan_id,       -- 有限枚举
  is_allocated, -- 2值枚举
  is_reserved,  -- 2值枚举
  address4_bin  -- 范围查询
);

## 方案B
CREATE INDEX idx_office_ipv4 
ON *** (
  sn,           -- 等值查询
  address4_bin  -- 范围查询
);

## 方案C
CREATE INDEX idx_office_ipv4 
ON *** (
  sn,           -- 等值查询
  is_allocated, -- 2值枚举
  address4_bin  -- 范围查询
);


大字段对性能的影响

线上出现慢 SQL 报警,经过排查之后,发现是大字段问题引起。关于这类问题影响经常容易被忽视,但若仔细想想,不难发现,这其实也是非常严重的风险。

CREATE TABLE `***` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `***` mediumtext NOT NULL COMMENT '***',
  `***` varchar(2048) DEFAULT NULL COMMENT '***',
  `***` text COMMENT '***',
  `***` text COMMENT '***',
  `***` mediumtext COMMENT '***',
  `***` text COMMENT '***',
  PRIMARY KEY (`id`),
  KEY `***` (`***`,`***`,`***`),
  KEY `***` (`***`,`***`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='***'
;

从表结构中发现有 2 个 mediumtext 字段和 3 个 text 字段,另外从统计中看:2064 行记录,表空间 1.02 GB,平均每条大小 399KB。进一步展开:


  • mediumtext 最大占用 16MB 字节,而 text 最大占用 64KB 字节。MySQL 5.7+ 版本后,会默认生效 ROW_FORMAT=DYNAMIC,即数据行内只会存储一个 20 字节指针,实际数据存放在溢出页(Overflow Pages),每页大小 16KB。这里注意两点,一是同样存在碎片问题,二是数据频繁读后同样会被加载至 Buffer Pool。对于一个 4C8G 数据库实例而言,极端情况下 1.02 GB 内存占用,不可谓风险不大;
  • varchar 是可变长字符,不同编码最终所占空间有锁差异,以 utf8mb4 为例,一个字符最多 4 个字节,即 varchar(1024) 最大占用 4KB 字节,而最终实际存储空间由实际数据长度 + 1 ~ 2 个字节的长度前缀组成;


这里的风险主要体现在 3 个地方:

  • 磁盘 I/O:先通过索引找到 B-Tree 数据页,然后再通过 20 字节指针去溢出页加载。一次简单的逻辑行查询,在物理层面变成了多次随机 I/O。如上面所说的平均行 399 KB,约需读取 399 / 16 ≈ 25 个数据页;
  • 网络 I/O:MySQL Server 会把这少则几百 KB,大则几百 MB 的完整数据,通过网络传输给应用服务器,除消耗网络带宽外,应用服务器也需要分配对应内存来接收和处理这些数据。极端情况会导致 FGC,甚至因为 FGC 无效果,而直接引起 OOM 而导致进程被 Kill;
  • Buffer Pool:InnoDB 依赖 Buffer Pool 来缓存热点数据页,以避免频繁的磁盘 I/O。当查询这些大行时,不仅 B-Tree 页被加载进 Buffer Pool,那些溢出页数据也会被加载。这会导致其他真正高频访问的小行数据被逐出,使得查询缓存命中率急剧下降,最终拖慢整个数据库的性能;


解决办法也很简单,避免 select * 以及将大字段拆出,或者改用其他更适合的数据库,比如对象存储。


DAO Mapping 框架默认 select * 是否合理

每次写 DAO 时都会纠结半晌,都说 DAO 是对 DB 的抽象,和实际业务不直接相关,不应通过写很复杂的 SQL 去处理业务逻辑,尤其不要进行 Join 操作。但现实往往走向另一个极端,尤其是在使用一些 DAO 框架以及 Aligenerator 这些代码生成工具之后,这些 Mapper 都太抽象,以至于没有任何业务语义,以下面这个 list 方法为例,能想到什么风险?

@Repository
publicclassXxxImplextendsServiceImpl<XxxMapper, XxxDO> implements XxxRepository {

    public Page<XxxDO> list(XxxParam queryParam){
        return PageHelper.startPage(Integer.parseInt(queryParam.getCurrentPage()), Integer.parseInt(queryParam.getPageSize()))
                .doSelectPage(() -> this.list(new QueryWrapper<XxxDO>().lambda()
                                .eq(StringUtils.isNotBlank(queryParam.getXxx()), XxxDO::getXxx, queryParam.getXxx())
                                .eq(queryParam.getXxx() != null, XxxDO::getXxx, queryParam.getXxx())
                                .eq(StringUtils.isNotBlank(queryParam.getXxx()), XxxDO::getXxx, queryParam.geXxx())
                                .orderByDesc(XxxDO::getGmtCreate)
                        )
                );
    }

}

这里 list 方法为了尽可能通用,入参使用分页查询 Param 以方便 Console 上不同查询场景。正常情况下,因为 filter 条件足够多,因此至返回几行数据,但若 osType、packageType 以及 appName 同时为空且分页查询参数又被设置的很大,后果是什么?


会把表中的数据都查回去,如果再涉及前面提及的大字段,紧接着数据库会报警,应用会 GC,接口耗时会飙涨 …最近几年,至少遇到过两次故障是由这类问题引起,其中一起是由于数据倾斜太严重,导致后续 SQL Select 语句笛卡尔出了几千条子查询,致使最终出现了戏剧性场面,运营每在 Console 上点一次按钮,一台机器就开始 FGC。


同理,一些涉及 delete 操作,是不是也是类似风险?


这里倒不是性能问题,更多是故障层面风险。很多时候,面向失败编程并不是口号,而是实实在在的保命手段。记得刚上班那会,团队一位老大哥说,定义接口时,即便约定好,白纸黑字,也不要相信。最深的一次印象,是一次在交易下单链路上渲染物流时效时,中台升级渲染引擎,使得 EL 表达式执行异常。几十兆流量瞬间击穿业务扩展点,但那次,我写的代码狗住了,因为我在扩展点里面,重复进行了 if 身份判断,而 CR 那会还被挑战了。那会心情挺复杂,倒不是虚惊一场,更多是懵逼。因为那次 EL 失效原因很诡异,是因为由于二方包冲突,而导致工具类版本被降级。如果换我,大概率也会引发这个故障。


类似场景,还遇到不止一次。防呆做到什么程度才是合适的,这也有一个很值得讨论的话题。


Redis 中大字段以及热点影响又是什么

翻开任何一个缓存手册,都在强调:

1. 避免大 Key 和热 Key;

2. 不能要求缓存一直稳定,如果出现抖动,业务要自己兜得住;

第一点如果不看一些原理是不太容易理解的,而第二点刚开始有点不太信,直到遇到几次,有时是真抖。

大字段意味着占用更大存储空间,这里空间更多是指内存(磁盘空间不太会成为瓶颈,但是磁盘 I/O 是瓶颈),但内存空间总归有限,不可能将所有数据都放至内存中(而且内存还有易丢性问题),因此这里就演化出两条设计线路:

  • 加载高频数据至内存,在 MySQL InnoDB 中就是 Buffer Pool,将数据所在把页(通常 16KB)缓存进内存。Buffer Pool 大小固定,写入新数据,也即意味着已存在数据要被逐出,从而降低缓存命中率,放大 I/O 成本。如果热点读,问题不大,但如果不是热点数据或者是写操作(行锁),这就会引入风险;
  • 既然内存足够快,那就用到极致,Redis 直接将值全部放至内存中,通过引入 Slot 在逻辑上对 Key 和实际存储位置进行了解耦,即便底层扩容,上层业务也不受影响。在 Redis 中,大字段更多是因为其 I/O 操作是单线程模型,读取数据量大意味着会 Hang 住其他请求,导致尾部请求劣化明显;

在关系型数据库中,热点不是设计关注点,因为引擎内部会通过页缓存、行锁等机制自行调度。而在分布式缓存中,热点是系统级负载均衡问题,需要应用层解决,比如拆 Key 和限流等方式解决。


Redis 和 MySQL 设计上的取舍

本质是和不同数据库的定位有关,MySQL 某种意义上是中心化数据库,更多是从容灾需要,将实例配置为主从模式。数据量不到一定量级,比如千万以上,也不会考虑分表或分库,更多是追求完整性和一致性。Redis 更像是过程中数据,即便缓存整体崩掉,系统也应该能基本运行,其目的更多是提高整体性能,容忍一定程度上的一致性。

图片来自:https://blog.algomaster.io/p/why-is-redis-so-fast-and-efficient

为什么一台低配 Pod 实例,可以轻松支持万级别 QPS 操作

  • 基于内存的数据库,避免了磁盘 I/O,通过多主模型实现低成本水平扩展,Slot 设计非常妙,解耦 Key 和实例 Node 之间关系,隐藏扩容时的底层数据复制操作,应用层无需任何改造;
  • 通过单 Reactor 单线程模式,避免线程切换带来的性能损耗;
  • 这段话成立的前提是认为基于内存的操作非常快,瓶颈往往不在 CPU,所以即便是单核,也能拥有极高吞吐;
  • 但是也不绝对,如果遇到大 Key 这种场景,将是灾难性的,线程将一直被 I/O 持有,导致其他任务进入排队。6.0 版本后,类似 Memcached,也支持通过多路复用的方式处理连接,而操作部分依然是单线程;
  • 正在前面提到的基于内存的数据库,所以有一些为了降低内存占用而非常妙的设计,比如 SDS(Simple Dynamic String)和 quicklis;
  • SDS 显现维护一个长度 len 字段
  • 使得获取长度的操作降为 O(1),使得字符串操作可以在实际执行前,先低成本进行校验,自动动态扩散容,避免直接溢出;
  • 也正是因为可以灵活获取长度,让空间预分配和惰性空间释放成为可能;
  • 同样也不需要类似 C 字符串以 \0 表示结尾,可以通过 len 直接判断,类似 HTTP Content-Length;
  • quicklist [Total Bytes][Tail Offset][Element Count][Entry1][Entry2]...[EntryN][End Marker];
  • 由一个双向链表组成,而链表的每个节点都是一个 ziplist;
  • ziplist 是一块连续的内存,其中多个列表项紧挨着存储,无需维护过多指针(64 位系统占 8 字节,联想 JVM 指针压缩,是另外一种意义上的优化),以及正是由于在连续内存上,被 CPU 缓存命中概率也大大提高,又间接带来读操作性能的提升;
  • 每个 entry 由 [prevlen] [encoding] [actual data] 组成;

1. prevlen 存储前一个 entry 的字节长度,实现 prev 指针功能;

2. encoding 编码方式,比如一个小整数可以用 1 字节编码,而一个短字符串可能用 2 字节编码;

3. actual data 实际数据;

图片来自:https://blog.bytebytego.com/p/why-is-redis-so-fast

  • 也有一些贴着业务,为提高读取速度,而用空间换时间的设计,比如 sorted set 同时使用 Hashtable 和 SkipList 这两种结构:
  • 如果只用 Hashtable 可以 O(1) 定位特定元素,但无法快速进行范围查找,而如果只用 SkipList 基于二分查找实现范围查找,但确无法快速定位特定元素;
  • 而当元素少且短时,Sorted Set 会直接使用 ziplist 实现以节省空间;
  • 基于内存,也会带来一些问题,比如数据持久化,毕竟不是关系型数据库,有一套复杂的 ACID 机制保障。
  • 避免数据丢;
  • RDB 快照模式,Redis 会 fork 一个子进程来将当前内存中的数据完整地写入磁盘上的 dump.rdb 文件;
  • AOF Redis 会将每一条写命令追加到一个日志文件 appendonly.aof 末尾;
  • 数据集大于内存情况;
  • maxmemory 2gb,设置 Redis 实例能使用的最大内存。当内存达到上限时,Redis 会根据配置的淘汰策略开始删除一些键,以防止 Redis 耗尽服务器所有内存,这些策略包括 ;
  • allkeys-lru 从所有键中,优先淘汰最近最少使用的;
  • volatile-lru 只从设置了过期时间的键中,淘汰最近最少使用的;
  • allkeys-random 随机淘汰;
  • noeviction 不淘汰任何键,此时对于写命令会直接返回错误;


图片来自:https://help.aliyun.com/zh/redis/product-overview/standard-master-replica-instances

同样以 Redis 为例,引申一个关联问题:高可用(HA)和高性能(HP)这两个概念间是什么关系。


如果不考虑资源闲置,这两个其实可以做到正交或协同,尤其是在一些通用领域,比如磁盘存储 RAID 10 = RAID 1 + RAID 0,支持既要又要。但现实情况往往是冲突,有些性能优化是贴着业务进行定制,定制往往意味着不通用,而高可用往往保障的是核心通用链路。而且高可用相关话题,在早期设计阶段也容易被忽略,再到后来想补的时候,则会面临各种钳制,比如性能会受损。

  • 高可用往往是数据冗余、堆机器和动态负载。
  • Redis 有个非常有趣的例子能够体现为什么高可用,往往意味着去中心化,就是集群模式和哨兵模式的容灾思路上有什么区别;
  • 集群模式通过节点间的 Gossip 协议和投票机制,实现了一个去中心化的、自带故障转移功能的、可水平扩展的数据分片系统;
  • 哨兵模式用一个独立于 Redis 数据节点之外的进程监控整个主从架构,容灾决策权掌握在哨兵手中,这本身就是一种中心化思路,但哨兵模式本身就是一主多节点,已经是中心化架构了,所以哨兵独立出来,反而实现了高可用性,本质这也是一种看门狗机制的实现;
  • 如果单点读写性能遇到瓶颈,哨兵模式下可以通过读写分离提高吞吐,但大都数时候并不推荐。基于内存型的数据库,如果引入主备数据同步 I/O 操作,某种意义上,因为在解决数据一致性问题的同时,也引入新的性能瓶颈。若从这个角度看,性能和高可用两者是冲突的;
  • 高性能则是去通用化和定制。


数据和代码的边界在哪

通常说数据存储的是状态,而代码处理的是业务逻辑,但似乎这两者边界在变得模糊。

不知道你是否也有过这样一些疑惑:

  • 有 MySQL + Redis 组合后,为什么数据库领域还会不断有新技术出现,比如 PostgreSQL、HBase、Flink/Spark、Elasticsearch、OSS 和 Neo4j 等;
  • 数据和代码的边界在哪;
  • ...

我理解,未来数据会更贴着垂直业务场景,提供计算加速(本质是为避免各种类型 I/O)能力,而不再一味强调通用性,等于变相的将原本部分业务侧通用逻辑能力转移给了数据库。

  • 计算向数据移动
  • Redis 中 ZSet,在数据库层面提供了排序能力,而原本需要把数据读取到应用中进行排序;
  • PostgreSQL 中 JSONB 数据结构对 JSON 复杂操作的支持,以及 PostGIS 扩展对复杂的地理位置查询的支持;
  • 新业务场景也在不断催生新数据库
  • Neo4j 在用户社交关系场景,利用原生图数据结构加速关系查询;
  • Elasticsearch 基于文本索引方式,避免关系数据库中 Like 操作性能差的问题;
  • HBase 面对海量的且结构稀疏多变,比如用户画像系统(几千个标签,每个用户只有几十个)和物联网传感器数据时,可以方便动态增删;
  • Flink/Spark 这类实时流处理引擎提供了更强的实时性,在监控以及风控等领域广泛使用;
  • 对象存储 OSS 对于非结构化或半结构化数据提供了相对较低成本的解决方案;
  • Milvus / PG-Vector 让 AI 模型直接在数据层调用相似度计算;

如果你发现代码写的比较吃力或者 CPU 利用率一直居高不下时,可以试着想想,是不是数据库选的不对?


六、Netty 网络编程几乎没有优化空间

日常接触网络问题较多,且在 Java 语境下,网络编程基本等同 Netty 编程。但在我翻开各种 Netty 相关代码时发现,若仅从性能优化角度看,几乎没有什么地方可以总结。


比如 HSF 中对 Netty 封装,和我们组管理十几台代理服务器的控制服务,甚至几十万设备的长连接服务器之间,几乎没有太大区别,都是短短几十行初始化代码,一些 Pipeline Handler 组装,再加两层 Encoder 和 Decoder 转换(从传输层 Payload 中解析出应用层数据如 LengthFieldBasedFrameDecoder,以及将数据进一直解析成对象,同理 Decoder。当然,编解码或者序列化本身效率也是需要关注的优化点),优化更多在应用层。


从这个意义上看, Netty 在抽象层面上已经逼近网络 I/O 编程的最优解。

  • 多 Reactor 模型:主从线程池划分 BossGroup、WorkerGroup
  • 零拷贝机制:DirectBuffer、CompositeByteBuf
  • 高度可插拔的管道模型 Pipeline、Handler
  • 灵活的参数控制 ChannelOption
  • 事件驱动(EventLoop)与任务队列化处理


我甚至一度怀疑 Virtual Thread 这些从语言层面提供的 NIO 封装是否必要?NIO 是为解决 I/O 密集型任务而出现,如前面 C10K 问题,如果不进行优化,会导致请求被阻塞,也会进一步耗尽线程资源。但是这些场景,往往都已基于 Netty 来实现,已经通过主从多 Reactor 模型,实现异步非阻塞。


但转念一想,似乎也不对。Netty 只是提供网络 I/O 快速 Accpept 能力,但若在具体 Handler 中依然大量调用 I/O,还是会劣化整体性能,这种情况下 Virtual Thread 相对于传统的线程模型提供了更好了编程体验。写这段话时,实则有些别扭,看上去似乎都对,但也不是十分认同。因为无论是 Thread,还是 Virtual Thread,还是 GO goroutine,问题本身都没有发生变化,至于将调度职责由内核交给用户态进程,是否是更优设计,也值得进一步讨论。同样基于 Virtual Thread 或者 goroutine 编程,是否获得更友好的编程体验,我觉得也因人而异。


这里贴一张 Netty 官网首页的图,统治 Java 生态这么多年的网络编程框架,Respect !


七、JVM

某天生产环境报警,显示某台机器 HSF 线程数量飙涨,同时出现大量请求 RT 变高情况。打开监控,发现:

1. FGC 1.27 秒,且时间点和 HSF 线程数飙涨时间点吻合;

2. HSF RT 影响持续 2 min,但其线程数运行在高位时间长达 5 分钟(24 - 29 分);

3. FGC 后接口影响恢复正常,且老年代回收效果明显,从 1.6GB 下降至 786.15MB;

看到这里,如果对 FGC 耗时没有直观感受,很容易会误判这是一起正常 FGC 带来的副作用,而且 RT 也仅是增长至 1.27 秒,也未到默认的 3 秒阈值,业务一切正常。


FGC 1.27 秒是什么概念?所有 HSF 请求被 hang 住 1.27 秒。


Dump Heap 显示 SSLSessionImpl 对象过多,当时未截图,参考另外一台即将 GC 机器实例情况(注意不要用 jcmd 3850 GC.class_stats | grep SSLSessionImpl,会引起 STW),至此,泄露对象已明确。

$/opt/taobao/java/bin/jcmd 3850 GC.class_histogram | grep SSLSessionImpl
   3:        194835       20262840  sun.security.ssl.SSLSessionImpl

考虑到当时还伴随一次 FGC 且 STW 时间 1.27 秒(日志中 0.0166715 ms + 1.25 secs),查看当时 gc.log 发现耗时集中在 Final Remark 阶段 weak refs processing 部分。结合上述 SSLSessionImpl 对象过多以及弱引扫描耗时现象,猜测和 HTTPClient SSL Session 残留有关且对象已进入老年代。直至 FGC,否则内存缓慢持续增长。往前查看堆老年代增长趋势,发现确实如此。

  • 2025-06-25T16:24:18.095+0800: 4326737.150: [weak refs processing, 0.7995066 secs]
  • 2025-06-25T16:24:18.895+0800: 4326737.950: [class unloading, 0.1978340 secs]
  • 2025-06-25T16:24:19.150+0800: 4326738.205: [scrub string table, 0.0051682 secs][1 CMS-remark: 1677724K(2097152K)] 1967483K(4019584K), 1.2496326 secs] [Times: user=1.42 sys=0.01, real=1.25 secs]
2025-06-25T16:24:11.945+0800: 4326731.000: [GC (CMS Initial Mark) [1 CMS-initial-mark: 1677724K(2097152K)] 1683220K(4019584K), 0.0166715 secs] [Times: user=0.02 sys=0.02, real=0.02 secs]
2025-06-25T16:24:11.962+0800: 4326731.017: [CMS-concurrent-mark-start]
2025-06-25T16:24:12.920+0800: 4326731.975: [CMS-concurrent-mark: 0.958/0.958 secs] [Times: user=1.47 sys=0.13, real=0.96 secs]
2025-06-25T16:24:12.920+0800: 4326731.975: [CMS-concurrent-preclean-start]
2025-06-25T16:24:13.020+0800: 4326732.075: [CMS-concurrent-preclean: 0.100/0.100 secs] [Times: user=0.15 sys=0.02, real=0.10 secs]
2025-06-25T16:24:13.020+0800: 4326732.075: [CMS-concurrent-abortable-preclean-start]CMS: abort preclean due to time 2025-06-25T16:24:18.027+0800: 4326737.082: [CMS-concurrent-abortable-preclean: 4.964/5.007 secs] [Times: user=7.37 sys=0.52, real=5.01 secs]
2025-06-25T16:24:18.030+0800: 4326737.085: [GC (CMS Final Remark) [YG occupancy: 289758 K (1922432 K)]
2025-06-25T16:24:18.030+0800: 4326737.085: [Rescan (parallel) , 0.0652373 secs]
2025-06-25T16:24:18.095+0800: 4326737.150: [weak refs processing, 0.7995066 secs]
2025-06-25T16:24:18.895+0800: 4326737.950: [class unloading, 0.1978340 secs]
2025-06-25T16:24:19.093+0800: 4326738.148: [scrub symbol table, 0.0569389 secs]
2025-06-25T16:24:19.150+0800: 4326738.205: [scrub string table, 0.0051682 secs][1 CMS-remark: 1677724K(2097152K)] 1967483K(4019584K), 1.2496326 secs] [Times: user=1.42 sys=0.01, real=1.25 secs]
2025-06-25T16:24:19.281+0800: 4326738.336: [CMS-concurrent-sweep-start]
2025-06-25T16:24:20.898+0800: 4326739.954: [CMS-concurrent-sweep: 1.441/1.617 secs] [Times: user=4.01 sys=0.35, real=1.62 secs]
2025-06-25T16:24:20.905+0800: 4326739.960: [CMS-concurrent-reset-start]
2025-06-25T16:24:20.923+0800: 4326739.978: [CMS-concurrent-reset: 0.015/0.019 secs] [Times: user=0.04 sys=0.02, real=0.02 secs]

Java 4 种引用

  • 强引用(Strong Reference):最常见的引用比如 Object obj = new Object(),只要被 Root 引用存在就不会被回收;
  • 软引用(Soft Reference):内存不足时回收,适合缓存场景;
  • 弱引用(Weak Reference):垃圾回收时立即回收,适合临时对象,无需常驻内存场景;
  • 虚引用(Phantom Reference):用于对象回收跟踪,配合引用队列进行资源清理,比如释放 DirectByteBuffer;

翻看 HttpClientUtil 相关代码时,发现一个疑似性能问题,生产网内部 HTTP 调用场景,没有显性启用 Socket KeepAlive,虽说 HTTP 线程池复用,但是 Socket 每次重建,除 TCP 握手开销外,还有 TLS 握手开销(机房内部 HTTPS 是否必要,也值得探讨,毕竟其他中间件比如 RPC 调用也都是明文),尤其涉及跨区域机房间调用,RTT 差异尤为明显,即便什么都没有做,先得开销 2~3 个 RTT。

publicclassHttpClientUtil {

    privatestaticfinal CloseableHttpClient HTTP_CLIENT = HttpClientFactory.getInstance(
            HttpClientFactory.DEFAULT_SO_TIMEOUT,
            HttpClientFactory.DEFAULT_CON_TIMEOUT,
            HttpClientFactory.DEFAULT_MAX_TOTAL,
            HttpClientFactory.DEFAULT_MAX_PER_ROUTE);

    publicstatic Object doPost(String url, List<Header> headers, Map<String, String> params){
        // 每次创建短连接且请求结束后有SSL Session残留
        HttpPost httpPost = new HttpPost(url);
        if (CollectionUtils.isNotEmpty(headers)) {
            for (Header header : headers) {
                httpPost.addHeader(header);
            }
        }

        List<NameValuePair> nameValuePairs = new ArrayList<>();
        Set<String> keySet = params.keySet();
        for (String key : keySet) {
            nameValuePairs.add(new BasicNameValuePair(key, params.get(key)));
        }
        httpPost.setEntity(new UrlEncodedFormEntity(nameValuePairs, Charsets.UTF_8));

        try (CloseableHttpResponse response = HTTP_CLIENT.execute(httpPost)) {
            HttpEntity entity = response.getEntity();
            if (entity == null) {
                return null;
            }
            String result = StringUtils.trim(EntityUtils.toString(entity, DEFAULT_CHARSET));
            // ...
        } catch (Exception e) {
            // ...
        }
        return null;
    }
}

publicclassHttpClientFactory {

    publicstatic CloseableHttpClient getInstance(int soTimeout, int conTimeout, int maxTotal, int maxPerRoute){
        // ...
        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(socketFactoryRegistry);
        connectionManager.setMaxTotal(maxTotal);
        connectionManager.setDefaultMaxPerRoute(maxPerRoute);
        //  ...
        return HttpClients.custom().setConnectionManager(connectionManager).build();
    }
}


但上述短连接问题和 SSL Session 泄露并没有直接关系。当时翻看了一些论坛文章,都提到 SSLSessionContext 会默认设置 cacheLimit(20480)和 timeout(24 hours),翻看本地源码发现确实如此。考虑 JDK 版本差异原因,还特地看了线上环境是 OpenJDK 1.8.0_172,而本地 IDEA 内置是 Amazon Corretto 1.8.0_291。考虑 sun.security.util 这么基础的 jar,应该没有什么差别,遂找其他可能疑似原因。

实在想不出其他原因,通过 Arthas jad 查看类文件,发现和本地真不一样。通过查看实例参数的方式 vmtool --action getInstances --className sun.security.ssl.SSLSessionContextImpl --limit 1 -x 1,也确认未对 Cache 大小进行限制,这也解释了为什么会一直上涨。

生产部署环境

cacheLimit = 0

本地 IDEA

这里虽然解释未对大小进行限制,但不有默认一天过期时间限制吗?按理应该过期清理,最终也不至于内存泄露上涨,除非业务本身有上涨。从入口流量看,并未有明显变化,但堆老年代内存确实每天有缓慢上涨。


再仔细看代码,maxSize > 0 时,才会执行后续 expungeExpiredEntries(),真是大聪明(狗头)。显然这里有些奇怪,后来翻了组内另外一个 JDK 11 实现,发现 put 实现没有变化,但是 cacheLimit 默认值已设为 20480,而不再是 0。


解法办法有很多,比如显现设置 maxSize 大小,至少能够正常 LRU,但不是最优,因为每次短连接都会重新进行 TLS 握手,其缓存 key 也就是 Session ID 一直在变,缓存实际没有意义。最好的方式就是在初始化 HTTPClient 线程池时,启用 Socket KeepAlive 具体代码如:


connectionManager.setDefaultSocketConfig(SocketConfig.custom().setSoKeepAlive(true).build())。在生产网内部,其连接 5 元组(源和目的 IP 和端口,TCP)信息相对稳定,正常情况下,该缓存大小非常有限。

Session Cache 本身有设置默认大小以及过期时间,并按 LRU 策略淘汰多余缓存。

// 且在put时若缓存大小超过阈值或者已超时,会LRU
classMemoryCache<K,V> extends Cache<K,V> {
    public synchronized voidput(K key, V value){
        emptyQueue();
        long expirationTime = (lifetime == 0) ? 0 :
                                        System.currentTimeMillis() + lifetime;
        if (expirationTime < nextExpirationTime) {
            nextExpirationTime = expirationTime;
        }
        CacheEntry<K,V> newEntry = newEntry(key, value, expirationTime, queue);
        CacheEntry<K,V> oldEntry = cacheMap.put(key, newEntry);
        if (oldEntry != null) {
            oldEntry.invalidate();
            return;
        }
        if (maxSize > 0 && cacheMap.size() > maxSize) {
            // 满足 maxSize > 0 条件,才有会触发清理过期缓存逻辑
            expungeExpiredEntries();
            // 大小超过maxSize,按LRU进行删除
            if (cacheMap.size() > maxSize) { // still too large?
                Iterator<CacheEntry<K,V>> t = cacheMap.values().iterator();
                CacheEntry<K,V> lruEntry = t.next();
                if (DEBUG) {
                    System.out.println("** Overflow removal "
                        + lruEntry.getKey() + " | " + lruEntry.getValue());
                }
                t.remove();
                lruEntry.invalidate();
            }
        }
    }

以上,也只解释第一个问题即 FGC 耗时,那么 HSF 线程池为什么又会飙涨且持续时间长达 5 分钟?


若是 STW 一共也才 1.27 秒,即便完整 FGC 耗时也才 9 秒(16:24:20.923 - 16:24:11.945)。无论怎么看,都不应该影响如此之久。再次回看当时 HSF 相关指标监控,从成功率上看,实际影响时间段为(24 ~ 25 min),考虑前面 16:24:20.923 时 FGC 就已完全结束。


事后扒代码,找到了原因,和 HSF 线程池机制有关。但这里也有一个比较奇怪的点(当然,如果再想想也可以理解,毕竟 HSF 属于基础中间件,基础层的配置需要足够通用),为什么 HSF 线程池队列默认是 SynchronousQueue,这样但凡服务一抖动,立马线程池就会耗尽,而且默认值 720 还很大,这样使得如果应用还有非 HSF,比如 HTTP API 对外提供服务,因为线程池设置过大,占住系统资源,也有可能会让 HTTP 线程池运行收到影响。


JVM 参数调优调的是什么

不知道你是否和我有一样的疑惑,大家常说的精通 JVM 调优,但是你如果留意启动脚本中关于 GC 参数配置后,会发现参数数量很少,尤其在 JDK 11 后,默认启用 G1 之后,能调整的参数更更是屈指可数。


默认启动参数参考 setenv.sh 中所示,JDK 版本大于等于 11 默认启用 G1,否则继续沿用 CMS。比如如下配置:


图片来自:https://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf

  • 年轻代采用 Parallel Scavenge(标记复制)算法,多线程并行,为了影响运行中业务,会将年轻代分为 Eden 和 Survivor 区,而其中 Survivor 又可以进一步分为 From 和 To 两个区域(SurvivorRatio=10,表示 Eden:Survivor From:Survivor To = 10:1:1),From 和 To 在任一时刻,有一般是空闲的。每次对象在 Eden 区生成,在 YGC 后若还存在,则进入 Eden From 区,如此经过几次 YGC 依然存活,则进入老年代;


  • 老年代采用 CMS(Concurrent Mark Sweep)算法,不像年轻代可以利用复制的方式搬迁内存,从而避免因标记产生的 STW,所以将标记过程拆成了3 次,清理过程拆成了 2 次;


  • CMS Initial Mark(STW)是单线程标记,因为只从 ROOT 标记一层,所以一般时间很快,很少出现性能问题;


  • 在 CMS Final Remark(STW)前,增加 CMS-concurrent-mark 阶段,利用并发(和业务线程并发)的特性,进一步沿着上述引用链进一步标记,并将改变过的区块,标记为 Dirty;


  • 为了尽可能回收多的内存,一般会在 CMS Final Remark 在触发一次 YGC,不过会在规定时间内(CMSMaxAbortablePrecleanTime=5000)完成;


  • 为了降低最后清理量级,会在并发标记结束后,提前进行一次回收;

有了 CMS 的基础,再来理解 G1 和 ZGC 会变得容易。从日常现象中,不难发现,CMS 有几个比较大的风险是 STW 时间不可控,GC 期间非常占用 CPU 资源,以及容易产生内存碎片,尤其在一些低配机器上。这里也能看出,技术的发展朝着更精细模块化发展,其实有些逆通用思路。其实写到这里,已经能看出,GC 算法本身的演变方向就是越来越精细化,对业务侧影响更小,配置参数也更少。

图片来自:https://www.oracle.com/technetwork/tutorials/tutorials-1876574.html

G1 通过 Write Barrier 与 Remembered Set 精确追踪跨区引用,使得局部回收成为可能,但对象移动与标记阶段仍需停顿。ZGC 则在此基础上,进一步实现了“几乎完全并发”的垃圾回收。

  • 整个堆被划分为上百个大小相等的 Region。每个 Region 可能是 Eden、Survivor 或 Old Generation;
  • 当 G1 决定回收一小部分 Region(比如所有的 Eden Region + 几个 Old Region,这被称为 Collection Set)时,它必须知道有没有其他 Region(尤其是 Old Region)里的对象,正引用着 Collection Set 里即将被回收的对象;
  • Remembered Set (RSet)
  • 每个 Region 都有一个私有的数据结构,叫做 Remembered Set (RSet);
  • RSet 记录了 “谁引用了我”,具体来说是 Region A 的 RSet 记录了其他Region中哪些卡表页包含指向Region A的引用;
  • RSet - Write Barrier
  • JVM 会在 JIT 编译时,在每一条对象引用赋值的指令(比如 myObject.field = anotherObject;)后面,悄悄地加上一小段额外的代码,作用是当 myObject 和 anotherObject 不在同一个 Region 时,它就会被触发更新 anotherObject 所在 Region 的 RSet;

ZGC 通过 Colored Pointers + Load Barrier,几乎完全并发的 GC,将 STW 时间压缩到亚毫秒级,同样是基于 Region(在 ZGC 中称为 ZPage),但大小是动态的。

  • Colored Pointers (着色指针);
  • ZGC 利用了 64位操作系统中指针地址实际上用不满 64 位的特点。它将指针的高位挪用出来,存储一些元数据(颜色),这些颜色代表该指针所指向的对象状态;
  • Load Barrier
  • G1 的 Write Barrier 是在写时做手脚,而 ZGC 的 Load Barrier 就是在读时做手脚,过程类似;


什么情况下 CMS 更优

参考下面一段 Oracle 官网(https://www.oracle.com/java/technologies/javase/hotspot-garbage-collection.html)上,关于是否要升级 CMS 至 G1 观点。

The first focus of G1 is to provide a solution for users running applications that require large heaps with limited GC latency. This means heap sizes of around 6GB or larger, and stable and predictable pause time below 0.5 seconds.


Applications running today with either the CMS or the ParallelOld garbage collector would benefit switching to G1 if the application has one or more of the following traits.

  • More than 50% of the Java heap is occupied with live data.
  • The rate of object allocation rate or promotion varies significantly.
  • Undesired long garbage collection or compaction pauses (longer than 0.5 to 1 second)


看下面单机(4C8G)相关监控数据,你能得出什么结论?从 CMS 选择升级至 G1 还是不升?


1. 流量规律,峰值在上午 10 点左右,CPU 利用率 27%(CPU Share 模式,正常情况下不缩量,即用量为 4C),Load 1 和 Load 5 均为 1 左右,内存使用率稳定在 60% 左右,HSF QPS 为 2000(其中 Provider 1200 RT,平均 13ms,而 Consumer 800,平均 RT 8ms);


2. 同时 10 点左右,每分钟 YGC 4次(加一起耗时 60ms 左右),连续一周以上无 FGC;

3. VM 相关参数

a. SurvivorRatio=10

b. MaxDirectMemorySize=1g

c. Xms4g -Xmx4g -Xmn2g

d. MaxMetaspaceSize=512m

从上述配置和监控指标,可以分析得到下面结论:

1. 峰值时段每分钟 4 次 YGC,总耗时 60ms(平均 15ms/次),说明年轻代 GC 压力小;

a. Eden 区大小 = 2GB × (10/12) ≈ 1.67GB;

b. 每分钟填满 4 次,分配速率 1.67GB × 4 / 60s = 111MB/s,分配速率相对较高(正常区间 50-200MB/s);

c. 平均每次 15ms,高频但延迟低,间接说明对象存活极少,YGC 压力小;


2. 连续一周以上无 FGC,在业务 2000 QPS 背景下,说明晋升率极低,几乎没有对象进入到老年代。


这简直就是 CMS 样本间(高分配率 + 极短存活),CMS 主要缺点包括内存碎片、并发模式失败(Concurrent Mode Failure)以及不可预测的长停顿这些问题,这里通通没有体现,因此继续使用 CMS 是更优选项。


但是,如果是因为 JDK 要从 8 升级至 11,使得 CMS 也间接升级至 G1,这反而要引起注意。


结论:最终还是建议升级,但是要稍微改改默认配置。

# 基础内存设置(保持原堆大小)
-Xms4g -Xmx4g

# 核心:提高年轻代占比(应对高分配率)
-XX:G1NewSizePercent=40      # 年轻代初始占比 40%(约1.6G)
-XX:G1MaxNewSizePercent=50   # 年轻代最大占比 50%(约2G)

# 暂停时间目标(匹配当前15ms YGC)
-XX:MaxGCPauseMillis=15

# 优化大对象处理(预防Humongous分配)
-XX:G1HeapRegionSize=4m      # 4MB Region(默认1-32MB)

# 关闭冗余特性(节省资源)
-XX:-G1UseAdaptiveIHOP       # 禁用老年代阈值自适应
-XX:InitiatingHeapOccupancyPercent=70 # 固定老年代阈值


八、火焰图

看火焰图时的几个基本规则

  • Y 轴代表调用栈深度
  • 下面是父函数,上面是子函数,最底下矩形是程序入口,顺着 Y 轴从下往上看,就能完整地看到一个函数的调用链;
  • X 轴代表 CPU 采用占用时间
  • 宽度代表 CPU 时间。一个矩形的宽度越宽,说明它在 CPU 上运行的时间越长,或者说被采样到的次数越多,但不一定代表消耗时间越多(IO 不占用 CPU);
  • 颜色没有特殊含义
  • 默认情况下,颜色通常是随机的,只是为了区分不同的函数矩形;
  • 水平顺序(从左到右)没有意义
  • 火焰图会将函数名按字母顺序排序,所以两个相邻的矩形,不代表它们在时间上有先后调用关系;

宽平顶(热点函数)

尖塔(深层调用)

一般看火焰图的过程:

  • 看全局,不要一上来就扎进细节,先看整体轮廓,看有没有平顶或者细长塔
  • 平顶,如果顶部有很宽、很平的矩形,说明 CPU 时间主要消耗在这个函数本身计算上;
  • 尖塔 ,如果顶部呈现一个或多个高耸的尖塔,说明 CPU 时间主要消耗在调用链的深处;
  • 一个主山峰,说明系统大部分时间在处理同一个核心任务;
  • 多个相似山峰,可能表示系统在并行处理多个相似请求,或者有线程竞争;
  • 找最宽的矩阵
  • 寻找最宽的那个矩形,宽度代表在 CPU 上时间消耗比例,看看是什么复杂的计算,比如序列化/反序列化、正则表达式、低效循环等;
  • 横向和纵向对照业务代码找差异
  • 比如 A 调用 B,但火焰图上显示 C 调用 B,并且很宽,说明可能暴露存在不熟悉代码或者框架行为,或者以为 A 方法耗时要远低于 B,但从火焰图上看,实则相反;
  • 业务代码可能很窄,但它下面的框架代码非常宽,说明可能误用框架或配置错误;


perf

# -F 指定采用频率 -a 监控所有CPU -g 记录调用栈 -o 指定输出文件名 -- sleep 持续采用60秒
sudo perf record -F 99 -ag -o flame_analysis.data -- sleep 60

# 将二进制内存地址映射成函数符号,比如将 0x7f12345678 映射为 native_safe_halt
sudo perf script -i flame_analysis.data > flame_analysis.perf

# 在电脑上下载 FlameGraph,然后 cd FlameGraph 目录下
git clone https://github.com/brendangregg/FlameGraph.git

# 聚合数据
./FlameGraph/stackcollapse-perf.pl ~/Downloads/flame_analysis.perf > ~/Documents/tool/tmp/flame_analysis.folded

# 生成 svg 文件,然后通过浏览器打开
./flamegraph.pl out.folded > system_flame.svg


Java 火焰图


为什么 perf 火焰图看不到 Java 调用栈?


Java 运行过程并不像其他 AOT 编译语言比如 C++,有一个显现编译过程(或者更准确说法是,没有一个显现编译成可执行机器码的过程,javac 解释执行之后,得到中间产物 class 文件,真正编译成机器码是由 JIT 完成,等于是解释器 + JIT 模式),在 make 之后产生 ELF 文件,其中就包括符号映射表,而 perf 采样得到内存地址,这种情况下,它会通过映射表反查符号,从而得到具有可读性的火焰图。


Java 是通过 JIT,在运行阶段编译,满足一定条件后保存在 CodeCache 中(联想大促前的 JIT 预热)。这种情况下,perf 就无法反向映射回符号,所以看到的往往是二进制地址,除少量 JVM 相关调用(本质 JVM 是 C++ 编写)可以看到符号,如 libjvm.so。


perf-map-agent 作为切在 JIT 编译过程的临时进程,注册 JIT 编译事件回调,记录得到符号映射表。async-profiler 是更高一层封装,可以更方便地得到 Java 进程内部 CPU 火焰图,而 Arthas 火焰图相关能力是基于 async-profiler 实现。下面截图均是利用 Arthas 工具完成。

火焰图有一个容易被忽视分析问题的角度,它虽然不会直接显示每个方法执行耗时,但是可以通过相对宽度,估算大概耗时,比如下面的方法内部,会执行上面 N 子调用,通过宽度,就可以知道不同方法执行大概耗时。如果其中 A 接口正常是 5ms,另一个 B 接口宽度是 A 的 2 倍,就可以简单得到 B 接口耗时 10ms,这对于建立整体性能模型非常有帮助。


但上述也仅是能大概估算,如果仔细想想,就能分析出一些例外。火焰图默认采样是 On-CPU 时间,如果是 IO 操作,其实是 Off-CPU。这种情况下,即便宽度很窄,也不能直接得出不耗时结论。此时,可以借助 wall(Wall-clock Time)挂钟时间模式,实际耗费时间,除 CPU 实际占用,还包括各种等待时间(本身不占 CPU 分片,处于休眠或等待状态,但是会影响最终耗时),比如网络 I/O、磁盘 I/O、同步阻塞(synchronized)等耗时。


如下面两张图所示,如果仅看左边这张图,会得到箭头所指方法很窄,谈不上什么宽顶,但如果结合右边图一起看,就会推翻前面结论。所以日常使用时,要明确排查问题是 CPU 利用率高,还是耗时长。

CPU 模式

Wall 模式


九、网络拨测,弱网影响和合规


常用拨测工具

常见网络质量分析工具

  • ping
  • ping github.com;
  • 基于 ICMP 协议,测试与目标的连通性、端到端 RTT 和丢包率;
  • traceroute(tracert on Windows)
  • traceroute github.com;
  • ping 升级版,默认基于 UDP(-I 基于 ICMP,-T 基于 TCP),探测并显示数据包从源到目标所经过的完整路由路径;
  • 利用目的操作系统处理逻辑(默认对大端口进行探测,若目的地没有进程监听在该端口,会返回 ICMP Port Unreachable),识别已到达目的地。同理,端口扫描也是利用这个特性绘制网络拓扑,因此一些防火墙会静默丢弃这些报文。不过意义不大,还可以使用 -T 指定 TCP 探测;
  • mtr
  • mtr -r -c 100 github.com > github_report.txt,-r 输出到文件,-c 探测 100 次;
  • traceroute 升级版,默认基于 UDP(-T 基于 TCP)持续更新从源到目标所经过的每一跳的网络质量;
  • 识别目的的逻辑同上;
  • iperf3
  • 服务端 iperf3 -s,客户端 iperf3 -c 192.168.1.100 -P 8,其中 -P 8 表示使用 8 个并行 TCP 连接进行测试;
  • 基于传输层(TCP/UDP)视角测试网络性能,分析两台机器间网络的带宽和吞吐量;
  • 原先决定在办公网部署 WireGuard时,专门做过性能测试,在一台 4C8G ECS 上,跑满 10Gb 带宽,CPU 利用率 70%,WireGuard 内存占用 400MB。这也从侧面论证,如果为 VPN 场景定制机器,根本不需要高配;
  • curl
  • curl -o /dev/null -w "\nDNS解析: "%{time_namelookup}" \nTCP建连: "%{time_connect}" \nTLS握手: "%{time_appconnect}" \n传输首字节: "%{time_starttransfer}" \n总耗时(秒): "%{time_total}" \nHTTP状态码: "%{http_code}" \n下载数据大小(字节): "%{size_download}" \n下载速度(字节/秒): "%{speed_download}"\n" 'https://github.com';
  • 应用层(HTTPS)视角拆分耗时,类似浏览器 Timing 功能;
0                   1                   2                   3
01234567890123456789012345678901
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     Type      |     Code      |          Checksum             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                      Message Body( varies )                  |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

IMCP 协议中和探测场景相关 Type

  • Type 8 (Echo Request) 和 Type 0 (Echo Reply)

Echo Request 作为探测包,通过控制 TTL 来探测路径上的每一跳。当探测包到达最终目的地时,收到的 Echo Reply 就标志着探测成功结束;

  • Type 11 (Time Exceeded)

路由器发现 IP 报文的 TTL 为 0 时会丢弃,同时会向源主机发送一个 ICMP Time Exceeded,traceroute 正是基于这个特性,识别访问路径上的每一跳;

  • Type 3 (Destination Unreachable) + Code 3 (Port Unreachable)
  • traceroute 默认基于 UDP 进行探测时(同理 mtr),并用一个极大概率未被使用的端口向目标发起探测。当这个包到达目标主机时,主机的操作系统发现该端口没有程序监听,就会返回一个 ICMP Port Unreachable 消息,探测发起方收到这个消息,就知道已经到达目的地,不需要再继续增加 TTL;

mtr 不同列含义

  • Host
  • 路径上每一跳的 IP 地址或主机名。
  • Loss%
  • 丢包率,在这一跳丢失的数据包百分比。这是判断网络问题最直接的指标。
  • Snt
  • 已发送,已经发送的数据包数量。
  • Last、Avg、Best、Wrst
  • 不同场景延迟,分别是最后一个包、平均、最好、最差的延迟时间,单位毫秒。
  • StDev
  • 标准差,数值越高,说明网络抖动严重。


连接数指标异常

理解前面知识点后,再回来看一些基于容器内应用的网络相关指标,就会特别容易,比如下面这个某天应用连接数出现极速上涨和下跌的问题,同期 CPU 和内存并没有显著变化。

通过 ss -pn | wc -l 确认 Sunfire 监控口径。

-t 显示 TCP 连接-p 显示进程信息-n 不解析服务名,直接显示端口号

  • Netid 网络类型
  • u_ 前缀,比如这里 u_str 表示 Unix Stream,机器上本地进程间 IPC 通信;
  • tcp/udp 即常规的网络连接;
  • State 连接状态
  • ESTAB 连接状态,常见状态还包括 LISTEN(正在监听连接)和 CONN(已连接,但尚未完全建立);
  • Recv-Q 接收队列(Receive Queue)
  • 表示有多少字节的数据已经从对方接收,但尚未被本地进程读取。如果该值持续不为零,可能意味着应用程序处理不过来或者被阻塞;
  • Send-Q 发送队列(Send Queue)
  • 表示本地进程已经发出,但尚未被对方确认接收的数据字节数。如果该值持续不为零,可能意味着对方进程没有及时读取数据,导致发送缓冲区被占满;
  • Local Address 本地地址
  • 192168.0.2:5472,通常意义上的 IP 和端口;
  • * -1580553750,* 表示匿名还未绑定文件路径的 Socket,正常情况下指本地 Unix 域 Socket;
  • Peer Address 对端地址,解释同上;

容器上运行很多进程,通过端口间接定位哪个进程占用大量连接,看到这里基本已经能够定位到进程,比如这里 TOP 1 进程为大量终端提供 KeepAlive 探测服务。这里的极速下跌和上涨和 Tengine 本身 Reload 机制有关系,Reload 后连接并不会被立即释放,而要等一段时间后逐步释放。

ss -pn | awk '{print $5}' | cut -d: -f2 | sort | uniq -c | sort -nr | head -n 10

awk 默认按空格分隔(等同 -F " "),打印第 5 列cut -d: -f1 切割字符串,指定分隔符为冒号,取切割后的第 2 部分sort uniq 将所有的 IP 地址进行排序,后面去重的前提uniq -c 去除重复行,-c 标识统计该行出现次数sort -nr 进行降序排序,-n 表示按数值排序,-r 表示降序排序head -n 10 只保留排序后的前 10 行

而下面的命令则侧面说明得益于生产网 VIP NAT 功能,即便是最多的对端 IP 也才占用本地 32 个端口,相对比 65535,5 元组标识的连接本身端口还没有风险。

从 TCP 协议层面看,四元组(源IP、源端口、目标IP、目标端口)在 reload 过程中没有发生任何变化,因此连接本身是稳定的。结合本地命令行执行结果,以及监控图形上显示,这里推论 Sunfire 监控关联了进程。


当执行 nginx -s reload 时,Master 进程接受到 SIGHUP 信号,然后检查新配置文件后启动一组新的 Worker 进程。这时旧的 Worker 进程仍在处理已建立的连接,而新的 Worker 进程开始接受新的客户端连接,直到连接处理完或者时间到达 worker_shutdown_timeout(主要面向一些一些文件上传下载场景,否则会一直占着)后旧的连接会退出。


弱网影响没有想象中大

每次在家附近解锁小红车时,都会发现杭州市民卡 APP 页面加载极其慢,多数时候解锁没有反应,加上 iPhone 信号只有两格,习惯性把锅扣给苹果,暗骂一句垃圾。直到有一次发现微信小程序叮达,能很快解锁,说明信号差和网络无法访问不直接相等,一下子激起了好奇心。


相比于有线网络,无线网络的传输质量相对是不稳定的,受到很多因素影响,比如信号强度、电磁干扰等。现在绝大部分终端,尤其是 C 端场景终端,都运行是无线网络下。服务端优化再完美,如果用户不能稳定接入,效果都不会太好。但这里面有个难点,就是终端网络环境由终端设备以及运营商把控,作为开发者,似乎什么都做不了。但正如上述现象所示,再想想,似乎也不完全对。


比如在同一台手机上,一些 APP 使用很流畅,而一些则表现得很卡。当然不排除其中有些通过事先缓存,或交互设计的方式“隐藏”网络异常,不过从实时数据上看,也能明显发现一些 IM 类 APP 通信质量,要明显好其他 APP。


先回到无线通信领域,有两个核心指标衡量通信质量,分别是 RSRP(Reference Signal Received Power,参考信号接收功率)和 BER(Bit Error Rate,误码率)

  • RSRP 衡量信号强度的一个指标,单位是 dBm,值越大(负得越少),表示信号越强。手机会持续测量多个邻近基站的 RSRP,当另一个基站的 RSRP 比当前服务的基站更强时,网络可能会指挥手机切换到信号更好的那个基站,以保证通话或数据业务的连续性;
  • BER 衡量传输比特流准确性的一个指标,表示在传输过程中,发生错误的比特数占总传输比特数的比率。反映物理媒介(如空气、铜线、光纤)的传输质量,而干扰、衰减、失真都会导致误码率升高;
/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -I

     agrCtlRSSI: -62
     agrExtRSSI: 0
    agrCtlNoise: -100
    agrExtNoise: 0
          state: running
        op mode: station
     lastTxRate: 286
        maxRate: 229
lastAssocStatus: 0
    802.11 auth: open
      link auth: wpa2
          BSSID:
           SSID: home
            MCS: 11
  guardInterval: 800
            NSS: 2
        channel: 36

之前一直以为只有 TCP 会重传,而其他层以及 UDP(交由应用层负责完整性,比如 HTTP/3)只会一个劲儿发包,还是远远低估了实际通信场景复杂度。

  • 物理层将比特流转换成无线电波发出去,或者把接收到的无线电波还原成比特流,通过 FEC (前向纠错) 技术,在发送的数据中加入一些冗余校验比特,接收端可以利用这些冗余信息自动检测和纠正一定数量的错误。如果错误太多,FEC 也无法纠正,这时就将异常抛给上传数据链路层;
  • 数据链路层 RLC/HARQ 为每个帧分配一个序列号,如果运行在确认默认下,接收方(如基站)每收到一个正确的 RLC 帧,必须向发送方(如手机)回送一个 ACK (确认)。如果发送方一段时间内没收到某个帧的 ACK,或者收到一个 NACK (否定确认),它就认为这个帧在“空中接口”这段链路上丢失了,然后会主动重传这个帧。这里有点类似 TCP 重传机制,不过不如 TCP 执行得这么彻底,另外这段具体也相对短,重传延时很低,效果很好;
  • 网络层报文中虽有 Header checksum,但也仅是校验数据包首部,而且即便错误,也不是自动重传,而依赖传输层识别;
  • TCP 重传是最后保障,如果前面无线链路层重传都失败,或者在后续路由转发中丢失,就需要 TCP 实现端到端重传。如果在弱网环境,大量丢包穿透到 TCP,还会进一步导致 TCP 频繁触发慢启动和超时重传;

无线信号辐射范围相对有限,间接说明信号从终端发出后,很快就到达基站,而基站后面的骨干网络就相对稳定的多。也就不难理解,是即便处于弱网环境下,因为底层也有检测和重传机制,而且还是就近重传,会发现页面刷新好像也没什么太大差别。


现在想想 TCP 队头阻塞是个非常有时代烙印的设计,我想设计之初应该没有考虑过 IPv4 下的 IP 资源后来会变成宝贵资源,同目的 IP 以及端口的链接上会承载 N 多业务,联想内部各类统一接入层(何尝不试一种 Anycast),以及以 Cloudflare 为代表的边缘接入。若从这个角度上看,这也是一种网络资源共享的实现,但若其中一个 TCP Segment 丢失,会导致后续所有包(即使已经到达)都被阻塞在接收缓冲区,等待重传。若从这个角度看,IPv6 也许也是优势,网络连接本身标识不再受限。


十、网络抓包 TCPdump、Wireshark 和 NetLog Viewer

正常抓包并不会影响当前流量转发,本质是从内核复制了一份流量至用户态抓包进程,不过因为涉及内存复制,会消耗 CPU,在一些网络设备上要尤为小心。Wireshark 和 tcpdump 的抓包原理,没有本质区别,都是借助系统提供的扩展能力,在类 UNIX 系统(Linux 和 macOS)上是 libpcap 库,而在 Windows 上是基于 Npcap 库(或之前的 WinPcap)。以远程入网场景为例,不同设备以及网卡上抓包差异:

  • 若以终端上 VPN utun 虚拟网卡为例,是在上述内核模块上,获取流经这个虚拟接口的 IP 包并复制一份给用户态抓包程序,不涉及混杂模式(工作在数据链路层,忽略目的 MAC 地址为本地网卡 MAC 地址校验)设置;
  • 若以中间 VPN Server 物理网卡为例,也是在上述内核模块上,获取流经该物理网卡的全部流量并复制一份给用户态抓包程序,这里就将物理网卡设置为混杂模式。不过实际上,这里的混杂模式并没有太多意义,因为正常情况下,前面交换机只会将需要发往该设备流量转发过来;
  • 结合前面分析,不难发现,若对网络设备上的物理网卡进行抓包,就是常说的流量嗅探,能够分析全部流经流量,这也侧面说明,为什么物理以及前向安全是通信安全基本前提;

从这个意义上看,Wireshark 只是在流量效果呈现上更加方便,会标出 RST、Retransmission 等异常,跟踪 TCP 流,支持不同格式条件过滤等,另外还提供更便捷的 TLS 卸载能力,比如在本地安装 MITM 证书或者利用预主密钥(Pre-Master Secret)日志卸载 TLS 流量。


TCPdump 是一个被低估的工具

很多人将 tcpdump 作为受限情况下 Wireshark 的平替,一般通过 tcpdump 抓包,然后再通过 OSS 将文件下载至本地,最后利用 Wireshark 进行分析。实际上它的作用被严重低估,很多时候可以在生产服务器上直接对比 tcpdump 和应用层日志,加速排查一些诡异问题。


比如某天 IT 反馈部署在伦敦的几台设备认证耗时稳定在 500ms 左右(正常应该在 250 ms 左右,显然多了一个 RTT),但又因非常稳定,一度怀疑是线路问题。这里也能体现拥有性能直观感受的重要性,要先能识别出 500ms 不正常,其次判断出这里是刚好是多了一个 RTT,如果能联想到这些点,下面排查效率会提高很多。

# 在物理网卡上对IP地址为*.*.*.188或*.*.*.192且非ICMP的报文抓10000个包
tcpdump -ni eth0 -nn '(host *.*.*.188 or host *.*.*.192) and not icmp' -c 10000
# -p 表示不启用混杂模式,在本例中没有区别,如前面所说,一般在中间网络设备比如交换机上才有意义
tcpdump -pni eth0 -nn '(host *.*.*.188 or host *.*.*.192) and not icmp' -c 10000
#  -z 表示只进行TCP握手,而不发送实际数据,最后将探测结果写至本地文件
nohup nc -v -z -w1 ${HOSTNAME} 8080  > ${LOG_TEMP_PATH}/temp 2>&1 &

如果同时将应用日志、应用以及 TCP/IP 抓包窗口同时打开:

  • 定期进行 TCP 三次握手,但随后进行断开,即没有正常应用 Payload 发送,这种情况要么是 DDoS 攻击(考虑量级以及部署在内部,可以排除这种可能性),要么就是 TCP 健康检测。这种情况翻看相关配置文件,很快就找到这行脚本 nohup nc -v -z -w1 ${HOSTNAME} 8080 >${LOG_TEMP_PATH}/temp 2>&1 &,通过 -z 表示只进行 TCP 握手,而不发送实际数据并将结果写至本地文件,另外通过在系统上注册定时任务触发。结合右上角应用日志记录时间,可以确认并不相关,仅是后台监控检查任务触发的请求;
  • 偶尔一次 TCP 三次握手握手后,继续有 HTTP 报文发送,结合右上和右下报文看,这种就是正常业务报文,到这里问题基本就很明显,明明应该是连接复用,为什么每次都需要重新建连;
  • 再去翻看连接池配置 connections = 32 并且 timeout = 2,似乎问题已经明确,该设备刚上线处于灰度中,实际承载的业务流量非常少,这里并没有类似 Java 线程池中 Core Size 概念,不会保活一些连接。因为请求稀疏,导致每次都需要重新建连,且结束后再断开。这种情况其实在国内一些小型办公点也已存在,只是原先在国内但请求增加 10ms ~ 50ms 并不容易被发现,但是在国外这种请求路由回张北情况下就被注意看到了;

回到上述 RT 过长问题排查过程中,将多个怀疑点同时打开,结合网络报文能够加速问题排查进度。历史上有一次,甚至从终端、AP、VPN 服务器、统一接入层以及后端服务容器上同时抓包以及输出应用层日志,也很快发现定位了问题所在。


在 Wireshark 上直接卸载 TLS

利用预主密钥(Pre-Master Secret)日志,在 Wireshark 上直接卸载 TLS

  • 客户端生成 48 字节随机数,用服务器公钥加密后发送;
  • 用于与服务器随机数共同生成主密钥(Master Secret);
  • 拥有 Pre-Master Secret 即可推导所有会话密钥;

设置环境变量 export SSLKEYLOGFILE=~/Documents/keylog/sslkeylog.log,然后在 Wireshark Appearance/Protocols/TLS 页面进行设置,然后通过命令行唤起 open -na "Google Chrome" --args --new-window(--args 可以不加,是为新开一个窗口) 就可以了,这里有个潜在要求是应用本身需要支持输出 sshkey,常见的 Fixfox 也是支持的。

TLS 卸载前,Payload 部分加密。

TLS 卸载后,可以在 Wireshark 中直接查看 HTTP 内容,可以借助借助过滤功能(上方红框)轻松实现流量过滤。Wireshark 过滤能力非常好用,除了按 String 格式,还可以直接基于 16 进制编码或者书写正则表达式。


通过 NetLog Viewer 发现更广阔世界

Wireshark 是基于内核流量复制,其性能好坏不能直接代表应用层性能好坏,这中间还隔着一层本地处理,这种情况下,基于应用层实施抓包就变得尤为重要,我日常用的比较多的就是 Chrome 官方提供的 NetLog Viewer。

在浏览器输入栏直接输 chrome://net-export/ 出现下面页面,然后可以选择开始(是否需要选择抓取 Cookie,看实际场景,一般不需要,有时需要将抓包后的文件发给其他人看,如果带了 Cookie 就比较尴尬,理论上他可以盗用身份直接发起请求),然后再通过 https://netlog-viewer.appspot.com/#import 导入后就可以分析。NetLog Viewer 和 Wireshark 一样,日常一些使用方法以及技巧有非常多文章介绍,这里不做进一步展开。

比如前段时间,有位用户反馈她访问某网站不稳定,截取当时的一些抓包数据,稍简单分析,就可以发现很多待优化项,若一一挖掘,会有不小收获。

  • 浏览器创建连接耗时 38ms,但 DNS 解析却耗时 30ms(间接说明 TCP + TLS 握手 8ms,非常快),其中还有 5ms 硬等 timeout 时间;
  • TTF 耗时 1514ms,结合上面 TCP + TLS 握手开销,这段显然过大,需要优先排除 RS 问题,进一步看统一接入层(或者应用网关)以及后端应用耗时;
  • 读取响应体波动明显;
  • 波动举例,比如
  • 第2次:16.3KB 耗时 53ms;
  • 第5次:16.3KB 耗时 3ms,相同数据大小,但耗时确相差 17 倍;
  • 这种情况需要进一步确认是网络问题,还是本地处理耗时问题;
  • 网络问题,又可以进一步细分;

1. 网络传输问题

2. 服务提供方问题

  • 本地应用层处理问题,即从内核缓冲区拷贝到用户态缓冲区时,由于某些原因,如 CPU 繁忙、进程调度、锁竞争、内存分配延迟等,而导致读取操作耗时波动。
  • URL_REQUEST
  • HTTP_STREAM_REQUEST 启动 HTTP 流请求;
  • HTTP_STREAM_JOB;
  • SSL_CONNECT_JOB_CONNECT [dt 46];
  • TRANSPORT_CONNECT_JOB_CONNECT [dt=38] TCP/SSL 握手耗时,但是 DNS 却占了 30ms;
  • HOST_RESOLVER_MANAGER_REQUEST [dt=30] ;
  • HOST_RESOLVER_DNS_TASK_EXTRACTION_RESULTS [dt=25] A 记录;
  • HOST_RESOLVER_DNS_TASK_TIMEOUT [dt=30] 请求并行,结果超时,不支持 DoH,等于硬多出 5ms 等待时间;
  • HTTP_TRANSACTION_SEND_REQUEST 发送 HTTP 请求头部;
  • HTTP_TRANSACTION_READ_HEADERS [dt 1514] 等待并读取响应头,TTF(Time To First Byte)过长;
  • HTTP_TRANSACTION_READ_BODY读取响应 Body,波动有些大,要进一步结合内核网络栈抓包,排除本地用户态处理问题;
  • 第1次:[dt=1] byte_count=7562 解压前大小,下同;
  • 第2次:[dt=53] byte_count=16366;
  • 第3次:[dt=4] byte_count=10895;
  • 第4次:[dt=97] byte_count=32762;
  • 第5次:[dt=3] byte_count=16366;
  • 第6次:[dt=2] byte_count=14199;


十一、隔离是提升稳定性最根本的方法

某天生产环境一个重要数据库系统指标会周期性地飚高。后来排查下来,原因倒也简单,一个后台统计类任务会周期性同步数据到表中,但这却是一个警醒,试想如果同步数据的工具吞吐能力上升,或者待同步数据量大幅上涨,会不会拉爆整个数据库?

Leader

Follower

想通过完备的测试流程和变更规范来规避生产环境风险并不现实,代码和配置并不是系统变更的全部,因为系统运行的上下文环境在持续变化,这里包括用户终端、中间网络以及应用服务本身所处的虚拟化环境等。


时间拉长看,既然问题不可避免,那么降低问题发生后的影响就变得尤为重要。从这个角度看,稳定性的本质可以简单等同于隔离影响,要尽量避免因为个例问题而影响到全局。


若将系统分为三层,自下而上分别是资源层、中间件层和业务逻辑层,越往下资源越共享,隔离程度越低,相互影响风险大。这里有个明显的设计“鸿沟”,从资源层过渡到中间件和业务逻辑层时,资源(比如内存、CPU 和网络连接等)颁发对象是进程,而中间件和业务逻辑本身往往运行在同一进程内(比如 JVM),且容器往往也只运行一个应用,这使得很多看似有效的隔离措施,实际上并不起作用。

  • 业务逻辑层
  • 容器化后,对微服务拆分更加友好,甚至出现无服务化架构 FaaS,但是拆的过细之后,运维成本也会急剧上升;
  • 现实中,往往会参考类似 DDD 领域建模结果设计应用架构,在同一应用(1:1 Pod)上运行多个归属于同一领域的服务;
  • 同一领域的不同服务,依据业务优先级不同,也并不意味着同一保障级别,即同一应用可能会同时运行订单创建(高优先级)和订单查询(中优先级)两种服务,订单查询服务异常也有可能会影响订单创建服务的 SLA;
  • 中间件层
  • 中间件很大程度上是伴随分布式微服务架构的出现,通过抽象远程资源,比如远程调用、消息或者数据库等操作,使得远程调用,就像在本地直接通过函数调用一样方便;
  • 中间件仍然运行在用户态,并和业务逻辑同属一个进程。虽然中间件提供了一些隔离参数,比如 HSF 注解 @HSFProvider 中 corePoolSize 可以为不同服务配置独立线程池,MetaQ 中可以为不同 Topic 设置不同 consumerGroup,同样 TDDL 也支持为不同数据库甚至同一数据库,配置不同连接实例;
  • 但上述隔离效果都很有限,以 HSF 线程池为例,HSF 默认线程池大小为 720,其数量会远远高于 CPU 核数,即便为核心服务划分独立线程池,但一样可能会被一个突然爆发的调用锤爆整个应用,甚至一整条调用链;
  • 资源层
  • 本身也涉及多个层次,从最下面的物理硬件,共享 Linux 内核(比如 cgroups 和 namespaces),容器引擎(比如 Docker 和 Kubernetes),在到具体容器内 Kernel,都可以归属为资源层;
  • 一旦进入容器内部用户态,就会出现抽象泄露问题,前面所说的系统资源颁发对象是进程,Pod 或者进程间资源实现了隔离,但进程内部充分共享。更多时候,这还能立为性能优化的正面典型,充分共享,避免上下文切换时的资源损耗;


似乎这些问题无解,用有限的资源,在满足无限需求的同时,还能提供必要的鲁棒性。但这正是架构设计时,需要权衡的种种因素。架构设计的第一步,往往是先得有一个定义清晰的问题域,而得到清晰问题域的前提,是对过去的有效总结,以及对未来的有效判断,但源头业务本身就充满不确定性。


十二、性能问题优化的时机

关于性能优化的时机,尤其容器化之后,本身已存在一定程度的超卖,再去压榨 CPU 利用率本身意义不太大。硬件成本已经非常便宜(短期,显卡除外),一台 26C 服务器和一台 MacBook Pro 价格相差不大,但可以虚拟化出 15 个 4C8G60G 标准 POD,而单 POD 年硬件+能耗成本约为 (物理机 3w,折算成本每年 1w + 能耗 2.6w) / 15 = 840 元。这里并不严谨,未考虑带宽、IDC 运维以及研发等成本,不过已经能够说明一定问题。


写到这里,似乎和全文的主题有些矛盾,都说要优化性能,为什么这里就又不建议进行优化。其实更准确的说法是,成本只是众多必要条件之一,但不充分。若因为成本原因,而不是从整体架构上进行考虑,设计更稳定、更灵活的方案,只从单一资源角度出发,比如合并应用等操作,甚至会引入一些复杂度。

硬件 + 能耗成本简单折算:

  • 系统预留(操作系统本身以及 Kubernetes 等)
  • CPU 4 vCPU
  • 内存 8 GB
  • 存储 15%(2 * 960 GB * 15% = 288G)
  • 资源规划
  • CPU 15 Pods × 4 vCPU/Pod = 60 vCPU,超卖率 60 vCPU / 48 vCPU(逻辑核) = 1.25(轻度超卖)
  • 内存 (15 * 8 + 8) / 128 = 100%
  • 存储 (15 * 60 + 288) / 1920 = 62%
  • 用电能耗
  • 按 300W/h 换算,0.3KW * 24 * 365 * 1 = 2628


十三、最好的优化是预防

如果把问题带入到安全视角:

  • 不同情景下,风险的解法不一样,有时候需要治根,而有时候只需要治标,甚至还可以接受风险,什么都不做;
  • 一个确定的问题,可以找出 10 种,甚至 100 种方法解决,而这些方法本身是有内在倾向优先级,可以从两个维度上进行整理:
  • 横向是时间维度:早期设计阶段,应该通过威胁建模等方式将风险考虑进去,从源头设计上避免。开发实施阶段通过各种测试手段暴露问题。项目发布以及运维阶段,要通过变更流程以及自动化扫描工具兜住下限,也要通过持续的情报、漏扫以及入侵检测手段监控系统水位,及时拉起响应或直接阻断。而这些如果带入软件开发开发生命周期 SDLC 所要解决的问题,也是一样,只是换了一个安全视角描述,本质并没有改变;
  • 纵向是解决层次:治理、管理还是技术手段。作为一线技术研发,很容易代入技术可以解决所有问题的语境。不过在大的组织里面,很难拔高到组织治理或管理角度。但是也不妨想想,有没有可能产品上调整一个交互,或者业务上调整下规则,问题本身就消失了;


  • 更多时候,我们看到的是现象,比如 CPU 飙涨,但这并不是问题。把具体现象,通过抽象,定义成问题,这才是重要的。进行问题抽象时,所处语境不同,问题定义甚至千差万别。实事求是这个词描述的是现象,是观点,而不是问题或结论。


安全视角和工程上性能优化,架构设计是相通的,前提都是需要先构建全局视角,然后再进行取舍。写代码是简单的,实现一个系统是复杂的。把现象变成问题,再从问题推导出行动,每一步都是一大步。


写到这,如果是去年,就可以结尾了,但放到今年,在各种拥抱 AI 热情背景下,很难不被影响。如果参加一些类似云栖或者 QCon 这类会议,会发现,没有公司再在强调为什么要拥抱 AI,而在拼了命的展示,自己企业怎么在 AI 上进行投入,以及获得了怎样的效果。


下面只是一些观点,不代表是对的,也许大部分是错的。


十四、AI 来了,这些知识总结还需要吗

写作过程中,发现无论涉及什么知识点,只要描述准确,AI 也能回答得很好。有时甚至还会产生一些跨学科的联想,时常让我一愣半晌。一度不想继续往下写,内心深处觉得这些知识点,终会被更高维度技术所取代。


就像过去一年深有感触的一个例子一样:很多人都知道 UDP 好,丢掉了 TCP 因为要“可靠”而被条条框框约束的不便,但又担心因为 UDP 本身一些原因,会时不时被 QoS 而选择放弃。如果有机会实际去实际观察,你会发现,UDP 在国内网络质量其实很好,尤其在东部沿海地区。再早几年,有些团队 UDP 也发现了这些,做一些类似类似后来 QUIC 所做的优化,但后来 Google 直接开源了 QUIC,并且在 2022 年,RFC 9114 将其纳入到 HTTP/3 标准。那么这些大量自研的协议优化就失去了很多意义,网络协议标准化后,意味着会有一大堆 Client 甚至一些安全组件会进行适配,个人或团队的力量很难扭转,也大概也是为了各个国家都要争做通信标准的原因之一吧。


在这不到半年的业余时间里,系统性地看完了两本关于 AI 的书(一本是技术视角,另外一本是社会认知视角)。在这过程中,心情也是一波三折,一会自洽、一会更加绝望、一会好像又发现了点什么而小确幸。分享几个过程一些印象比较深的心得:


1. 理解和拥抱 AI 的第一步就是放下对确定性的执拗,因为 AI 本身的训练和推理过程就充满随机。一些人喜欢挑一些偏的问题,甚至用一些不常用语法去提问 AI,然后找出 AI 回答错误的点并展开批评,得到 AI 不行的结论。作为亲历一线的研发,我们不要以普通用户的视角去使用 AI,而是换成一个参与者视角,乐观看待目前遇到的一些问题,无论是工具缺失或者是幻觉等。有些时候并不是 AI 不好用,而是可能没有理解其原理,没有找到合适的方法。我好像并没有遇到如 AI 般的技术变革,联想 17 年毕业以来,所遇到到的 OTO、Web 3 和元宇宙等,放现在看,似乎更多是商业模式上创新。这些底层还是基于网络以及密码学的底子,技术上也还一直是延续惯性去解决问题;


2. 刚入行的程序员会不会因为没有机会接触到这些技术的知识实践,会不会丧失获得更高阶岗位的机会?原先我也是这么认为的,后面有一天忽然反应过来。这不就是一开始大家都基于汇编写代码,后面有了 C、Java、Python 后,还会有人怀恋汇编吗?Java 程序员会因为不了解 macloc 而管理不好内存吗?解决问题工具的抽象程度在变得越来越高,甚至一段好的 Prompt 也可以把问题解决。这并不意味着问题在变少,而是对人的要求确确实实在变高。但是,能走到这一步的人的数量,相比于基数,会呈现数量级减少,这才是需要额外注意的;


3. 未来也许对基础性能分析、技术架构甚至产品架构能力的要求会更加综合,也许会弱化研发作为一个独立的工种,而回到所要解决问题本身,围绕问题以及 AI 设置岗位。曾尝试过通过 Cursor,排查一个涉及多个代码库的并发问题,涉及多门语言以及技术栈,在过去因为这些门槛,单靠一个研发很难排查,但今天变得容易得多。如果再往前想一下,AI 对于研发影响,也许不仅仅可以写代码,也有可能是改变了整个开发的范式。工程本质是要解决问题,至于是通过代码去解决,还是通过换数据库,还是通过驱动大模型解决,仅从结果上看,并并没有本质区别,最终都要回到效率、成本、质量和体验上进行权衡;


4. 很多人羞于承认自己代码或者文档是借助 AI 写的(注:写这段话的时候是在 2025 年 6 月,又几个月过去,甚至看到了一些团队在考核 AI 使用情况),另外也有一些大学甚至禁止学生用 AI 完成作业。很多依赖时间积累的知识,在 AI 面前实现了平权,甚至资深和刚入行的程序员之间,编码质量上也不会太大区别。原来平台、中台化盛行那会,经常说好的框架,可以让刚入行的程序员也能写出高质量代码,没想在这一刻逐渐成为了现实;


5. AI 对于解决确定到确定的问题尤为擅长,而在未来,AI 对于确定性到不确定问题的能力也会大概率高于顶尖人类,而这背后是因为数据和算力无限。有个有意思的说法是,人类四分之一的氧气和能量要供给仅占人体质量 4% 左右的大脑,而心脏和肺即便再过一百年、一千年也不会有大的提升,这样看,人类自身等于间接锁死了算力;


6. 在博物馆里面,可以很直观看出 5000 年前的良渚文化挖掘的器物和 500 年前嘉靖年间的东西,似乎没有太大区别,除了做工更加精细外,但是和 200 年前(第一次工业革命 1830 年)就差别很大,是因为工业革命催生了很多蒸汽设备,生产力得到了质的提高,而现在 AI 也是一样。人类过去几千年的进化,靠的是知识的传承且更多近 200 年,即前面提到的工业革命。这也是近代教育背景,本质是对知识的传承;


7. 社会分工愈趋细分,映射到大学,就是各类专业划分,这提高了社会整体的生产效率,但也在一定意义上阻止了创新。目前,一些头部大学开始建立各种书院,强调通识教育,强调扩展知识宽度,而非一味强调深度,培养专才。北京大学在 7 月份,颁发本科生学业评价通知,取消绩点,不再设置指导性课程的优秀率指标,以及早在几年前,哈佛大学开始的通识教育改革,都在培养发现问题的能力。在 AI 时代,解决问题的能力也许不再稀缺;


8. AI 这次 GPT 时刻,很大程度是各个领域发展都到了很高一个阶段,具体就是数据 + 硬件算力(GPU 并行计算加速训练和推理的过程) + 模型框架。甚至因为数据和算力到了一个很高的阶段,模型本身反而越趋于简单。大模型发展到现阶段,很多问题转化成了工程问题,具体来说是规模问题,更多数据,更多更高质量数据,以及更大算力(如果量子计算获得突破,或者只是在某些领域获得突破,也许会是另一个 GPT 时刻。10月7日,刚刚开出的偌贝尔物理学奖,颁给了量子计算);


9. 基于语言进行建构,这某种意义上,是认为语言能够表达现实世界。这种观点是否成立,研究还在持续,但至少目前,并没有能够推翻这种观点的证明。编程语言也是一种语言,而且语法相对于自然语言,更为严格。某种意义上,也正是因为这种严格,使得这种语言有确定的规律可以学习,而 AI 非常擅长这些从确定问题,推导确定结论的场景,这大概也是 AI 能够在编程中,有比较好的落地效果原因之一;


10. AI 本质也还是统计模型,通过注意力机制,从纷繁复杂的源头数据中自主挖掘特征,且维度和复杂度远超人力所能及,极大地弱化对显式特征工程依赖。通过数据以及算力规模的大,甚至涌现出无法解释的智能。传统 ML 更依赖数据结构(特征工程)以及规则(专家经验),从某种意义上看,是否可以视为是一种确定性很高的函数,给定相同输入,得到相同输出,是一种更加精密的“接雨水”。本质还是白盒(如决策树、线性回归)或灰盒(如 GBDT)算法,有可解释性高且确定性强,而这些对工程都很友好。传统 ML 与 LLM 是互补而非替代关系。ML 适合结构化数据、高确定性任务,而 LLM 适合非结构化数据、生成和语义理解任务;


11. 如果简单将围绕 LLM 能力抽象为三层,从下而上分别是基础模型层、模型服务与微调层以及 Agent 与应用层:

a. 基础模型层是巨头机会,门槛极高,需要万亿级数据、万张级别 GPU 集群、顶尖人才;

b. 模型服务与微调层,是数据、领域知识和对特定任务的深度优化,同时提示工程、RAG 也能起到四两拨千斤作用;

c. Agent 与应用层核心是工程问题,关于如何编排和使用模型完成复杂任务:

  • 任务分解,将一个复杂的用户请求分解成一系列由 LLM 或传统工具可以执行的子任务;
  • 状态管理与记忆,为 Agent 维持一个长期、连贯的记忆和工作状态;
  • 可靠性与可观测性,如何调试和监控一个由不确定性组件(LLM)驱动的复杂系统;


12. NIST 对于AI 风险管理框架 21 年才颁布且一直在更新版本,AI 发展非常快,快到一些基础设施都还在快速迭代,甚至对于 AI 安全的思路都还在百花齐放阶段。安全更强调架构或整体视角,如果以企业内部敏感数据为锚,有 N 多可以从内部或外部,访问或外发这些数据的路径。安全需要解决的,就是要确保只有有权限的人在可信设备上发起访问,对应到技术领域,就是 AAA(身份、认证和授权) ,再配合南北和东西向不同层级防火墙、IDS/IPS 以及各种物理或逻辑隔离,端侧 EDR 和 DLP 等。涉及领域之宽,以至于鲜有人能够了解或熟悉每个点。从攻击方视角看,借助 AI ,其入侵能力获得量级提升,对现有这些防护能力轻松实现降维攻击,只能通过 AI 去防 AI,未来也许就是巨头间的游戏;


13. 以 LLM 为中心的分布式架构系统,和现有架构有很大区别:

a. 技术层面

  • 如何在相对严肃的生产环境中“黑盒”中构建确定性,避免幻觉对业务影响,是通过专家规则进行限制,还是引入人工审核,还是限制其作用半径等,这些都区别于传统工程问题域;
  • 当 LLM 不可用时,系统应能自动降级到基于规则的、功能有限但可用的“非智能”模式,确保基础功能可用;
  • 利用缓存和更小、更专业的模型来处理高频、简单的任务,减少对中央大模型的压力和依赖;
  • 设计多 LLM 提供商策略,能够灵活切换不同 LLM 后端的抽象层,避免单点依赖。这里还有另外一个风险,基础模型升级,也许会直接穿透到应用层,而这些本质是商业问题;

b. 合规层面

  • 建立从数据、模型到应用的完整可信链。这包括数据溯源、模型偏见检测、输出内容安全审核等;
  • Prompt 安全网关,位于应用和 LLM 之间,能用 AI(语义理解)的方法来检测和清洗 Prompt,防止注入攻击和数据泄露,这可以看作是“下一代 WAF”。现在内部涌现的各种 MCP Sever,某种意义上,是否也应该挂在这个网关后面,形成统一的防护切面;
  • 既然无法完全预测 Agent 行为,就必须对其所有动作进行详尽审计;
  • 如果再进一步展开,还会涉及数据隐私保护以及道德层面的问题;

AI 会是国家的机会,也会是大公司的机会,但对于普通个体而言,尤其是身处知识密集型行业的人,比如程序员、医生和律师等职业,也许即将会面临许多挑战。

最后,分享网上一段关于 Transformer 的评论。

Transformer 听起来也不复杂(很多听起来高深算法甚至觉得理解起来并不复杂)。有时候甚至觉得人类怎么才走到这里?不过不就是这样:我相信那种聪明的人很多,这样的人可能解决这种难题是很快就搞定的。但是现实中,能有机会坐到那个位置,动用资源,能免于饥荒、灾祸、糊口、疾病、收入、家庭琐事,以至于还有心情,有着内心追求去做点努力,还要付出大量的金钱获得结果,可能迎接他的还是大量的失败,他必须耐心到最后,还需要幸运,最后能得到结果这样的人是少数。Transformer 的出现也是一个随机幸运。而且一定是出现在资源大量溢出的国家。徘徊在糊口附近的国家,人思维受限的国家,无法产生这样的东西。 即使回过头来看起来很简单。

如果跳出普通个体视角,我们这代人是何其幸运,正在或即将见识到很多领域因为 AI 的出现而质变。


来源  |  阿里云开发者公众号

作者  |  韩春

相关文章
|
15天前
|
存储 弹性计算 人工智能
【2025云栖精华内容】 打造持续领先,全球覆盖的澎湃算力底座——通用计算产品发布与行业实践专场回顾
2025年9月24日,阿里云弹性计算团队多位产品、技术专家及服务器团队技术专家共同在【2025云栖大会】现场带来了《通用计算产品发布与行业实践》的专场论坛,本论坛聚焦弹性计算多款通用算力产品发布。同时,ECS云服务器安全能力、资源售卖模式、计算AI助手等用户体验关键环节也宣布升级,让用云更简单、更智能。海尔三翼鸟云服务负责人刘建锋先生作为特邀嘉宾,莅临现场分享了关于阿里云ECS g9i推动AIoT平台的场景落地实践。
【2025云栖精华内容】 打造持续领先,全球覆盖的澎湃算力底座——通用计算产品发布与行业实践专场回顾
|
7天前
|
云安全 人工智能 安全
Dify平台集成阿里云AI安全护栏,构建AI Runtime安全防线
阿里云 AI 安全护栏加入Dify平台,打造可信赖的 AI
|
10天前
|
人工智能 运维 Java
Spring AI Alibaba Admin 开源!以数据为中心的 Agent 开发平台
Spring AI Alibaba Admin 正式发布!一站式实现 Prompt 管理、动态热更新、评测集构建、自动化评估与全链路可观测,助力企业高效构建可信赖的 AI Agent 应用。开源共建,现已上线!
927 29
|
9天前
|
机器学习/深度学习 人工智能 搜索推荐
万字长文深度解析最新Deep Research技术:前沿架构、核心技术与未来展望
近期发生了什么自 2025 年 2 月 OpenAI 正式发布Deep Research以来,深度研究/深度搜索(Deep Research / Deep Search)正在成为信息检索与知识工作的全新范式:系统以多步推理驱动大规模联网检索、跨源证据。
666 52
|
3天前
|
监控 BI 数据库
打工人救星!来看看这两家企业如何用Quick BI让业务更高效
Quick BI专业版监控告警助力企业高效运作,通过灵活配置规则与多渠道推送,让数据异常早发现、快响应,推动业务敏捷决策与持续增长。
打工人救星!来看看这两家企业如何用Quick BI让业务更高效
|
7天前
|
文字识别 测试技术 开发者
Qwen3-VL新成员 2B、32B来啦!更适合开发者体质
Qwen3-VL家族重磅推出2B与32B双版本,轻量高效与超强推理兼备,一模型通吃多模态与纯文本任务!
580 11