日志聚合与告警轻量方案:基于Go、UDP和SQLite的实践
1. 写在前面我被日志问题折腾没脾气之后写了 Madeira如果你也在维护几十台甚至上百台 Linux 服务器大概率经历过这种场景线上报了一个偶发问题你手上只有一句错误提示接下来就是挨个 SSH 上机器翻日志、grep、再翻。运气好五分钟定位运气不好一上午就搭进去了。时间全浪费在“找日志”而不是“分析日志”上。我为了不再重复这种低效劳动写了一个内部代号叫 Madeira 的项目一个非常轻的日志聚合与告警服务。它解决的问题很聚焦——把分散在各台机器的日志统一收起来能搜、能查、能按规则提醒。先交代一下背景。我之前所在的小组不算大但服务器数量并不少线上问题排查大多靠人肉。后来引入过一些开源方案要么太重要么对团队维护能力要求太高。折腾一圈之后我决定自己动手写一个刚刚好够用的东西。这也是我坚持做 Madeira 的初衷不追求大而全只追求在真实场景里能稳定跑起来、能减少我半夜爬起来查日志的次数。如果你也是运维、SRE 或者经常跟服务端日志打交道这篇文章里的设计和踩坑记录应该能给你不少参考。1.1 我踩过的三个具体痛点第一个痛点是日志散落。我们的服务分布在多台机器上同一个请求可能会经过 A、B、C 三个节点但每个节点的日志只会留在本地。一旦出问题你需要先按调用链猜可能涉及哪几台机器再逐一登录查看。这个过程非常依赖个人记忆和运气新同学接手时几乎是无从下手。第二个痛点是 grep 只能解决“在线问题”。单台机器上 grep 一下当然很快但当你想回答“最近一小时所有机器上出现过多少次 timeout”这个问题时单机 grep 就完全失效了。你需要把几十台机器的日志全部拉下来再合并、去重、统计。这个动作听起来简单实际操作中维护一堆临时脚本反而更累。第三个痛点是告警太粗。系统监控只能告诉你 CPU、内存、磁盘有没有异常但业务层面的问题它感知不到。比如某个接口开始随机返回 500或者数据库连接池出现溢出很多时候是用户先反馈我们才发现。等你能看到日志的时候问题往往已经持续了一段时间。我需要的不是一套复杂的规则引擎而是一个能对日志内容做实时匹配、命中后立刻通知我的东西。1.2 我对 Madeira 的定位会搜索、会告警、不搞复杂分析在实际动手之前我先把需求收敛成了四个核心能力。第一日志统一汇聚每台机器跑一个极简 Agent把新增日志发送到中央服务端第二全文检索支持关键字、正则、按时间范围、按机器名过滤第三规则告警日志内容命中某个模式后通过 webhook 推到钉钉、飞书或企业微信群第四保留策略日志按天分表到期自动清理避免磁盘被撑爆。这套定位决定了 Madeira 不是一个日志分析平台它不负责解析业务字段也不做复杂的聚合计算和可视化。它的价值在于把散落的日志变成一份可查询、可订阅的数据源。对我来说这些功能覆盖了日常 80% 的排查场景剩下的 20% 我依然会去单机 grep 或者写临时脚本。边界清晰之后项目做起来反而快因为我知道哪些东西不需要做。1.3 先说清楚它的边界如果你在评估要不要自己写这类工具我建议先想清楚自己的日志量级。Madeira 适合服务器数量在几十到几百台、日增日志量在几十 GB 以下、团队里没有人专职维护日志系统的场景。这个量级下它比 ELK 轻得多也比单纯的 grep 强得多。但如果你的日志日增量动辄几百 GB或者需要支撑多租户、复杂聚合分析、高并发查询那还是老老实实用 Elasticsearch、Loki 这类成熟方案。判断方法很简单当你发现自己需要反复改表结构、加索引、做分布式部署的时候说明已经超出了自研轻量工具的合理范围。不要因为“自己写工具很有成就感”就把架构搞复杂这是我在很多项目里看到的通病。2. 技术选型为什么是 Go、UDP 和 SQLite 的组合工具选型往往是项目最开始也是最容易反复的一步。Madeira 最终选择了 Go UDP SQLite 这一套组合很多人第一反应是“太随意了吧”。但如果你仔细算过账会发现这个组合在特定量级下非常合理。下面我逐个讲清楚背后的取舍逻辑。2.1 选 Go单文件部署和 goroutine 是最核心的两个理由Go 在服务端工具链里现在已经非常普遍但我要强调的不是“大家都在用”而是它在这个场景里的两个具体优势。第一编译产物是单二进制没有 JVM 或 Node 运行时依赖。我交叉编译之后把文件直接 scp 到服务器上就能跑连环境变量都不需要配。这对部署 Agent 来说是巨大的便利尤其是你要在几十台上百台机器上批量分发的时候。你甚至可以直接用一条命令完成分发GOOSlinux GOARCHamd64 go build -o madeira-agent ./cmd/agent scp madeira-agent root10.0.0.8:/usr/local/bin/第二goroutine 的并发模型非常适合日志接收场景。每个 UDP 连接、每个规则匹配、每个 webhook 推送都可以丢进独立的 goroutine不需要手动管理线程池。Go 标准库里的net/http也足够支撑查询 API省去了引入 Web 框架的负担。有一个比较隐蔽的坑需要提一下我用github.com/mattn/go-sqlite3时交叉编译到 ARM 平台会因为 CGO 问题失败。后来换成了纯 Go 实现的modernc.org/sqlite才彻底解决编译分发问题。如果你也打算在多个架构上部署建议一开始就用纯 Go 的 SQLite 驱动别走弯路。2.2 用 UDP 而不是 TCP/Kafka实时、轻量、可接受损失的权衡日志传输层我选了 UDP在当时的评审会上被同事质疑过。我先说结论在内网环境下UDP 丢包率通常极低而日志场景本身允许丢失少量非关键数据。如果一条日志因为网络抖动丢了它造成的损失远小于整个采集链路因为 TCP 重传或队列堆积而阻塞的损失。用 TCP 的话当客户端或服务端处理不过来时会出现队头阻塞和重传风暴反而拖垮整个采集链路。用 Kafka 则意味着要多维护一套集群这已经违背了我“轻量自建”的初衷。UDP 最直接的好处是没有连接状态Agent 发完就走服务端只负责接收天然适合高频小包的日志上报。当然这不意味着放弃所有保障。我在协议层做了一个简单的补偿设计Agent 每次发送的 JSON 里带一个自增的message_id服务端在内存里保留最近一批 id 用于去重。这样即使某个日志包在网络上被复制也不会被重复落库。发送端的核心逻辑可以写得非常干净conn, err : net.DialUDP(udp, nil, serverAddr) // 每行日志序列化为 JSON一次 Write 对应一个 UDP 包 payload, _ : json.Marshal(map[string]any{ host: hostname, file: file, ts: time.Now().Unix(), level: level, msg: line, message_id: atomic.AddUint64(seq, 1), }) conn.Write(payload)2.3 SQLite 能不能扛住日志写入先算一笔账再动手在决定用 SQLite 之前我做了非常具体的估算而不是凭感觉拍脑袋。假设你有 100 台机器每台平均每秒产生 20 条有效日志每条日志平均 300 字节。那么全系统每秒大约产生 2000 条日志、600KB 数据。一天下来大约 1.73 亿条、52GB。这个量级听起来很大但你要知道 SQLite 的写入能力其实被严重低估了。它每秒可以执行几万次简单 INSERT而且我们还可以用事务批量提交。只要保证每次事务里写入几百条记录2000条/秒的写入压力对 SQLite 来说非常轻松。真正需要担心的不是写入能力而是磁盘空间和查询索引。所以我在表结构上做了一个关键决策按天分表。每天的日志单独存一张logs_20250118这样的表清理时直接DROP TABLE比 DELETE 快一个数量级也不会产生大量碎片。存储方面如果保留 7 天大约需要 350GB 磁盘这个成本在绝大多数服务器上是可以接受的。如果觉得太大可以在 Agent 端做级别过滤或者丢弃 heartbeat、healthcheck 这类噪音落库量能降到原来的三分之一以下。2.4 关于“为什么不用 ELK”的完整回答我在前面已经暗示过但这里还是想把问题说透。ELK 是很好的方案但它解决的问题和我不同。Elasticsearch 适合海量日志的复杂检索和聚合分析但它的部署和运维成本摆在那里Elasticsearch 节点常驻内存轻松超过 2GB加上 Logstash、Kibana一组最小集群也要好几台机器。对于只有二三十台服务器的小团队来说这个成本可能比日志问题本身还大。Loki 比 ELK 轻一些但同样引入了新的组件和运维复杂度。我的观点是工具选型应该跟着问题走。如果你只需要“找一个错误出现过多少次”和“这个错误现在立刻通知我”SQLite 的全文搜索加上几行规则匹配代码已经够用。等你的查询并发量上来了、需要多维度统计报表了再来迁移也不迟。轻量架构的最大优势就是随时可以推倒重来因为你没有背上沉重的运维包袱。3. 架构与核心实现从采集端到告警规则的最小闭环说完选型我直接带你过一遍 Madeira 的核心实现。整个系统分成 Agent 采集端、Server 接收端、告警引擎、查询 API 四块。我会给出一份能跑通的最小版本代码思路并解释每一步为什么这么设计。3.1 数据模型日志怎么变成一行 JSON日志首先被 Agent 读取然后转化成一条结构化的 JSON 记录。这里我没有提取业务字段的复杂解析逻辑只做最基础的元数据包裹。一条典型的上报数据长这样{ host: web-01, file: /var/log/app.log, ts: 1737168000, level: error, msg: db connect timeout, retry3, tag: api, message_id: 2847291 }之所以不解析业务字段是因为日志格式千差万别强行结构化只会让 Agent 变得臃肿而且在字段规则频繁调整时很难维护。保持msg原始字符串把搜索、正则匹配全部交给服务端去做是性价比最高的选择。服务端建表时也保持同样思路只建最必要的索引字段CREATE TABLE logs_20250118 ( id INTEGER PRIMARY KEY AUTOINCREMENT, received_at INTEGER NOT NULL, ts INTEGER NOT NULL, host TEXT NOT NULL, file TEXT, level TEXT, tag TEXT, msg TEXT NOT NULL ); CREATE INDEX idx_logs_host_ts ON logs_20250118(host, ts); CREATE INDEX idx_logs_ts ON logs_20250118(ts);3.2 Agent 采集端tail 正则过滤 UDP 发送Agent 的实现我优先考虑稳定性和轻量性。最初我用 Go 的fsnotify监听文件变化但在日志轮转场景下处理起来非常麻烦很容易出现重复读或漏读。后来我直接采用更底层的tail -F方式让 Agent 启动一个子进程跟随文件再把新增行送入处理管道。tail -F对日志轮转的处理非常成熟这也是我推荐你优先尝试的方案。Agent 的一个核心处理器逻辑可以这样理解cmd : exec.Command(tail, -F, filePath) stdout, _ : cmd.StdoutPipe() scanner : bufio.NewScanner(stdout) for scanner.Scan() { line : scanner.Text() if dropPattern.MatchString(line) { continue } // 超过 512 字节的日志截断避免 UDP 分片 if len(line) 512 { line line[:512] } sendToServer(line) }这里有两个细节值得注意。一是 UDP 包不要太大超过 MTU 后会触发 IP 分片分片包更容易丢。我的经验是单条日志控制在 512 字节以内超过部分直接截断。二是 Agent 的过滤规则要尽量前置不采集的日志直接在本地丢弃这样既节省网络带宽也减少服务端存储压力。3.3 服务端接收与落库用批处理和单写协程解决写入瓶颈服务端的设计核心是“无阻塞接收、异步批量落库”。UDP 监听开启几个 goroutine 持续读包解析后放入带缓冲的 channel。channel 满的时候直接丢弃并打点计数绝不让接收线程阻塞。这样即使后面写入暂时跟不上也不会拖慢局域网内的 Agent 发送。落库环节我用了单写协程模型而不是多个 goroutine 同时写 SQLite。原因很简单SQLite 同一时刻只允许一个写事务多写协程不仅不会提速反而会频繁触发SQLITE_BUSY增加锁等待。单写协程配合批量事务每攒够 500 条或者时间过去 1 秒就统一提交一次。func flushBatch(items []LogItem) { tx, err : db.Begin() if err ! nil { return } stmt, _ : tx.Prepare(INSERT INTO logs_20250118 (received_at, ts, host, file, level, tag, msg) VALUES (?, ?, ?, ?, ?, ?, ?)) for _, it : range items { stmt.Exec(time.Now().Unix(), it.Ts, it.Host, it.File, it.Level, it.Tag, it.Msg) } tx.Commit() }这套写法的好处是写入放大极小。一次事务写入 500 行SQLite 的 fsync 次数就从 500 次降到 1 次性能提升非常明显。实际测试中单机普通 SSD 上恢复到 5000条/秒以上没有压力完全覆盖我们前面算出的 2000条/秒需求。3.4 告警规则引擎正则匹配、冷却时间和 webhook 推送告警引擎是 Madeira 里最贴近业务的部分。规则定义在配置文件里每一条规则包含一个正则、一个冷却时间和一个 webhook 地址。Server 处理日志时异步地对每条已落库数据做规则匹配匹配命中后进入冷却判断。冷却机制非常重要否则同一个错误每秒出现一百次你的群就会被刷屏。我的实现是维护一个map[ruleIDhost]lastSentAt每次命中时检查距离上次推送是否超过冷却时间。如果没有超过只更新统计计数不重复发 webhook。这样既保证问题被第一时间感知又不至于把告警通道变成噪音制造器。规则配置文件大概长这样[[rules]] name api 5xx pattern HTTP/1\.[01] 5\d\d cooldown_seconds 300 webhook https://oapi.dingtalk.com/robot/send?access_tokenxxx [[rules]] name db timeout pattern db connect timeout cooldown_seconds 600 webhook https://qyapi.weixin.qq.com/cgi-bin/webhook/send?keyxxx推送 webhook 时我用了一个独立的 goroutine并设置 3 秒超时。这是踩过坑的早期我直接在规则匹配协程里同步发送 webhook结果某个群机器人接口响应慢了几秒整个日志处理协程都被卡住了。后来我改成异步发送即使 webhook 长时间无响应也只是丢失一次通知不影响日志主链路。3.5 查询 API 和简易面板够用就好最后是查询层。我用 Go 标准库net/http暴露了一个简单的 REST API支持按关键字、时间范围、机器名、日志级别过滤。前端是一个不依赖任何框架的静态页面定时刷新展示最近日志和告警统计。这个面板的定位是“排查问题时打开看一眼”不是给领导做可视化大屏。curl -H X-Token: your-token \ http://10.0.0.5:8080/api/search?qtimeouthostweb-01start1737168000end1737171600limit200我建议你在做查询接口时一定加上 limit 参数默认限制返回 200 条。没有限制的日志查询接口很容易被误操作拉出几百万条数据直接把页面和浏览器内存拖垮。这个细节看着不起眼实际使用中非常关键。4. 部署和性能调优在真实环境里把 Madeira 跑稳代码写完只是第一步真正让工具产生价值的是稳定部署和日常调优。这里把我从测试环境到大规模部署过程中用到的配置、踩过的坑和优化手段分享出来基本都是可以直接抄作业的内容。4.1 用 systemd 部署 server 和 agent服务端和 Agent 部署我都用 systemd 管理不建议用裸 nohup 方式。原因很简单systemd 自带开机自启、崩溃重启、日志采集出了问题可以在journalctl里统一查看。Server 的 unit 文件参考如下[Unit] DescriptionMadeira log server Afternetwork.target [Service] ExecStart/usr/local/bin/madeira-server -config /etc/madeira/server.toml Restartalways RestartSec5 Usermadeira LimitNOFILE65535 [Install] WantedBymulti-user.target这里特别强调LimitNOFILE。日志服务需要同时打开大量文件描述符包括 UDP 连接、SQLite 数据库文件、可能的 HTTP 连接等。默认的 1024 限制很容易耗尽导致服务表现异常。我通常直接设置成 65535避免这类低层次问题来干扰排障。Agent 的 unit 文件结构基本一样只是执行路径不同。分发时可以用 Ansible、Shell 脚本甚至手动 scp 都行。重要的是在每台机器上确认 Agent 进程一直在跑我习惯加一个简单的定时任务检测进程不存在就调用systemctl restart madeira-agent。4.2 SQLite 优化三板斧WAL、synchronous、批量事务SQLite 调优有一条非常成熟的路径我会在每次建库时执行这几条 PRAGMA让读写性能上一个台阶PRAGMA journal_modeWAL; PRAGMA synchronousNORMAL; PRAGMA busy_timeout5000;WAL 模式最大的好处是读操作和写操作可以并发执行不会出现传统 rollback journal 模式下写锁阻塞所有读的情况。synchronousNORMAL表示只在 checkpoint 时进行 fsync而不是每次事务提交都 fsync这能把写入性能提升好几倍同时不会像OFF那样面临断电丢数据的风险。另外我会在系统空闲时执行一次PRAGMA wal_checkpoint(TRUNCATE);让 WAL 文件回落到合理大小。如果你按天分表可以在每天凌晨的清理任务里顺手做这个操作。批量事务在前面已经讲过这里不再重复但要记住一个原则任何单个日志条目的写入都不应该单独开事务。4.3 容量规划和日志保留策略容量规划要在部署前就做而不是等磁盘告警再做。我们回到 2.3 节里的数据估算假设日增 52GB、保留 7 天那么你需要至少 400GB 的磁盘空间。建议单独挂一块数据盘给 Madeira避免和系统盘争抢空间。我的保留策略是按天分表后直接 DROP而不是 DELETE。DROP 表在 SQLite 里是非常轻量的操作瞬间完成且不产生数据碎片。保留天数我一般设置成 7 到 14 天太长的历史日志很少被查询反而占用磁盘空间。对于需要长期追溯的日志可以再加一道筛选任务把重要日志导出到另外一个归档库。Agent 端也要做容量控制。每台机器上报前就过滤掉 heartbeat、健康检查这类高频噪音可以显著减少总数据量。我发现很多团队在这个环节不够重视最后日志量远超预期被迫加大磁盘其实源头过滤一下就能省掉一半空间。4.4 几个容易踩的隐形坑文件句柄、时钟、时区除了文件句柄还有三个坑我要提醒你。第一是 UDP 接收缓冲区。Linux 系统默认的 UDP 接收缓冲可能不够大在高并发日志场景下会丢包。你可以通过以下命令实时调整sysctl -w net.core.rmem_max8388608 sysctl -w net.core.rmem_default8388608第二是时钟同步。Agent 记录的是采集端机器的时间戳如果服务器时间漂移搜索排序就会混乱。建议所有机器都启用 NTP 同步服务端展示日志时也统一按接收端时间排序两者差异通常只有几秒对排查问题影响不大但时间戳本身必须可信。第三是时区问题。所有日志时间戳我都以 Unix 秒存储展示层再转本地时区。千万不要在日志内容里带上时区前缀“2025-01-18 10:00:00 08:00”这种字符串因为不同机器的时区配置可能不一致解析起来会让你怀疑人生。统一 UTC 存储是日志系统的基本功。5. 常见问题与排查技巧实录这个部分算是整篇文章里我认为最值钱的章节。项目上线后遇到的各种奇怪问题我都记录下来了并整理成一套可快速定位的排查清单。遇到类似情况时至少能帮你少走几个小时弯路。5.1 告警没触发但日志里全是 ERROR这是最常见的困惑。第一反应通常怀疑规则写错了但实际情况往往是链路中某一个环节静默丢掉了日志。我建议按下面这个顺序排查首先确认 Agent 有没有发送。如果 Agent 的 drop 正则或 level 过滤设置得太激进日志可能在本地就被丢弃了。你可以在 Agent 启动参数里加一个 verbose 模式观察哪些行被过滤掉其次确认服务端有没有收到。看一眼/api/stats里的接收计数再对比 Agent 本地记录的发送计数差值就是丢包或过滤的量最后才检查规则本身。regexp 在 Go 里默认区分大小写ERROR和error是两回事我用(?i)前缀解决了大小写敏感问题。还有一个容易忽略的点规则匹配发生在日志落库之后、异步协程里如果 webhook 地址配置错误告警推送会失败但日志本身不受影响。你在检查的时候先看告警推送日志再看规则执行日志不要一上来就改规则。5.2 告警轰炸和 webhook 超时怎么解决告警轰炸通常不是规则引擎的问题而是缺少冷却机制。如果你发现某个规则一小时内推送了几百条消息基本可以确定 cooldown_seconds 没设置或者冷却状态没有按 host 区分。同一个错误在多台机器上同时出现如果不按 host 分组冷却每组都会推送一条几百台机器就是几百条消息。我的建议是第一天上线规则时cooldown 设置为 600 秒跑几天观察命中频率再逐步调整到合适的冷却值。另外webhook 推送必须设置超时并且在失败时打印错误日志。我之前遇到一个情况企业微信机器人接口偶尔超时由于没有设置超时告警协程全部卡死在 HTTP 请求里后来的日志反而没有被及时处理。超时和异步发送是告警功能稳定的底线要求。5.3 UDP 丢包率过高先查 MTU再查 buffer如果你发现服务端接收数量明显少于 Agent 发送数量首先不要怀疑“UDP 就是不可靠”而是要去查网络路径。内网环境下UDP 丢包通常来自三个原因MTU 分片被丢弃、系统 UDP buffer 不足、交换机或防火墙对 UDP 做了限速。排查贴士Agent 可以在发送端记录 sent_count并定期发给服务端做对账。当丢包率超过 0.1%先检查单条日志是否超长导致分片再检查系统 UDP buffer 配置。如果这两个都正常去问网络组是不是做了 QoS 限速。曾经有一个案例某条链路上防火针对非标准端口的 UDP 做了限速导致日志大量丢失换一个端口问题立刻消失。如果内网丢包仍然严重可以考虑在 Agent 端做本地重传但必须配合服务端去重机制。重传倍数不要超过 2否则重复日志会淹没存储。我在前面提过用 message_id 做去重在这个场景就会发挥作用。5.4 查询偶尔慢索引和碎片问题的排查方法SQLite 默认单表数据量大了之后即使走了索引查询性能也可能下降。按天分表能有效控制单表体积但如果你在当天表里做不带时间范围的模糊查询依然会全表扫描。我后来给msg字段建了 FTS5 全文索引搜索体验提升非常明显CREATE VIRTUAL TABLE logs_fts USING fts5( msg, contentlogs_20250118, content_rowidid );实际使用中不需要太复杂的同步逻辑每批次落库后把新增行的 id 和 msg 插入 FTS 表即可。如果数据分表可以定时重建当天的 FTS 索引查询慢的问题基本迎刃而解。另外查询时尽量带 host 或 ts 过滤条件让 SQLite 能够有效利用复合索引这是调起来最简单也最有效的一步。最后再分享一个我自己养成的习惯每次改完告警规则不要直接部署到生产先用测试 payload 往 server 发一条日志确认 webhook 能正常推送、冷却时间生效再更新正式配置。这个习惯帮我避免了很多次“规则写错但自己完全不知道”的尴尬。另外webhook 里的 token 一定要通过环境变量注入不要明文写在配置文件里尤其在多人协作时这个细节能省掉不少安全隐患。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →