上一篇文章聊的是按业务 Key 拆分日志时,会遇到哪些问题,具体可以回顾:# 当日志按业务 Key 拆分后,我们到底会遇到什么问题? 而这次我们要讨论在这样的场景下,又应该如何去设计消费端的处理呢?
在讨论这个设计之前,我们先明确一下上篇文件说的几点特性要求:
一、顺序与并行,同一个业务 Key 内部的日志必须保持写入顺序,可不同 Key 之间又希望尽可能并行,两者要同时满足;
二、每个操作系统的文件打开句柄是有限的,而活跃 Key 可能成千上万,需要靠缓存来管理大量文件的打开和关闭;
三、在有限的文件打开资源的情况下,什么时候淘汰、淘汰谁,是一个需要做决策的问题;
四、IO 的物理边界,日志最终要落到磁盘上。现代硬盘同一时间只能对同一个文件进行实际的写盘,每秒能写多少字节是硬件定的,任何缓冲和优化都只能延缓问题,不能消除它。
这几个问题每个单独拎出来都不算复杂,难就难在它们同时出现在一个系统里,互相约束、互相影响。而围绕这几个问题,接下来将讨论应该如何去设计最终的消费端处理。
一、Worker 消费线程越多,性能是否真的越好
最开始我也是这么想的,但实际稍微想多一层,就知道存在两个问题:
一、Worker 解决的是并行度问题,但日志最终要写到磁盘上,一块盘的写入能力是固定的。用4个线程写还是16个线程写,可以提高从 Buffer 到 Page Cache 的效率,但真正从Page落盘到磁盘上,是与硬盘类型和性能相关。另外线程多到一定程度之后,写盘吞吐量上不去,时间反而耗在锁竞争和上下文切换上。
二、当活跃的 Key数量超过了预设定的可打开文件数量,即使加再多的Worker,依然无效。
这里可能有对文件写入不太理解的朋友,这里稍为解释一下。无法是什么开发语言,通常文件写入,都只是写到系统的Page Cache中去,并不是真正写到磁盘。什么时候从Page真正写到磁盘,与操作系统的执行策略,以及硬盘类型、性能有关。如果是旧式的机械盘,由于只有一个磁头进行写盘,所以同一时间,只能刷写一个文件。而固态盘没有虽然没有磁头寻轨问题,但同样会受到分区数量,系统flusher 线程分配的影响,依然存在限制。
所以 Worker 的数量决定以多少粒度把数据送到磁盘,但并不等于磁盘愿意写多少。
二、maxOpenChannels 最大打开文件数的设置
如果将每个Worker可以打开的文件数上限调大,则会触及到系统里一个无法修改的东西:操作系统的文件描述符上限(ulimit)。
虽然不同系统,对于每个进程能打开多少个文件的多少,限制会不一样,但总会有一个上限。所以在实现时,需要读系统的 ulimit,乘一个保留比例(如:20%)给日志系统用,剩下的让给应用自己;算出来的这个数,再和配置里写的上限取一个较小的值,才是最终生效的 maxOpenChannels。
maxOpenChannels 本身并不能使用资源的上限,是一个画在进程内部的保护线,避免日志系统把系统的文件描述符完全消耗,拖垮整个系统。而把这条线调大,只是把前面的悬崖往后挪了一点,只要活跃 Key 继续涨,瓶颈问题依然存在。
三、真正的问题:如何利用有限的资源
既然不能无限加线程,也不能无限开文件,那真正的问题就不再是怎么让资源无限增加,而是怎么让有限的资源,尽可能去服务当前最活跃的那批 Key。
首先并不是所有 Key 都值得长期占着一个打开的文件。例如:一个玩家在线时不停操作,日志一直写,而另一个玩家则在挂机发呆,日志只有一定时间内偶尔记录。这里比较正确的做法,是让文件资源跟着活跃度走。谁活跃,就享受打开着的通道;谁处于闲至状态,资源就让出来,给下一个活跃的用。
要让这个资源跟着活跃度走,通常的做法就是采用 LRU(最近最少使用淘汰)机制。
四、但 LRU 也不是魔法
在最终实现了 LRU 后,也就是最科的一轮基准测试,也就是上篇文章贴出的测试数据。
做法是把最大文件打开数设成一个比较紧张的值,然后逐步增加 Key 的数量:
| Key 数量 | Files Touched | Flushes/sec | Writes/sec | Rejected/sec | IO MB/sec |
|---|---|---|---|---|---|
| 200 | 0 | 88 | 14,036 | 0 | 28.36 |
| 210 | 1,327 | 1,378 | 8,595 | 9,976 | 16.11 |
| 220 | 2,034 | 2,034 | 4,687 | 11,453 | 7.42 |
Files Touched 是每秒新建文件的次数,Rejected 是达到文件数上限后触发淘汰/拒绝的次数。可以看到,Key 从 200 涨到 220,系统不是缓慢劣化,而是直接断崖:Files Touched 从 0 跳到两千多,写入吞吐掉了三分之二。
为什么 LRU 救不了?因为 LRU 解决的只是缓存满了以后淘汰谁的问题,它默认了一个前提:大部分写入都能命中缓存。当活跃 Key 的数量明显小于通道预算时,这个前提成立,命中率接近百分之百,Files Touched 是 0,一切都很美好,对应上表 Key 等于 200 那一行。
可一旦 Key 的数量越过预算线,情况就完全反过来了。这时候每条日志打过来,大概率都要新开一个文件,而每开一个新文件,就得先淘汰一个旧的。于是每一条日志都需要在:关闭文件 -> 打开文件 之间反复进行,这个也是最花费系统资源的操作。
更要命的是,由于频繁切换文件,每次都要将缓冲区刷盘,使到原来 Worker 中的缓存功能,变得可有可无。
如果应用场景是少数热点 Key 大量打日志,LRU 的命中率会非常高,这套机制会很舒服。但如果 Key 是高度离散的,每个 Key 都只是偶尔冒一条,那无论淘汰策略设计得多聪明都救不了。
五、Worker、maxOpenChannels、LRU:三者真正的关系
看到这里,Worker、maxOpenChannels、LRU 这三个东西各自管什么,已经比较清楚了:

Worker 数量解决的是并行度的问题,决定同一时刻有几条写入路径在跑;
maxOpenChannels 数量解决日志组件的安全边界问题,决定进程最多敢占用多少系统文件资源;
LRU 则解决复用的问题,决定这些有限的资源优先服务谁。
它们三个是层层约束的关系:并行度不能超出资源边界,资源边界之内靠 LRU 做取舍。任何一层单独调到很大,都不会让系统变得更强,只会让下一层的矛盾更早暴露。
六、必须接受的现实,以及真正可做的事
从我们上面的讨论,得到一件必须接受的事:Worker 数量、打开文件的数量、缓存淘汰策略,这三样东西都没办法让磁盘获得额外的吞吐能力。磁盘每秒能写多少字节,是硬件决定的。而三个参数真正做的,其实是另外两件事:
一、避免系统过早进入资源失控的状态。如果没有 Worker 上限,没有文件打开限制,Key 达到操作系统的文件打开上限,整个进程崩溃,可能还不是最坏的情况。如果能把安全边界限制在进程内部,那至少出问题的只是日志系统自己,而不是整个应用。
二、把 IO 压力控制在一个可管理的范围内。通过缓冲批量、延迟刷盘、LRU 复用,尽量让数据以大批量、少次数的方式落到磁盘,而不是一次一条、反复开关文件。
资源管理不是为了突破物理边界,而是为了防止因日志问题对系统造成破坏。毕竟通常日志的处理,都不是主要任务,不能影响真正的业务主进程或系统的正常运行。
想明白这一点之后,很多优化的针对性可能就变得不一样了。比如在 Key 高度离散的场景下,与其纠结把 LRU 换成 LFU 会不会好一点,不如先问一个更基础的问题:这个业务场景,是否真的适合一个 Key 一个文件。如果业务上就是几万个 Key 同时高频产生日志,那每个 Key 都一个文件的处理方式,可能就不太适用。毕竟我们以上提及的物理限界,几乎是不可逾越的。
最后
对于日志消费端这一侧,也就是日志从业务线程产生之后,到真正落盘之前,我们最终并不是靠增加 Worker、也不是靠增加打开文件的上限来解决问题的,而是先想清楚这些参数到底在控制什么,以及什么才是它们控制不了的东西。
而我自己最终实现,大致上是:固定数量的 Worker,每个 Worker 独占一个无锁的投递队列和一批文件通道;业务线程只负责格式化、路由和投递,真正费资源的文件开关、缓冲、刷盘、淘汰,全部放在 Worker 内部。这样既保住了同一个 Key 的顺序,又不会让业务线程被 IO 拖住。
这套设计目前已经在 Log4Key 里实现了。我也还在继续验证几个问题:不同的 Worker 数量、通道上限以及 Key 的离散程度之间,到底应该怎么取得平衡。目前看下来,这三个参数不是越大越好,而是要跟活跃 Key 的分布形态去匹配,而分布形态本身又随业务变化,这里还有不少工程细节没想透。
目前项目还在持续更新与优化当前,如果你对这种按 Key 组织日志的方式,或者对这套资源管理模型有不同的思路,有不同的优化建议,很欢迎来看看当前实现,以及留下你的想法。