日志平台降本90%:LCA列式压缩与冷热分层实践
去年下半年接手一个日志平台的维护,第一件事就是拉账单。三类存储节点、每天新增三百多 GB 原始日志、保留三十天,光硬盘折算下来每个月五位数还挂着零头,而查询侧还在天天被业务方催“能不能再快一点”。后来我们把其中一批非核心业务的日志迁到了一个开源的日志项目上,项目缩写是 LCA,中文社区里习惯叫它“日志压缩分析引擎”。从起服务到第一路日志能查,确实只花了三分钟;但真正让账单掉下来的,是后面那几组存储配置。这篇就把这套东西从开源日志采集、LCA 部署、列式压缩、冷热分层到日志检索的完整链路拆开讲清楚,包括我踩过的坑和我算过的每一笔账。
写这篇的目的很简单:如果你现在正在为日志存储成本发愁,或者团队规模不大但又想要一个能扛住检索压力的日志系统,这篇能直接给你一套可复现的方案。全文会涉及体积测算、参数选择、配置文件、排错思路,基础一般的读者可以照着抄,有经验的读者可以直接跳到第 4 节看那几个决定成本的开关。标题里那句“存储成本直降 90%”我一开始也不信,后面会给出它成立的具体前提条件——不是无脑吹,是有边界的一折。
1. 先把账算清楚:日志的钱到底花在哪
1.1 一条日志从产生到被查到,中间过了几道手
很多人对日志系统的理解停留在“写进去、查出来”,实际上一条日志从业务进程里被fmt.Println打出来,到你在界面上敲关键词敲回车,中间至少要过六道手:采集端从文件或标准输出读出来、做多行合并、加上来源标签、批量打包、走网络投递到服务端、服务端解析成结构化字段、写入存储、建索引、最后才是查询命中。这六道手里,任何一道做得糙,成本就会在后面被放大好几倍。
我见过最典型的浪费场景是这样的:采集端把一整行原始文本原封不动塞上去,服务端不做结构化,全文丢进一个倒排索引里。这种做法的好处是省事,坏处是每一个字节都进了两套存储——原始文本一份,倒排索引里的词条一份。索引的体积往往比原文还大,尤其是日志这种关键词高度重复的文本,倒排表里的词条会被反复记录偏移量,膨胀率轻松到 1.3 倍甚至更高。这一点在做容量规划的时候必须先心里有数,否则后面怎么优化都是白搭。
所以你要先问自己一个问题:这条日志,我以后是要按关键词搜它,还是要按字段统计它?如果只是想知道“某个接口每分钟报了多少 5xx”,那根本不需要全文索引,只需要标签加数值列;如果是要查某条具体的异常栈,那才需要全文。这两种查询模式对应的存储成本,差着数量级。
1.2 三个隐形的成本放大器
第一个放大器是副本数。为了高可用,传统做法是每个分片存三份,或者至少两份。这意味着你辛辛苦苦压缩下来的空间,乘个 2 或者 3 又回去了。三副本在这个年代其实是个历史遗留选择,因为当年单机磁盘不可靠,现在对象存储层面的冗余已经把这部分工作接走了,应用层再存三份属于重复保险。
第二个放大器是索引膨胀。倒排索引的本质是把文本切词以后建一张“词到文档”的映射表,这张表本身也是要落盘的,而且为了查询快,通常还要常驻内存。日志的文本重复度极高,INFO、ERROR、/api/v1/order这类词在几亿条日志里反复出现,映射表里的 postings list 会变得极长。我实测过一组数据:一亿条平均 400 字节的日志,原始大小约 37 GB,加上倒排索引以后变成 61 GB,膨胀 1.65 倍。
第三个放大器是保留策略一刀切。绝大多数团队的日志留存是“全部热存 30 天”或者“全部热存 7 天 + 全部冷存一年”。问题是日志的价值分布极度不均:出问题的那几天、那几个服务的日志是金子,三个月前某个健康服务的 DEBUG 日志基本没人看。把冷数据放在热存储上,等于把矿泉水放进冰箱冷冻层——能用,但是浪费。
注意:这三个放大器是相乘关系,不是相加。副本 2 倍 × 索引 1.5 倍 × 冷热不分导致的 3 倍超额保留,合起来就是 9 倍。这就是为什么很多团队一算优化空间就觉得“不可能降这么多”,其实是自己把三层浪费叠在了一起。
1.3 为什么列式压缩 + 冷热分层能压到一折
理解了浪费在哪,优化路径就清楚了。LCA 这类新一代日志存储的底层思路,是把日志当成列式数据而不是文本文档。一条日志{ts, service, level, host, trace_id, msg}在行存里是六个字段挨着放,同一列的值在物理上是分散的;在列存里,所有日志的service值连续放在一起,level值连续放在一起。这个差别看起来只是摆放顺序,但压缩效果天差地别。
原因在于同质性。连续存放的service列里,可能一万行全是order-api,字典编码后只存一个字典项加一万个连续的下标 0,再用游程编码一压,一万行变成几十个字节。时间戳列是单调递增的,做 delta 编码以后相邻差值基本恒定,再对差值做 delta,结果几乎全是 0,压缩率接近极限。即便是msg这种看起来最“不可压”的列,因为日志模板重复度高(同一个代码位置打出来的日志只有变量部分不同),前缀压缩加 zstd 也能压到 8:1 以上。
我这边的实测数据是:业务日志(结构化程度中等,msg 占大头)整体压缩比在 9:1 到 12:1 之间;访问日志(结构化程度高,字段规整)能到 20:1 以上;纯异常栈(几乎无重复)只有 4:1 左右。你看,压缩比和你日志的结构化程度直接挂钩,这也是为什么第 3 节我会反复强调采集端要做解析。
至于冷热分层,逻辑更简单:超过 N 天的分片整体搬到对象存储上,查询时按需拉回来。对象存储的单位成本大约是本地 SSD 的十分之一到二十分之一,冷数据占总量的大头,这一刀下去账面上立刻就好看。
2. LCA 的架构拆解与选型逻辑
2.1 单二进制:为什么把复杂度收进一个进程
LCA 最吸引我的地方是它的交付形态——一个静态编译的二进制文件,内置了 HTTP 写入接口、syslog 接收、文件采集、存储引擎、查询引擎,全部塞在一个进程里。启动就是./lca --config config.yaml,没有依赖、没有外部元数据库、没有需要单独部署的协调组件。
这个选择有明确的取舍。好处对小团队极其友好:不用维护一套“采集端 + 消息队列 + 写入层 + 存储层 + 查询层”的五段式架构,出问题只看一个进程的日志。我在一个只有三台机器的环境里部过传统方案,光是把各个组件之间的认证和网络打通就花了两天;LCA 这边从下载到能查,真的就是标题里说的三分钟。
坏处也要说清楚:单进程意味着水平扩展靠分片而不是靠无状态扩容。它没有那种“加一台查询节点就多一份查询能力”的弹性,扩容的方式是按时间范围或者按业务标签切成多个实例,前面挂一层路由。这对日增量在 1 TB 以下的团队完全够用,但如果你的日增量是几十 TB,那就得提前规划分片方案,别指望单集群扛到底。
提示:单二进制不等于单点。它支持把冷数据写到共享对象存储,热数据做本地 RAID 或者多机分片,宕机恢复靠重放 WAL。真正的高可用方案在 4.2 节展开。
2.2 写入链路的三段式
LCA 的写入链路分成三段:接入段、缓冲段、落盘段。接入段负责协议解析(HTTP JSON、行协议、syslog、采集端上报),缓冲段是一个本地磁盘上的预写日志队列,落盘段负责攒批、排序、编码、写分片。这三段分离的设计,让我在处理“上游突然暴涨”这种场景时特别从容。
接入段最关键的一个设计是字段类型推断只做一次。第一条日志进来的时候,它会把status推断成整数,把latency_ms推断成浮点,把这个类型记在分片的元数据里。后面所有写入都按这个类型走,不会再变。这带来两个后果:好的一面是后续编码可以用最省空间的整数编码;坏的一面是如果上游某天突然把status改成了"200 OK"这种字符串,这条日志会因为类型冲突被丢弃或者落到旁路。这个坑我在 5.3 节会专门讲怎么排查。
缓冲段是我最喜欢的一环。它是一个环形队列,落在本地磁盘上,默认上限可以配置。上游写入速度超过落盘速度的时候,数据先进队列,不会因为下游抖动就丢。队列满了以后有两种策略:阻塞上游,或者丢弃并计数。生产环境我建议选阻塞加限流,因为日志丢了就是丢了,找不回来;阻塞最多是业务侧打日志慢一点。
落盘段的核心是攒批。单条写和批量写的成本差着两个数量级,因为每次落盘都要涉及行组切分、编码、压缩、索引更新、元数据提交。LCA 默认按“满 5000 条或者满 1 秒”触发一次落盘,这个参数后面 4.1 节会讲怎么调。攒批的另一个好处是排序:同一批次内的数据按时间戳排好再写,写出来的分片内部时间有序,查询时的块级跳过效果会好很多。
2.3 存储布局:分片、列存与编码
存储布局分三层:分片(shard)→ 行组(row group)→ 列块(column chunk)。分片按时间切,默认一天一个,也可以配成 6 小时一个。行组是压缩和索引的最小单位,默认 65536 行。列块是每一列在行组内的连续存储段。
为什么行组不能太大?因为行组是查询时“跳过”的粒度。行组越大,压缩率越高(因为同质性更好),但跳过精度越差。举个具体例子:查level = 'ERROR',如果 65536 行一个行组,而这个行组里一条 ERROR 都没有,整个行组的列块可以直接不读,省掉一次 IO。行组设成 100 万行,跳过率就上不去了。65536 是我实测下来比较平衡的值,日志量特别大的场景可以调到 262144。
编码策略是按列类型自动选的,一张表说清楚:
| 列特征 | 自动编码 | 典型压缩比 | 说明 |
|---|---|---|---|
| 时间戳列 | delta-of-delta | 50:1 以上 | 单调递增,差值近似常数 |
| 低基数枚举列(level、env) | 字典 + 位图 | 30:1 | 值域小,位图比游程更省 |
| 中基数列(service、host) | 字典 + 游程 | 10:1 | 有重复但不极端 |
| 高基数字符串(trace_id) | 前缀压缩 | 2:1 ~ 3:1 | 几乎无重复,能压的不多 |
| 长文本(msg、stack) | zstd 块压缩 | 6:1 ~ 10:1 | 模板重复高则更优 |
| 数值列(latency、bytes) | zigzag varint | 4:1 ~ 8:1 | 小数值多,变长编码占优 |
从这张表能看出来一个关键结论:高基数字段是压缩率的敌人。trace_id这种每条都不一样的字段,放进去只会拖累整体压缩比。它不是不能存,而是要有节制地存——这也是 4.3 节要讨论的“字段取舍”。
2.4 查询侧:分区裁剪和块级跳过
查询性能靠三层过滤,一层比一层细。
第一层是分区裁剪。查询条件里带时间范围,直接只扫对应分片。这一层是最省钱的,因为分片是按天切的,一次查询命中 3 个分片和命中 300 个分片,耗时差两个数量级。所以我在规范里写死一条:所有查询必须带时间范围,不允许全表扫。
第二层是标签索引过滤。LCA 为低基数字段(service、level、host 这类)建了轻量倒排索引,存在内存里。查询先走索引拿到候选行组列表,直接跳过不在列表里的行组。这层对大范围过滤特别有效,比如service IN ('a','b','c')在 200 个服务的集群里直接砍掉 98% 的数据。
第三层是块级统计跳过。每个列块里存了 min/max 值和一个可选的布隆过滤器。查询条件里如果有范围或者等值,先看块头的统计信息,不命中直接跳过。对于level = 'ERROR'这种,因为 ERROR 在行组内的分布不均匀,跳过率通常在 80% 以上。
三层过滤叠完,一个原本要扫 100 GB 的查询,实际读盘可能只有几百 MB。这也是为什么 LCA 能在不依赖大规模并行的情况下,把查询做到秒级。真正慢的查询,我总结下来只有三种情况,5.2 节细说。
3. 三分钟把服务跑起来并接入第一路日志
3.1 起服务前的三项检查
标题说三分钟,是真的,但前提是三件事你已经准备好了。
第一件是时间同步。日志系统对时间极其敏感,时间戳乱了,分片就乱,分区裁剪就失效。所有写入节点和采集端必须走同一套时间源,误差控制在毫秒级。我见过一次因为某台机器时间慢了 40 分钟,那段时间的日志全部落到了前一个分片里,查询怎么都查不到,排查了一下午。
第二件是磁盘。热数据目录建议用本地 SSD,不要用网络盘。日志写入是典型的顺序写加随机读,网络盘的延迟抖动会让攒批效率大幅下降。如果实在没有 SSD,至少保证热数据目录和冷数据对象存储的缓存目录在同一块盘上,避免跨盘拷贝。容量按 4.4 节的公式算,留 30% 余量。
第三件是端口规划。LCA 默认三个端口:8420管查询和管理 API,8421管 HTTP 写入,5140管 syslog。三个端口都要通,尤其是写入端口,采集端往往在另一个网段,内网策略里经常有人只开了查询端口忘了写入端口,结果数据进不来,还以为是程序问题。
提示:如果你的环境不允许开多个端口,可以把写入和查询合并到同一个端口,通过路径前缀区分(
/ingest和/query),代价是没法对写入做独立的限流和鉴权。生产环境还是分开好。
3.2 一份能直接抄的 compose 配置
单机试跑用容器最省事,下面这份我用了小半年,字段含义都在注释里。
# docker-compose.yaml services: lca: image: lca/lca:latest # 实际请换成你环境里的镜像地址 container_name: lca restart: unless-stopped ports: - "8420:8420" # 查询/管理 - "8421:8421" # HTTP 写入 - "5140:5140/udp" # syslog volumes: - ./data:/var/lib/lca # 分片数据 - ./wal:/var/lib/lca/wal # 预写日志队列 - ./conf:/etc/lca # 配置 environment: - TZ=Asia/Shanghai # 只影响日志展示,存储一律 UTC command: ["--config", "/etc/lca/lca.yaml"] deploy: resources: limits: memory: 8G # 索引常驻,按日志量给配置文件是关键,这份是我在生产上跑的精简版:
# lca.yaml server: listen: 0.0.0.0:8420 data_dir: /var/lib/lca retention_hot: 30d # 热数据保留期 retention_cold: 365d # 冷数据保留期 storage: shard_duration: 24h # 一个分片覆盖的时间范围 row_group_rows: 65536 # 行组行数,压缩率与跳过精度的平衡点 compression: zstd compression_level: 5 # 3~6 之间,超过 6 收益很小 index: labels: true # 低基数字段倒排索引 bloom: true # 布隆过滤器,等值查询提速 fulltext_fields: ["msg"] # 只对 msg 建全文,别贪多 ingest: http: enabled: true port: 8421 max_batch_rows: 5000 # 攒批上限 max_batch_wait: 1s # 攒批等待 syslog: enabled: true port: 5140 protocols: ["udp", "tcp"] buffer: wal_dir: /var/lib/lca/wal max_disk: 20GB # 队列上限,满了阻塞上游 overflow: block # block | drop,生产选 block cold: enabled: true backend: s3 endpoint: http://对象存储地址 bucket: lca-cold after: 7d # 热存 7 天后转冷 cache_size: 10GB # 冷查询的本地缓存这份配置里有两个参数值得单独说。compression_level: 5是我压测出来的拐点,从 1 调到 5,压缩比从 7.2:1 涨到 9.8:1,CPU 占用涨了不到一倍;从 5 调到 9,压缩比只涨到 10.4:1,CPU 却翻了三倍。写入密集的场景调高压缩级别纯属给自己找麻烦。after: 7d转冷,是因为日志查询的高峰集中在产生后的 72 小时内,7 天以后再查的概率掉到个位数,这时候转冷对体验影响最小。
3.3 接入第一路日志
服务起来以后,最快验证的方式是直接推一条 JSON 进去。
curl -s -XPOST 'http://127.0.0.1:8421/ingest' \ -H 'Content-Type: application/json' \ -d '{ "ts": "2025-03-11T10:22:31Z", "service": "order-api", "level": "ERROR", "host": "node-07", "msg": "payment callback timeout after 3000ms", "latency_ms": 3012 }'返回 200 就说明写入链路通了。但真正生产要接的是持续采集,方式有两种:文件采集和 syslog 推送。文件采集的配置长这样:
# collector.yaml —— 部署在业务机上 server: endpoint: http://lca-host:8421 sources: - name: order-app type: file paths: - /var/log/order/*.log parser: json # 应用直接打 JSON 最省事 labels: service: order-api env: prod multiline: start_pattern: '^\d{4}-\d{2}-\d{2}' max_lines: 200 # 异常栈合并上限 throttle: max_bytes_per_sec: 5MB # 防止异常刷屏打爆管道 - name: nginx-access type: file paths: - /var/log/nginx/access.log parser: nginx_combined # 内置解析器,直接出结构化字段 labels: service: gateway env: prod这里有个经验:能改成 JSON 打日志的应用,优先改。解析正则的 CPU 开销是解析 JSON 的三到五倍,而且正则一旦写得不对,字段就抽不出来,后面全靠msg里捞,检索体验直线下降。我在一个老项目上做过改造,把日志格式从自定义文本改成 JSON 单行,采集端的 CPU 从单核 70% 掉到 15%,字段准确率从 82% 涨到接近 100%。这个改造的投入产出比,比我后面做的任何存储优化都高。
多行合并那个参数也说一下。Java 服务的异常栈、Python 的 traceback,都是以单行日志的形式打出来的,采集端必须把连续的几十行合并成一条,否则一条异常会被拆成几十条记录,检索时全是噪音。start_pattern就是用来识别“新一条日志的开头”,匹配不上的行就往当前记录上追加。max_lines是安全阀,防止某个格式错乱的文件把合并逻辑拖死。
3.4 验证:从写入到查出来
数据进去以后,用查询接口确认一下。
curl -s -XPOST 'http://127.0.0.1:8420/query' \ -H 'Content-Type: application/json' \ -d '{ "sql": "SELECT service, level, count(*) AS c FROM logs WHERE ts >= now() - 1h GROUP BY service, level ORDER BY c DESC LIMIT 10" }'返回结果里如果能看到order-api和gateway,说明整条链路通了。我通常还会多做一步验证:把retention_hot临时改成1d,等第二天看分片是不是真的转冷了,cold.after也要同步调小,用一个临时环境跑一遍,确认对象存储的凭证和网络都通。这一步不做,等真的过了 30 天发现转冷失败、热盘爆了,那就不是五分钟能补救的事了。
4. 把成本打到一折的那几个配置
4.1 压缩参数:级别、行组、列编码
压缩比不是一个参数决定的,是三个参数的组合:行组大小、压缩级别、字段结构。我做过一轮完整的对照测试,用同一份 120 GB 的原始日志,各项配置下的最终落盘体积如下:
| 行组行数 | 压缩级别 | 落盘体积 | 写入 CPU | 查询 P95 |
|---|---|---|---|---|
| 32768 | 3 | 13.6 GB | 低 | 0.7s |
| 65536 | 3 | 12.1 GB | 低 | 0.9s |
| 65536 | 5 | 11.4 GB | 中 | 1.1s |
| 262144 | 5 | 10.2 GB | 中 | 1.8s |
| 262144 | 9 | 9.6 GB | 高 | 2.1s |
| 1048576 | 9 | 9.1 GB | 很高 | 3.4s |
原始 120 GB,压到 11.4 GB,压缩比约 10.5:1。从这张表能读出两个关键结论。第一,压缩级别超过 5 以后收益急剧衰减,从 5 到 9 只多压了 1.8 GB,但写入 CPU 翻了三倍,查询还慢了,完全不值。第二,行组从 65536 加到 262144 能多压 10%,但查询延迟几乎翻倍,因为跳过粒度变粗了,该跳的行组跳不掉,多读的数据把省下来的 IO 又吃回去了。
所以我的默认推荐就是row_group_rows: 65536、compression_level: 5。这个组合在压缩率和查询延迟之间取得了不错的平衡,除非你的场景是纯归档、几乎不查,那可以把行组调大、级别调高换取极限压缩。
还有一个容易忽略的点:列编码是自动的,但字段类型要人为控制。同样一个latency_ms,如果你在采集端没做类型转换,进来是字符串"3012",那它就退化成高基数文本列,压缩比从 6:1 掉到 2:1,查询还得做类型转换。前面说的“采集端做结构化”不是一句空话,它直接决定了后面每一列的编码效率。
4.2 保留策略与冷热分层
保留策略要分层设计,一刀切是最贵的做法。我一般按这个层级来分:
- 热层 3~7 天:本地 SSD,全字段可查,承担 95% 以上的查询请求。
- 温层 7~30 天:本地大盘或者低配 SSD,查询延迟接受秒级。
- 冷层 30~365 天:对象存储,查询需要先拉回本地缓存,延迟分钟级,用于事后审计和故障复盘。
- 归档层 1 年以上:只保留压缩包,不提供在线查询,需要时整包下载。
很多团队卡在“冷数据要不要留”这个问题上。我的判断依据是合规要求和故障复盘窗口的并集。一般业务日志留 90 天足够覆盖绝大多数复盘需求,涉及交易、安全审计的单独延长。剩下的日志,与其放热存储当摆设,不如降到冷层,成本断崖式下降。
具体到成本,我给你算一笔真实的账。假设日增原始日志 300 GB,保留 30 天热存:
| 方案 | 单日落盘 | 30 天总量 | 折算月成本量级 |
|---|---|---|---|
| 传统倒排索引栈(2 副本 + 1.5 倍索引膨胀) | 约 900 GB | 约 27 TB | 100% |
| LCA 全热存(10:1 压缩,单副本) | 约 30 GB | 约 0.9 TB | 约 3.3% |
| LCA 热 7 天 + 冷 30 天 | 热 0.21 TB + 冷 0.7 TB | — | 约 1.5% |
| LCA 热 7 天 + 冷 365 天 | 热 0.21 TB + 冷 10.7 TB | — | 约 12% |
最后一行的意思是:即便你把保留期从 30 天拉到 365 天,成本也只有原来 30 天方案的八分之一。这就是冷热分层的威力——保留期延长 12 倍,成本反而下降 88%。标题里那句“降 90%”,在这个配置下是能站住的,但前提是你真的接受了冷查询的延迟,并且把索引膨胀和副本这两个放大器拆掉了。如果你坚持全部热存、全部建全文索引、还要三副本,那这个数字你永远达不到。
4.3 索引字段的取舍
索引是双刃剑,建得越多查得越快,但存储和内存吃得越狠。LCA 里能建索引的地方有三处:低基数标签索引、布隆过滤器、全文字段。我给的原则是“标签索引敞开建,布隆按需建,全文克制建”。
标签索引只对低基数字段有意义,值域控制在几百到几千。service、level、env、region、status_code这类字段,建索引几乎零成本,查询收益巨大。但如果你把trace_id、user_id、request_id也配成标签,那就是灾难——一亿条日志产生一亿个不同的值,倒排索引直接爆炸,内存里装不下就落盘,查询还得全扫。这三个字段的正确用法是存在但不建索引,需要的时候用等值条件配合时间范围做点查,靠块级统计跳过,速度也不慢。
全文字段更是要克制。我见过一个配置把所有字符串字段都加进了fulltext_fields,结果索引体积比数据本身还大。正确的做法是只对msg和stack建全文,其他字段一律靠标签索引加等值匹配。真要做关键词模糊搜索,那也应该是“先用时间范围和标签把候选集缩到百万行以内,再在候选集里做全文匹配”,而不是一上来就全局搜。
布隆过滤器是用来加速等值查询的,典型场景是查某个具体的trace_id。它的代价是每个列块多存一点位图,开销大概在千分之一到千分之三之间,很划算。但如果你的查询基本都是范围查询和聚合,布隆过滤器的收益就很有限,可以关掉省空间。
4.4 一份完整容量测算
我把前面所有参数整合成一个公式,你在规划的时候可以直接套:
单日热存体积 = 原始日志体积 ÷ 压缩比 × 副本系数 热存总量 = 单日热存体积 × 热保留天数 冷存总量 = 单日热存体积 × (冷保留天数 - 热保留天数) 总成本 ≈ 热存总量 × 热单价 + 冷存总量 × 冷单价压缩比按日志类型取:结构化 JSON 日志取 12,半结构化文本日志取 8,带大量异常栈的取 5。副本系数,单机试跑取 1,生产建议 1.2(本地单副本 + 定期快照),不要取 3。
举个具体例子。某团队日增原始日志 200 GB,其中结构化日志占 70%,异常栈多的服务占 30%:
- 加权压缩比 = 1 ÷ (0.7/12 + 0.3/5) ≈ 8.6
- 单日热存 = 200 ÷ 8.6 × 1.2 ≈ 27.9 GB
- 热 7 天 = 195 GB
- 冷 90 天 = 27.9 × 83 ≈ 2.3 TB
- 冷 365 天 = 27.9 × 358 ≈ 10 TB
按本地 SSD 和对象存储的公开标价区间折算,这套配置的月成本大概是同规模传统方案的十分之一上下。注意这个数字对压缩比极其敏感,压缩比从 8.6 掉到 4,成本直接翻倍。所以上线前一定要拿自己真实的一周日志跑一遍,别拿别人的数字套。
5. 常见问题与排查实录
5.1 日志对不上数
这是最高频的问题:应用侧说打了 10 万条,LCA 里只查到 7 万条。别急着怀疑存储,先按顺序排查三个地方。
第一,采集端有没有丢。文件采集靠 inode 和偏移量记录读到哪里,如果日志文件被logrotate切割了而采集端没有配置copytruncate或者没有正确处理 inode 变化,切割瞬间的内容就会丢。判断方法很简单:看采集端的偏移量文件是不是在被切割的时间点归零了。
第二,类型冲突有没有静默丢弃。前面说过字段类型只推断一次,如果status第一天是整数 200,第三天来了个字符串"200",这条日志会走旁路。LCA 会在管理接口的/metrics里暴露一个ingest_type_conflict_total计数,这个数非零就说明有丢弃。解决办法是在采集端做强制类型转换,别指望服务端猜。
第三,写入端口限流。上游突发流量超过max_disk队列上限,overflow: block会把上游阻塞住,业务侧的日志库如果带超时丢弃策略,这时候就开始丢日志了。这种情况的特征是写入延迟飙升但错误率不高,看采集端的发送队列深度就能确认。
5.2 查询慢
查询超过 5 秒,九成是这三个原因之一。
时间范围太大。有人查“最近一个月所有 ERROR”,这种查询在任何系统上都慢。正确做法是先聚合后明细:先按小时聚合出错误量曲线,定位到异常时段,再缩小到那几分钟查明细。我在团队里推的做法是给查询模板加默认时间范围,不给用户留“全表扫”的口子。
没走标签过滤。只用msg LIKE '%timeout%'这种条件,等于放弃所有索引。优化方式是先把service、level这些标签加上,把候选集从几亿缩到几百万,全文匹配才有意义。我实测过同一个查询,加一个service条件,耗时从 12 秒降到 0.8 秒。
聚合基数太高。GROUP BY trace_id或者GROUP BY user_id这种,等于让引擎对几千万个分组做哈希,内存直接打满。这种需求通常不该用日志系统做,应该走数据仓库。
还有一个隐蔽原因:冷数据查询没预热。查 30 天前的数据,第一次要把分片从对象存储拉回来,延迟取决于网络和分片大小。我的做法是给常用查询加个定时预热任务,在业务低峰期把最近会被查的分片拉到本地缓存。
5.3 磁盘水位
磁盘爆满是最紧急的情况,处理顺序不能错。
先止血:把retention_hot临时调小,让过期分片尽快删掉。LCA 的删除是后台异步的,调完参数不会立刻生效,可以用管理接口的/admin/compact触发一次清理。
再定位:看是分片没合并还是冷转失败。分片没合并的表现是data_dir下面有大量小目录,每个几百 MB;冷转失败的表现是热存目录里躺着超过after天数的分片。前者调大合并频率,后者先检查对象存储的凭证和网络。
最后补洞:如果确认是某个服务刷屏导致的(比如一个死循环打了几个 G 的日志),在采集端加throttle.max_bytes_per_sec,或者直接在服务端按标签做写入限流。刷屏日志是日志平台的隐形杀手,我见过一次故障,一个服务在 20 分钟内打了 400 GB 日志,直接把三天的热盘填满,连带其他业务全部写不进来。
5.4 常见问题速查表
| 现象 | 大概率原因 | 快速验证 | 处理动作 |
|---|---|---|---|
| 数据查不到 | 时间戳解析失败落到错分片 | 查该时段分片元数据的 min/max ts | 采集端统一 UTC 格式 |
| 条数偏少 | 字段类型冲突 | 看ingest_type_conflict_total | 采集端强制类型转换 |
| 条数偏少 | 队列溢出丢数据 | 看采集端发送队列深度 | 调大队列或加限流 |
| 写入 200 但查不到 | 查询时间范围不含数据 | 用SELECT min(ts), max(ts)确认 | 检查时区配置 |
| 查询超时 | 无时间范围全扫 | 看查询计划的分片数 | 强制加时间条件 |
| 查询超时 | 冷数据未预热 | 看是否命中冷层 | 配预热任务或接受延迟 |
| 磁盘涨得快 | 高基数字段建了标签索引 | 看索引目录体积 | 移除trace_id类标签 |
| 磁盘涨得快 | 分片未合并 | 看分片目录数量 | 调高合并频率 |
| 内存占用高 | 索引常驻 + 大查询 | 看内存曲线和查询并发 | 限制并发或加内存 |
| 异常栈被拆散 | 多行合并未配置 | 看单条日志是否只有一行 | 配start_pattern |
6. 半年跑下来的一些真实体会
6.1 三个我一开始想错的地方
第一,我以为压缩比是固定的。上线前拿样例日志跑出来 11:1,觉得稳了,结果全量接入以后只有 7.5:1。原因是样例里全是结构化好的访问日志,真实环境里混着大量格式不统一的业务日志和异常栈。这件事教会我:容量规划必须用全量真实数据跑,不能用抽样,而且要多跑几天,覆盖业务高峰和低谷。
第二,我以为查询慢是存储的问题。前期花了不少时间在调存储参数上,后来发现 80% 的慢查询都是使用者自己写的:没带时间范围、没带标签条件、聚合基数爆炸。这事的解法不是优化引擎,而是给查询加护栏:查询模板里强制填时间范围,超过一定行数的结果直接拒绝并提示缩小范围。护栏加上去以后,P95 延迟掉了 60%,比任何参数调优都管用。
第三,我低估了采集端的改动量。服务端部署确实三分钟,但要让日志真的“好查”,采集端的日志格式改造花了两周。尤其是那些跑了好几年的老服务,日志格式是历代人改出来的,五花八门。改造的优先级我是这么排的:先改报错最多的三个服务,让排障体验立刻改善,有了正反馈再推其他服务。全面铺开反而容易推不动。
6.2 什么情况下我不建议用它
这套东西不是万能的,有几种场景我会直接劝退。
日增量超过 10 TB 且要求强实时检索的场景。单二进制的架构在这里会成为瓶颈,你得自己做分片路由和结果合并,复杂度不比维护一套分布式方案低。这种规模下,专门的分析型数据库更合适。
需要复杂 JOIN 和窗口函数的场景。LCA 的查询能力偏向日志检索,做多表关联很吃力。如果你的日志要和订单表、用户表关联分析,那应该把日志先落到数据湖,再在湖上做分析。
团队里没人愿意维护采集端。这是最现实的劝退理由。服务端再简单,采集端也有格式适配、多行合并、限流、升级这些事情要人管。如果没人认领,日志平台很快就会变成“数据不全、格式混乱、没人敢删”的烂摊子,那时候成本降得再多也没意义。
最后分享一个我自己一直在用的小习惯:每个月月初花十分钟看一眼三张图——每日写入量、分片体积分布、查询耗时分位数。这三张图能提前两周发现 90% 的问题,比任何告警都直观。日志系统这种东西,平时不出事的时候没人管,一旦出事就是排障最依赖它的时候,日常花这点时间做体检,怎么算都值。