你的系统真的可靠吗?
你有没有想过一个问题:系统上线之后,才是真正难点的开始?
在系统还只是一个雏形的时候,没有历史技术债,没有脏数据,没有复杂的依赖关系,我们可以尽情选择自己喜欢的技术,有着极大的自由度。
但是系统一旦上线,就迎来了第一个约束——业务不可中断。
我们一切的改动,必须是向后兼容的。每一次故障,都是对用户信任的一次消耗。你的信任钱包里,还有几张钞票呢?
"$1000 中,改掉一个 bug 的时间只值 $1,但知道问题在哪,值 $999。"
也许你是在项目已经完成从 0-1 的时候,加入团队去完成从 1-10、从 10-100 的过程。对于历史的情况,我们是难以完全了解的——有哪些坑?历史的权衡有哪些?哪些地方没有收口?
系统对于我们来说,就是一个黑盒子。
我们要想对它进行升级,首先就需要想办法去了解它。
上游的数据可能是脏的,怎么办?
到系统观察,有个很重要的问题你可能没想过:上游传给你的数据,可能是"脏"的。
什么是脏数据呢?比如:
- 字段缺失
- 格式不对
- 业务逻辑不符合你的预期
那怎么应对呢?
我见过一个挺好的设计是这样的:
▼JSON复制代码{ "from": "order", "input": {} }
每个数据都带着它的来源。这样当系统出问题的时候,你就能快速定位:哦,原来是 order 服务传的数据有问题。不是我的问题,是上游的问题! 快速甩锅(呃不对,是快速定位问题)的能力,就是可观测性的一部分。
下游访问频率很高,你敢随便改接口吗?
说完上游,再说说下游。
作为服务提供方,你是不是经常遇到这种情况:你的接口响应是这样的
▼json复制代码{ "code": "5000", "msg": "设备不存在" }
然后某天架构师说:"为了解决中文终端乱码问题,我们统一把 msg 改成英文吧。"
你啪一下就改了,然后上线... 咦?怎么有些下游调用方出问题了?
原来人家是这么用的:
▼javascript复制代码if (msg === "设备不存在") { // 做一些特殊的错误处理 }
啊这... msg 改了,逻辑就崩了。
所以作为服务提供方,我们一定要保证接口向后兼容,不能随意变更响应格式。
有时候你甚至不知道有谁在访问你,这就更头疼了。所以我在想:下游的访问频率高,实际上是在提醒我们——变更要谨慎,兼容性要放在第一位。
可以考虑给每个系统颁发一个 AccessKey,每次访问必须携带。这样当系统需要升级的时候,可以将改动点同步给下游,确认无误再升级,也就不粘锅了
你真的需要 ELK 吗?
说到可观测性,或许我们能够立刻想到 ELK(ElasticSearch、Logstash、Kibana、Filebeat),或者再加上 Kafka。
是的没错,这些技术能够让我们构建出一个好的可观测性体系。
但是,这些技术栈带来便利的同时,也引入了新的复杂性:
- 海量数据的存储
- 组件的稳定性
- 数据一致性和延迟
- 学习这套技术栈所投入的成本
也许当你成功搭建出这一套体系去解决问题的时候,用户已经流失了。
又或者,你只是说话权重不高的一名工程师,团队不会为你协调这些资源。资源是与业务增长挂钩的。
所以今天想聊聊:如何打造一个极简的可观测性体系?
系统架构设计,是克制的艺术
抛开华丽的技术名词之后,我们需要重新思考:什么才是真正的可观测性?
可以想象警察办案。办案不能靠某个人的主观说辞,而是要不断基于假设去验证,然后构建一段完整的证据链。
比如常见的:"你好,请问事发当晚9点你在做什么?谁来证明?人证、物证?"
所以关键是:某个组件在某个时间点,做了什么?结果怎么样?
这就是一个简易的事件模型:
▼JSON复制代码{ "actor": "order", // 谁 "timestamp": 1776420525400, // 什么时候 "action": "create-order" // 做了什么 }
换到系统里,我们描述一个组件做的事情,其实就是一个 I/O 的过程:
▼JSON复制代码{ "actor": "order", "timestamp": 1776420525400, "action": "create-order", "input": {}, // 外部提供了什么 "output": {} // 组件交付了什么 }
最后,为了描述一件被多个组件协同完成的事情,我们补充上链路 ID,对任务进行关联:
▼JSON复制代码{ "actor": "order", "timestamp": 1776420525400, "action": "create-order", "input": {}, "output": {}, "trace_id": "uuid" // 链路ID }
记录日志这件事,到底该怎么考虑?
好,现在说回到日志。
我之前一直在思考:加日志的代价是什么?
磁盘压力
日志文件增长起来是真的恐怖。
你想啊,一个日活 10 万的系统,每个请求打一条日志,一秒钟就是 100+ 条日志。一天下来就是上千万条。
一个月呢?一年呢?
- 存储成本:假设一条日志 200 字节,1000 万条日志就是 2GB。一年下来就是 700+G。这还只是一个系统。
- IO 压力:频繁写入会导致磁盘 IO 升高,尤其是机械硬盘。
- SSD 寿命:企业级 SSD 贵,消费级 SSD 有写入寿命限制。
"你以为加日志不花钱?存储是要钱的,运维是要钱的,到时候磁盘满了报警,你得半夜起来处理。"
CPU 开销
很多人觉得日志就是"写个字符串",能有多少开销?
还真不少:
- 序列化开销:把对象序列化成 JSON 或者其他格式,CPU 得算吧?
- 字符串拼接:日志框架的
{}占位符,每次拼接都会创建新字符串。 - IO 阻塞:同步日志写入会阻塞业务线程,影响接口响应时间。
- GC 压力:大量字符串对象会加重垃圾回收的负担。
💡 "你以为 logger.info('xxx') 是免费的?每一行日志,都是有成本的。"
网络开销
如果你的日志还要发送到远程服务器(比如 Kafka、Elasticsearch),那就还有网络开销:
- 带宽占用:海量日志会占用网络带宽
- 延迟增加:日志传输会引入额外的延迟
- 序列化/反序列化:数据在网络上传输前需要序列化,接收端需要反序列化
成本考量
综合来看,日志的成本包括:
| 成本类型 | 说明 |
|---|---|
| 存储成本 | 磁盘空间、备份、归档 |
| 运维成本 | 日志清理、磁盘扩容、报警处理 |
| 性能成本 | CPU 计算、IO 阻塞、GC 压力 |
| 网络成本 | 带宽占用、传输延迟 |
| 人员成本 | 日志分析和问题排查时间 |
所以日志不是你想加就能加的,得有个策略。
那应该记什么呢?
我的想法是:日志记录的应当是事实源,而非决策源。
什么意思呢?
| 事实源(应该记) | 决策源(不应该记) |
|---|---|
| 用户登录时间 | 用户是好人 |
| 订单金额 | 订单金额过高需审核 |
| API 调用参数 | 参数校验失败原因判断 |
说白了,决策是人做的,日志只提供证据链。
你不能指望日志告诉你"这是一个异常订单",你只能指望日志告诉你"这个订单的金额是 100 万,时间是凌晨 3 点,调用 IP 是 xxx"。
日志的权衡策略
说了这么多代价,那应该怎么权衡呢?
1. 根据环境调整日志级别
- 生产环境:ERROR 和 WARN 为主,业务关键节点记录 INFO
- 测试环境:DEBUG 随便打,方便排查问题
- 预发环境:根据问题需要,动态调整
2. 异步日志 vs 同步日志
- 同步日志:可靠性高,但影响性能
- 异步日志:性能好,但可能丢失(机器宕机时)
- 核心交易链路:用同步日志;一般业务:用异步日志
3. 日志采样
- 高并发场景下,不可能记录每一条日志
- 可以按比例采样(比如 1/100),或者只采样异常请求
4. 日志轮转
- 按大小轮转(1GB 一个文件)
- 按时间轮转(每天一个文件)
- 定期清理过期日志(比如保留 30 天)
5. 精简日志格式
- 避免记录大对象、大数组
- 只记录必要的字段
- 考虑使用二进制格式(更紧凑)
"加日志之前,先问自己三个问题:这个日志以后会看吗?这个日志能帮我定位问题吗?这个日志的价值,值得我付出这些成本吗?"
记日志的正确姿势
说了这么多,最后总结一下记日志的正确姿势:
1. 链路要完整
▼JSON复制代码{ "trace_id": "abc123", "timestamp": "2024-01-01 10:00:00", "actor": "order-service", "action": "create-order", "input": {"user_id": 123, "amount": 100}, "output": {"order_id": "xxx", "status": "success"} }
2.上下文要足够
▼JSON复制代码{ "trace_id": "abc123", "user_id": 123, "order_id": "xxx", "action": "payment-callback", "status": "failed", "reason": "balance_insufficient", "balance": 50, "amount": 100 }
3. 日志要记录堆栈
▼Java复制代码try { // 业务代码 } catch (Exception e) { logger.error("支付失败", Map.of( "order_id", orderId, "error", e.getMessage(), "stack", ExceptionUtils.getStackTrace(e) )); }
4. 敏感信息要脱敏
▼JSON复制代码logger.info("用户登录成功", Map.of( "user_id", userId, "phone", phone.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2") ));
怎么形成一条完整证据链
光有日志还不够,还得能让日志串联起来。
比如一个请求过来,经过了 A 系统、B 系统、C 系统,最后出问题了。怎么把这个请求在三个系统里的日志串起来呢?
答案是:trace_id。
同一个请求,所有日志都带着同一个 trace_id,这样就能串起来了。
在 Java 里,通常用 MDC (Mapped Diagnostic Context) 来实现,基于 ThreadLocal 的线程隔离,很方便。
思考问题的方法
最后想聊聊思考问题的方法。
我最大的感悟是:不要太快出结论。
正确的做法应该是:
- 先收集事实
- 再绘制证据链的地图
- 逐步扩散
- 当证据链闭环的时候,再出结论
如果你一开始就有了一个结论,然后带着结论去找证据,那很容易陷入确认偏误——只看到支持你结论的证据,忽略不支持的。
证据链的方法,能保证我们客观地分析问题。
发散一下
说到可观测性,其实业界有个经典的说法:可观测性的三大支柱:
- Logging - 离散的事件记录
- Metrics - 聚合的数值统计
- Tracing - 请求在分布式系统中的完整路径
这三个结合起来,基本上就能覆盖大部分的可观测性场景了。
还有一些可以想的:
- 错误处理分层:网络层超时、应用层校验失败、数据层约束违反
- 降级与熔断:当下游不可用时怎么办
- 容量规划:基于日志分析接口调用频率、数据增长趋势
