写在前面
一直在想,怎么搞一个对业务无侵入性,又能让开发/运维人员轻松排查问题的轻量级系统,市面上很多,但是怎么感觉都不适合自己。
原来有一部分服务中日志采集一直用 ELK。一套 ELK 至少要 3 节点 + 4-8GB 内存,加上 Kibana,资源占用比业务进程加起来都重。运维成本也不低:ES 版本升级、索引模板、ILM 策略、磁盘水位、cluster state 同步,事不少。
Loki 试过一阵,轻是真轻,但全文检索基本半残。它只索引 label,content 不索引,你想 grep message 里的某个关键词,它实际上把所有匹配 label 的 chunk 全扫一遍。日志量一上来,慢得让人想砸键盘。
Datadog 这类 SaaS 倒是省事,贵。中小项目一年几万到几十万,扛不住。
恶心的现状

更头疼的是接入。我这边自己的产品线和项目线有着明显:
- Java 17 + SpringBoot 3.2 的新服务
- Java 8 + SpringBoot 2.0 的老服务,迁不动
为了让这些不同来源的日志都进同一个存储,要配 Logstash + Filebeat + OTel Collector 三套 agent,每种都有配置坑。
干脆自己写一个。设计目标:
- 不依赖 ES,用 PG 做存储(顺便验证 PG 扛日志场景的能力)
- 一种 Java 接入方式,跨 Java 8/17 + SpringBoot 2.x/3.x 都能跑
- 资源占用 ~1GB 单进程
- 支持 OTLP 标准端点,非 Java 应用零改接入
- 内置 AI 助手,自然语言查日志,AI时代下 必须有这个,自动分析你的服务日志,发现潜在问题
先看为敬

Dashboard 概览页,KPI 数字 + 24h 趋势 + 服务健康表 + 最近 ERROR 滚动。
用户在服务中直接输入的logger日志,以及服务的启动日志、异常堆栈日志


技术深究
我们展开几个核心技术点来谈谈
存储层:为什么我用 PG 替代 ES
时间/服务/POD/级别/关键词多维过滤,32764 条日志 34ms
ES 的代价
ES 强是强,代价大:
- 内存:每节点建议 16GB 起,堆内存一半给 JVM,一半给 OS page cache(给 Lucene)
- 磁盘:倒排索引本身 + source field(原始 JSON)+ doc values(列存),实际占用是原日志的 3-5 倍
- 运维:索引模板、ILM 策略、分片均衡、cluster state 同步、版本升级,每个都是坑
中小项目搞一套 ES,投入产出比太低。
PG 怎么做日志存储
PG 不是为日志设计的,但有几个特性在这场景下够用:
1. 按天分区
sql
CREATE TABLE lightlog (
ts TIMESTAMPTZ NOT NULL,
service TEXT NOT NULL,
instance TEXT,
level TEXT NOT NULL,
logger TEXT,
message TEXT NOT NULL,
stack TEXT,
mdc JSONB,
log_fp TEXT,
parsed_meta JSONB
) PARTITION BY RANGE (ts);
CREATE TABLE lightlog_20260805 PARTITION OF lightlog
FOR VALUES FROM ('2026-08-05') TO ('2026-08-06');
好处:
- 清理老日志 = DROP PARTITION,毫秒级,不像 DELETE 产生 bloat 还要 VACUUM
- 查询带时间范围(日志检索几乎都带),PG 的 partition pruning 直接跳过不相关分区
2. BRIN 索引
日志是顺序写入(时间戳单调递增),这种数据用 B-tree 索引太亏------索引本身占空间,跟数据量 1:1。
BRIN(Block Range Index)只存每个数据块的 min/max,索引大小是 B-tree 的 1/100:
sql
CREATE INDEX idx_lightlog_ts ON lightlog USING BRIN (ts);
代价是查询要做"块扫描",会读一些不在范围内的块。但日志查询基本都带时间范围,partition pruning 已经跳过大部分分区,BRIN 在剩下分区内扫描,精度足够。
3. JSONB + GIN 索引
MDC 和 parsed_meta 是半结构化字段,JSONB 存,加 GIN 索引:
sql
CREATE INDEX idx_lightlog_mdc ON lightlog USING GIN (mdc jsonb_path_ops);
-- 查询
SELECT * FROM lightlog WHERE mdc @> '{"traceId": "abc123"}';
实测:3000 万条日志,按时间 + 服务 + 关键词查询,34ms 出结果。够用。
存储可插拔:SQLite / PG / H2 三种,改环境变量切换。本地开发用 SQLite(零依赖),生产用 PG,测试用 H2。Spring 抽象 + 工厂模式,SchemaInitRunner 启动时根据 LIGHTLOG_STORAGE 选实现。
异步采集 + WAL 防丢:业务线程 0 等待
异步采集是日志系统标配,但实现细节决定可靠性。
数据流
scss
log.info("xxx")
↓ (同步,纳秒级)
Appender.append()
↓ (ConcurrentLinkedQueue.offer,纳秒级)
内存队列 (默认 10000)
↓ (后台线程批量取,1000 条一批 or 100ms 超时)
WAL 文件 (本地磁盘)
↓ (HTTP POST)
Center (HTTP API → 入库 PG)
↓ (200 OK)
删 WAL 文件
业务线程全程不等网络。center 宕机或网络抖动,业务不受影响。
WAL 实现
WAL 核心是"先落盘再推"。简化版(实际业务并非如此):
java
class WalStore {
private final Path baseDir;
private final AtomicInteger counter = new AtomicInteger();
Path append(List<LogEvent> batch) throws IOException {
String name = "wal-" + System.currentTimeMillis() + "-" + counter.getAndIncrement();
Path file = baseDir.resolve(name);
try (OutputStream os = Files.newOutputStream(file)) {
for (LogEvent e : batch) {
os.write(serialize(e));
}
}
return file;
}
void ack(Path file) throws IOException {
Files.delete(file);
}
}
// 后台推送线程
while (!stopped) {
List<LogEvent> batch = drain(queue, 1000, Duration.ofMillis(100));
if (batch.isEmpty()) continue;
Path walFile = wal.append(batch); // 先落盘
try {
centerClient.post(batch); // 推送
wal.ack(walFile); // 成功删
} catch (Exception e) {
log.warn("push failed, will retry next round: {}", e.getMessage());
// 不删,下次循环重试这个文件
}
}
代码不到 200 行。我后来想这玩意是不是过度设计------轻量场景直接同步推送也行。但有个细节说服了我自己:用户一旦发现丢日志,信任就崩了。日志系统可以慢,但不能丢。WAL 不是过度设计,是日志系统的最低承诺。
启动时重放 业务进程重启后,WAL 目录可能还有未推送的文件。Starter 启动时扫一遍目录,按文件名时间排序,逐个推送给 center,推完才允许新日志进队列。
细节:启动重放不能阻塞 Spring 启动太久(否则业务起不来)。所以重放是异步的,在 ApplicationReadyEvent 之后起独立线程跑。
AI 助手:为什么是 LangGraph + litellm

问"今天 ths-api 的错误日志情况",它拉了 246 条 ERROR,识别出 95% 是同一个 fingerprint,出自 ZwUploadServiceImpl,定位到"同一逻辑路径持续失败"。
为什么用 LangGraph 而不是裸 prompt
LangGraph 毕竟是 LangChain 的新一代 agent 框架,核心是显式状态机 + ReAct loop。
裸 prompt 的问题:
- LLM 可能死循环,token 烧穿
- 工具调用过程不透明,debug 困难
- 多轮对话状态管理混乱
LangGraph 解决:
- 每个 node 是明确的函数(state → state)
- edge 是状态转移规则
recursion_limit=10硬限循环次数,防 token 爆炸- 工具调用结果作为 state 流转,每步都能 print 出来看
agent 大致结构:
py
from langgraph.graph import StateGraph, END
builder = StateGraph(AgentState)
builder.add_node("plan", plan_node) # 解析用户问题,决定调哪些工具
builder.add_node("execute", execute_node) # 执行工具调用
builder.add_node("analyze", analyze_node) # 综合工具结果,生成回复
builder.set_entry_point("plan")
builder.add_edge("plan", "execute")
builder.add_conditional_edges("execute", should_continue, {
True: "execute", # 还要继续调工具
False: "analyze",
})
builder.add_edge("analyze", END)
graph = builder.compile()
graph.recursion_limit = 10 # 防 token 爆炸
4 个工具,每个都有硬限制
py
@tool
def search_logs(service: str, keyword: str = None, level: str = None,
start: str = None, end: str = None, limit: int = 100) -> list:
"""检索日志,硬限 ≤100 条返回"""
if limit > 100:
limit = 100
return center_client.search(...)
@tool
def count_logs(service: str, level: str, start: str, end: str) -> dict:
"""统计数量,不返内容,只返数字"""
return center_client.count(...)
@tool
def list_services() -> list:
"""当前在上报日志的所有 service 名字"""
return center_client.services()
@tool
def recent_snapshot(seconds: int = 30) -> list:
"""最近 N 秒(最多 300)日志,判断是否正在持续报错"""
if seconds > 300:
seconds = 300
return center_client.recent(seconds)
为什么 search_logs 硬限 100 条?这是经济决策,不是技术决策。LLM 上下文塞 1000 条日志,一次调用可能 ¥0.5。一天用 100 次就是 ¥50。100 条够 LLM 理解模式、给出有用洞察,token 消耗可控。
litellm 屏蔽多 LLM 差异
ini
from litellm import completion
response = completion(
model="qwen-plus", # 一行切换:openai/gpt-4o, anthropic/claude-3.5, deepseek/deepseek-chat
messages=[...],
tools=[...],
)
对比市面主流

怎么使用

讲了这么多,该怎么使用呢,很简单 0侵入性
xml
<dependency>
<groupId>io.github.yjn-pj</groupId>
<artifactId>lightlog-collector-spring-boot-starter</artifactId>
<version>1.2.5</version>
</dependency>
yaml
lightlog:
enabled: true
server-url: http://lightlog-center:8088
service-name: my-service
一段 pom + 4 行 yml,不改一行 Java 代码 ,搞定。 剩下的就是➡️部署