上个月调试一台跑 PREEMPT_RT 内核的工控机,实时控制线程按 2ms 周期运行,结果日志一多,周期抖动直接飙到 30ms。最先怀疑是中断亲和性、CPU 隔离的问题,查了一圈都没中。最后用blktrace一看,真正的元凶是/var/log下日志文件轮转触发的 fsync,整块磁盘的 IO 被打满,实时线程在一个文件锁上等了一个完整的调度周期。
从那以后我把 Rsyslog 和 Journald 的异步写入彻底翻了一遍,也摸清了磁盘 IO 阻塞实时任务的各种路径。这篇就把整套调优思路、配置参数、验证方法完整写下来,给正在做实时 Linux 后台服务、边缘计算网关、工业控制器日志方案的人一个可以直接照抄的参考。
1. 为什么后台写日志能把实时线程卡出几十毫秒毛刺
1.1 日志链路里的三个同步陷阱
很多人以为日志就是"往文件里写几行字",后台进程慢点也无所谓。但在实时 Linux 系统里,日志路径上有三个地方都可能让前台任务同步等待。
第一个陷阱是syslog()系统调用本身。传统 syslog 协议走 Unix domain socket,应用把日志丢给 rsyslogd 后返回。这套设计本来是异步的,但当 rsyslogd 的 socket 接收缓冲区写满时,sendmsg()会阻塞,应用就得等。换句话说,日志消费速度跟不上生产速度时,异步会退化成同步。
第二个陷阱是 Journald 的磁盘同步。systemd-journald 把日志写入 journal 文件后,会在一定条件下调用fsync()。fsync 不是立即返回的,它要等数据真正落到磁盘介质上,机械盘寻道、SSD 掉速、磁盘控制器写缓存忙,都会让这个调用等上几十甚至上百毫秒。journald 执行 fsync 时自己卡住不要紧,关键是 journal 文件在/var/log上,文件锁被 journald 持有,其他要写日志、读日志的进程都得排队。
第三个陷阱是文件锁和 logrotate。日志轮转时要把旧文件改名、创建新文件、执行 postrotate 脚本,这个过程中持有文件锁,期间任何想写同一个日志文件的任务都会阻塞。工业现场常见的就是 logrotate 恰好和实时任务的高峰撞在一起。
1.2 优先级反转的现实版本
教科书里的优先级反转是低优先级任务持有锁,高优先级任务等锁。实时 Linux 系统加了优先级继承协议,但只覆盖显式的pthread_mutex等锁机制。文件锁、内核块 IO 等待、ext4 journal 提交这类路径,优先级继承并不总是有效。
举个例子:日志进程以普通优先级运行,拿到日志文件的锁,开始写几千行日志,中途触发文件系统分配块、等待 journal commit。此时 SCHED_FIFO 的实时线程来了,它需要读日志或者写日志,必须等那把锁。实时线程不会因为优先级高就插队获得文件锁,只能干等磁盘完成当前 IO。这就是"低优先级日志进程打劫高优先级实时任务"的现实版本。
1.3 磁盘层的不可控:寻道、GC 与 cache 回写
机械盘顺序写尚有几十 MB/s 的持续性能,但随机写或遇到寻道就是几毫秒到几十毫秒的延迟,实时任务等不起。SSD 的情况更隐蔽,正常延迟几十微秒,遇到垃圾回收、写放大、NVMe 队列深度陡增时,尾延迟能飙到几十毫秒,这个长尾对实时系统最致命。
还有一个容易忽略的点是文件系统自身的 journal。ext4 在data=ordered模式下,每次提交都要把元数据和数据块落盘,磁盘缓存的回写时机由内核控制,不受用户进程优先级影响。所有这些合在一起,就形成了那句话:磁盘 IO 延迟不可控,所以实时任务绝不能同步等磁盘。
2. 设计思路:把日志写入从实时热路径上彻底剥离
2.1 先定延迟预算,再谈日志方案
任何实时系统先要有延迟预算。比如控制任务周期是 2ms,那日志链路引入的最坏延迟应该控制在任务周期的十分之一以内,也就是 200us 左右。这个预算决定了日志方案不能是"尽量快",而必须是"绝不阻塞调用方"。
在实际项目中,我给自己定的原则是:实时线程所在路径上,绝对不能出现任何write()、fsync()、文件锁等待、IO 排队等待。这些操作一律交给独立的消费者线程,实时线程只负责把日志丢进内存队列就返回。
2.2 日志通路选型:Journald、Rsyslog 与应用直写
先梳理一下常见的三种日志通路及其实时性特征。
| 通路方案 | 实时性风险 | 适用场景 |
|---|---|---|
| Journald 直接持久化(Storage=auto) | journald 周期 fsync,磁盘繁忙时阻塞不可控 | 需要本机留存完整日志、可接受偶尔抖动 |
| Journald 内存模式 + Rsyslog 异步队列 | 调用方只写内存队列,磁盘写由低优先级消费者承担 | 实时任务为主,日志允许少量丢失 |
| 应用直接 open/write/fsync 日志文件 | 每次写盘都暴露在实时路径上,风险最高 | 不建议实时线程使用 |
在我处理的实时系统里,默认推荐第三种组合:Journald 接收日志但只放内存,Rsyslog 通过 imjournal 从内存 journal 读取日志,进入自己的异步队列,再由低优先级工作线程批量写入磁盘或转发远程。这样就形成了"生产者写内存、队列做缓冲、消费者批量刷盘"的三段式结构。
2.3 核心架构:生产者-队列-消费者
整个方案的核心思路是剥离开"产生日志"和"写磁盘"这两个动作。产生日志的动作必须快速、无阻塞,写磁盘的动作可以慢,但必须有界、可控、低优先级。
实现上,Journald 本身充当第一级缓冲,它接收应用日志后写入/run/log/journal这种 tmpfs 内存文件系统,不直接触发块 IO;Rsyslog 的 imjournal 模块从内存 journal 读取日志,投递到自身的主队列;rsyslog 的 omfile 动作在另一端批量出队,用可控制的节奏写入持久化磁盘。
这里有个关键点:队列必须提供背压保护。队列满时,新的入队请求要能快速超时返回或者丢弃,而不是无限期阻塞调用方。这决定了系统在极端高负载下的表现——宁可丢日志,不能卡任务。
3. Journald 侧实战:调大缓冲窗口、压低同步频率
3.1 推荐的 journald.conf 配置与参数含义
Journald 的配置文件在/etc/systemd/journald.conf。实时场景下我通常这样设置:
[Journal] Storage=auto Compress=yes Seal=no SplitMode=host SyncIntervalSec=10m RateLimitIntervalSec=30s RateLimitBurst=20000 SystemMaxUse=4G SystemMaxFileSize=1G RuntimeMaxUse=512M RuntimeMaxFileSize=128M逐个说下关键参数。
SyncIntervalSec=10m是核心,它控制 journald 强制 fsync 的周期。默认值是 5 分钟,我调到 10 分钟甚至更长,就是为了减少后台 fsync 打断磁盘节奏的频率。注意这个参数不是"完全不 fsync",在关机、切换 journal 文件等场景下 journald 仍然会同步。
Seal=no关闭 journal 文件的 HMAC 签名封存。封存功能默认并非全部发行版开启,但显式关闭可以减少周期性校验带来的 CPU 开销。实时系统里 CPU 预算非常宝贵,不值得花在验签上。
RateLimitIntervalSec=30s配合RateLimitBurst=20000是为了防止实时任务突发大量日志时被 journald 的默认限流策略误伤。默认的 10000 条 / 30 秒在极端情况下不够用,调大后可以降低丢日志概率,但仍要注意 journald 在高负载下仍有静默丢弃的可能。
3.2 Storage=volatile 的取舍:日志可丢就只用内存
如果业务允许日志在系统重启后丢失,或者日志最终会转发到远程日志中心,那么Storage=volatile是最省心的选择。这种模式下 journald 只写/run/log/journal对应的 tmpfs,完全不产生持久化磁盘 IO,实时任务彻底摆脱了 journald 写盘的干扰。
代价是重启后日志全部清空,磁盘上的持久化目录不会继续积累。对工业控制器、边缘网关这类设备,日志本来就是给远程运维看的,本机留不留都行。我的建议是:能接受丢日志的实时系统,直接用 volatile,再把 Rsyslog 配成转发到远程;不能接受的,用Storage=auto加独立日志盘,并把SyncIntervalSec调大。
3.3 别让 journald 同时成为 CPU 瓶颈
很多人只盯着磁盘,忽略了 journald 在高日志速率下的 CPU 占用。journald 本身要对日志做解析、时间戳格式化、压缩。Compress=yes开启后 CPU 开销会增加,但在日志量大的场景下能显著降低磁盘写入量,一般建议保留。
如果实时任务所在 CPU 核非常紧张,可以考虑把 systemd-journald 的 CPUAffinity 限制到某个不怎么跑实时任务的核心上:
[Service] CPUAffinity=2,3配合下一章要讲的 IO 优先级设置,让 journald 成为一个"有界、有偏向"的后台消费者。
4. Rsyslog 异步队列:从逐条写文件改成批量出队
4.1 imjournal 与 main_queue 的分工
Rsyslog 在实时日志方案里承担的角色是"读取 + 格式化 + 写入/转发"。imjournal 模块负责从 journald 的 journal 文件读取新日志,投递到 rsyslog 的主队列;主队列背后的工作线程以批次为单位取出日志,交给 omfile 或 omfwd 执行。
这套结构和"请求-响应"模型完全不同。应用产生日志后,journald 写入内存,rsyslog 的 imjournal 按轮询间隔读取,之后进队列;队列满时 rsyslog 会按配置的timeoutenqueue策略拒绝新日志而不是无限等待。这样任何一环慢都不会反向阻塞日志生产者。
4.2 一套可以抄的 main_queue 配置
Rsyslog 的异步配置写在/etc/rsyslog.conf里,核心是主队列参数。下面是我在一台 16 核实时工控机上实际使用的配置:
global(queue.size="200000") module(load="imjournal" StateFile="/var/lib/rsysql/imjournal.state") module(load="omfile") module(load="omfwd") template(name="RtFormat" type="string" string="%timegenerated% %syslogtag% %msg%\n") main_queue( queue.type="linkedList" queue.size="200000" queue.highwatermark="150000" queue.lowwatermark="20000" queue.maxdiskspace="1G" queue.filename="rt_queue" queue.spoolDirectory="/var/spool/rsyslog" queue.dequeuebatchsize="1024" queue.timeoutenqueue="3" queue.timeoutshutdown="10" queue.timeoutactioncompletion="10" )参数含义拆解如下:
queue.type="linkedList"表示用链表内存队列。fixedArray 的固定数组在突发流量时可能提前占满,链表在日志量波动大的场景下更灵活,代价是每条日志多一点点内存开销。
queue.size是队列最大条数上限,我按系统内存余量配到 20 万条。以平均每条日志 500 字节计算,约占用 100MB 内存,这在 16G 内存的工控机上可以接受。队列越大,允许的突发缓冲越深,但内存占用也随之增长,需要按实际硬件来折中。
highwatermark和lowwatermark是磁盘辅助队列的启停水位。队列长度达到高水位时,rsyslog 开始把后续日志写入磁盘辅助文件;降到低水位后停止。这里设置的150000和20000保证大部分时间日志停留在内存队列中,只有极端情况才触发磁盘辅助。
dequeuebatchsize="1024"是关键中的关键,它控制工作线程一次取出多少条日志再执行写入。默认值较低,一次取几十条;调到 1024 后,写日志的频率大幅降低,每次都是顺序批量写,磁盘效率明显提升。代价是单批次在队列里的等待时间变长,日志到文件的延迟会增加一点。实时场景下我们优先保证任务不卡,日志晚几秒落盘完全可接受。
timeoutenqueue="3"表示队列真的满了时,入队最多等 3 秒,超时就丢弃当前这批日志。这个参数就是那道"宁可丢日志也不能卡调用方"的保险闸。
4.3 action 级队列与 DiskAssist 兜底
除了主队列,rsyslog 允许给单个动作单独配队列。这个能力在"一个重要日志目标拖垮其他所有日志"的场景下很有用。比如把远程转发单独隔离出来:
ruleset(name="rtlogs") { action( type="omfwd" target="192.168.10.20" port="514" protocol="udp" queue.type="fixedArray" queue.size="50000" queue.highwatermark="40000" queue.lowwatermark="5000" queue.dequeuebatchsize="512" queue.timeoutenqueue="2" queue.maxdiskspace="512M" queue.filename="fwd_queue" ) }如果远程日志服务器慢或者网络抖动,这个 action 队列会吸收缓冲,不会影响本机其他日志目标的写入节奏。
DiskAssist 机制同样重要。配置了queue.filename、queue.spoolDirectory和queue.maxdiskspace后,当内存队列持续处于高水位时,rsyslog 会把超出部分的日志落盘到 spool 目录,防止进程内存耗尽。对实时系统来说,这是极端情况下的兜底,不是常态路径。
4.4 日志到远程:omfwd 与本地零落盘组合
如果远程日志中心可用,最干净的组合是 journald 用 volatile、rsyslog 只负责转发、本机完全不落盘日志文件。这样日志数据永远在内存和网络缓冲区之间流动,完全不依赖本地磁盘的 IO 性能。
action( type="omfwd" target="log-center.example.internal" port="6514" protocol="tcp" template="RtFormat" action.resumeRetryCount="-1" )resumeRetryCount="-1"表示断线后无限重连,配合队列缓冲,保证网络瞬断时日志不丢。这个方案唯一的弱点是依赖网络可靠性,适合有专网或稳定数据链路的工业现场。
5. 让日志进程"让路":IO 优先级、挂载参数与调度器协同
5.1 用 systemd 把 journald 和 rsyslog 的 IO 优先级压到最低
异步队列解决了"调用方不被阻塞"的问题,还没解决"日志进程本身抢磁盘带宽"的问题。当实时任务的数据盘和日志盘共用同一块物理磁盘时,日志进程的高频 IO 会挤占实时数据读写的带宽,延长实时任务的 IO 完成时间。
Linux 的 ionice 提供了三类 IO 调度优先级:realtime(最高)、best-effort(默认)、idle(最低)。日志这种后台任务,直接压到 idle 最合适。在 systemd 服务里设置:
[Service] IOSchedulingClass=idle IOSchedulingPriority=7 IOWeight=1对 rsyslog 和 systemd-journald 都执行:
systemctl edit systemd-journald systemctl edit rsyslog写入上面的片段后重启服务,然后验证:
systemctl show systemd-journald -p IOSchedulingClass systemctl show rsyslog -p IOSchedulingPriorityIOSchedulingClass=idle表示该进程的 IO 请求只在磁盘完全空闲时才会被调度。对于实时任务的数据读写来说,日志进程直接变成透明人。IOWeight=1是 cgroup v2 的权重值,1 是几乎最低档位,进一步保证日志 IO 不会和实时 IO 竞争。
5.2 文件系统挂载参数:降低周期性刷盘频率
如果日志最终还是落在本地盘,挂载参数能显著影响刷盘行为。以下配置适合日志分区:
/dev/sdb1 /var/log ext4 noatime,nodiratime,data=writeback,commit=120 0 2noatime,nodiratime关闭文件访问时间更新,减少元数据写入。data=writeback让文件数据不经过 journal,只记录元数据,能减少 journal 写入量,但崩溃时文件内容可能处于不一致状态,这点必须和业务侧确认接受。commit=120把文件系统 journal 提交周期从默认 5 秒拉长到 120 秒,减少周期性刷盘带来的突刺。
如果你对数据一致性要求高,坚持用data=ordered,那至少把commit调大,让 journal 提交尽可能聚合。日志文件本来就不是数据库事务日志,掉电丢几秒日志通常可以接受,但游戏规则要在设计文档里写明白。
5.3 块设备调度器:日志盘和数据盘要"分而治之"
块设备调度器决定了 IO 请求在设备队列里的排序方式。机械盘用mq-deadline或bfq,NVMe 盘通常直接none。查看当前调度器:
cat /sys/block/sda/queue/scheduler我的建议分三层:实时任务的数据盘和日志盘在物理层面分开,这是最优解;必须共享一块盘时,给日志盘设置独立分区并限制 IO 优先级;调度器层面,机械盘用bfq配合上面设置的 idle 类,NVMe 用none即可。
物理隔离的意义远大于任何软件参数。实时任务的数据读写和日志刷盘如果落在一块盘上,哪怕日志进程是 idle 类,磁盘固件内部的处理也会互相干扰。能上两块盘就不共用一块,这是架构层面的决策。
5.4 实时调度规则与日志进程的关系
这里顺带回应一下"怎样配置实时 Linux 调度规则"这个经常被问到的问题。实时线程用SCHED_FIFO或SCHED_RR只能保证 CPU 不被普通进程抢占,但解决不了线程自身陷入 D 状态等待磁盘 IO 的问题。一个实时线程如果发起了fsync(),它就处于不可中断睡眠,CPU 调度规则再高也没用,只能傻等磁盘。
所以实时任务里写日志的正确姿势是:实时线程绝不直接打开文件写,而是把日志塞到内存环形缓冲区或发给 journald,由独立的低优先级进程去刷盘。实时调度规则管的是 CPU 资源分配,日志架构管的是消除 IO 同步等待,两者缺一不可。
6. 验证效果:用 cyclictest 和 IO 监控对比优化前后
6.1 构造一个能复现的"日志风暴 + 实时负载"压测环境
没有实测数据的优化都是玄学,我当时用 cyclictest 模拟实时负载,再用脚本灌日志风暴,对比前后的延迟分布。压测脚本大概长这样:
#!/bin/bash # 实时负载:1 个线程,SCHED_FIFO 优先级 95,1ms 周期,跑 10 万次 cyclictest -t 1 -p 95 -i 1000 -l 100000 -h 400 -m -q > /tmp/cyclictest-before.txt & # 日志风暴:连续往 syslog 写带时间戳的日志,模拟业务峰值 for i in $(seq 1 500000); do logger -p user.info "rt-noise $i $(date +%s%N)" done &跑完后让 iostat 在后台记录磁盘状态:
iostat -x 1 60 > /tmp/iostat-before.txt6.2 看延迟直方图和最大值,不是看平均值
cyclictest 的-h 400会输出延迟直方图。重点关注最大值和 99.9 分位,平均值对实时系统没有意义。优化前我测得的最大延迟在 32ms 左右,优化后最大值降到 180us 以内,效果立竿见影。
对比表格大致如下:
| 场景 | 平均延迟 | 最大延迟 | 99.9% 延迟 |
|---|---|---|---|
| 基线(无日志负载) | 12us | 38us | 22us |
| 默认配置 + 日志风暴 | 68us | 32ms | 2.4ms |
| 优化后 + 日志风暴 | 15us | 180us | 42us |
这个对比充分说明:日志配置不合理时,最坏延迟能高出两个数量级,而合理的异步化能把日志风暴的影响压回接近基线水平。
6.3 看 fsync 频率和块设备排队指标
延迟数字之外,还要从系统层面确认优化确实生效。用 strace 数 journald 的 fsync 调用次数:
strace -f -p $(pidof systemd-journald) -e trace=fsync -c跑 10 分钟后,优化前的 fsync 调用次数可能上百次,优化后个位数。同时用iostat -x 1看磁盘的await和%util,日志风暴期间优化前的await可能飙升到几十毫秒,优化后保持稳定低值。
rsyslog 队列状态也可以直接监控:
/usr/sbin/rsyslogd -Q或者看/var/log/syslog里是否出现队列满的告警。实际调试中,我会把queue.size临时调小来制造告警,验证timeoutenqueue确实生效,确认极端情况下系统不会卡死。
6.4 长稳测试才是硬通货
日志和 IO 的干扰有一个特点:不频繁,但一旦发生就是大坑。单次测试可能刚好没踩到 logrotate、刚好没遇到 SSD 垃圾回收,所以必须长时间跑。
我建议至少 8 小时连续测试,中间人为触发几次 logrotate、journal 文件轮转和磁盘写入压力,再统计全程最大延迟。很多"优化完很稳"的方案就是倒在 4 小时后的那次 logrotate 上的。把 logrotate 时间错开实时任务高峰期,或者干脆在低峰期执行,也是很实用的手段。
7. 容易忽略的坑:限流、audit、队列超时
7.1 journald rate limit 静默丢日志
journald 默认的RateLimitIntervalSec=30s和RateLimitBurst=10000在日志量瞬时飙升时会触发限流,超出的日志被静默丢弃,应用层完全感知不到。实时任务的日志往往就是突发式的,开机自检、故障录波、控制事件,都可能瞬间产生大量日志。建议把 burst 调到业务峰值的两倍以上,同时用 tcpdump 或导出的日志对比确认没有静默丢失。
7.2 auditd 和 logrotate 是另一个隐藏刷盘者
很多人只盯着 journald 和 rsyslog,忽略了 auditd。内核审计模块如果配置不当,在日志风暴期间触发审计事件,auditd 的 backlog 会被打满,进而影响系统调用性能。如果实时任务系统不需要审计功能,直接关掉审计服务是干净的方案;必须保留的话,把 backlog 调大并限制max_log_file的触发动作。
logrotate 的问题前面提过,再补充一点:确保日志文件的轮转时间不和实时任务的周期任务重叠,可以用cron配到凌晨低峰,或者在 systemd timer 里加随机延迟,避免所有服务同时触发轮转造成 IO 峰值叠加。
7.3 队列参数别盲调:内存、出队延迟和丢日志窗口
异步队列的参数是互相牵扯的。queue.size调大带来更长的事件延迟——日志在队列里等得越久,落盘时间越晚;dequeuebatchsize调大提升吞吐,但也会让日志在队列里积累更多;timeoutenqueue调大增加了阻塞容忍度,却违背了"绝不卡调用方"的初衷。
我的参数设计逻辑是:先定业务可接受的日志最大延迟,比如 10 秒内必须落盘或转发;再按日志峰值速率算队列深度;最后按硬件内存余量定queue.size。日志这种数据,宁可丢,也不能让它卡住实时控制回路。把这句话写进团队的设计评审文档,比任何参数都重要。
7.4 实时系统的日志哲学:该丢就丢,但要知道丢了什么
做实时系统时间长了,对日志的态度会从"一条都不能丢"变成"允许丢,但要有监控告诉我是谁丢了"。异步队列、内存缓冲、限流策略,本质都是在延迟、可靠性、资源消耗之间做折中。日志异步化之后必须配套可观测性:队列长度、丢弃计数、落盘延迟这些指标要暴露出来,不然就是蒙着眼睛做优化。
我的最终配置在这个思路上落地:实时任务的日志路径上没有任何同步磁盘等待,journald 是内存缓冲,rsyslog 是批量消费者,IO 优先级最低,磁盘物理隔离。跑了一个月下来,实时任务的最坏延迟始终稳定在预算以内,日志也一条不落地到了远程日志中心。
最后再给一个小技巧:调完参数后,把/etc/systemd/journald.conf和/etc/rsyslog.conf的差异保存下来放进版本管理,同时在部署文档里写明"为什么这些参数要这样配"。等三个月后你自己回来看配置,会发现当初的决策记录比任何运维文档都值钱。