LRU 真的能解决日志按业务 Key 分文件的落地瓶颈吗?

上一篇文章聊的是按业务 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 组织日志的方式,或者对这套资源管理模型有不同的思路,有不同的优化建议,很欢迎来看看当前实现,以及留下你的想法。

项目地址:github.com/log4key/log...

相关推荐
小满zs1 小时前
Go语言第十一章(协程)
后端·go
带刺的坐椅1 小时前
不只是 Token 流:Solon AI 4.1 的语义化聊天事件
java·stream·event·solon-ai
延凡科技2 小时前
建筑节能新视角:智慧冷站的数字化实践
java·人工智能·能源·暖通
www_aiyuanma_vip2 小时前
Java个人博客系统
java·开发语言
秋名RG2 小时前
Java 集合框架完全指南:从核心接口到 JDK 21 有序集合新特性
java·windows
蜡台2 小时前
Kotlin 五大作用域函数详解|let/run/apply/also/with 选型指南+实战避坑
android·java·kotlin
AC赳赳老秦3 小时前
农产品公开数据应用:OpenClaw 抓取农产品价格、产销公开数据,实现农产品行情动态监测
java·c语言·javascript·python·php·deepseek·openclaw
1001101_QIA3 小时前
从 CPU 到 GPU:高速缓存、指令并行与 CUDA 执行原理入门
java·后端·spring
2501_928996223 小时前
信创备份一体机性能焦虑根源与中科热备国产CPU平台实测拆解
后端·数据安全·测试