如果你翻过某团队内部的项目清单,大概率见过一类只有代号、没有详细介绍的项目——我这次负责的 REA 就是其中之一。刚拿到手的时候,除了这个名字,只剩一条需求:“把各处散落的运行数据收拢起来。”听起来很简单,但真正动手之后才发现,这句话背后藏着一整套采集、缓冲、处理、存储、查询的问题。这篇内容把我从项目启动到上线维护的所有关键决策、踩坑过程和复盘经验完整记录下来。如果你也在做一个内部数据处理类的系统,或者正打算从零搭一个类似的小工具,我的建议是可以把这篇当一份避坑清单用——很多坑都是不看一眼根本预想不到的。
1. 接手 REA 时只有一句话需求,我是怎么把边界画出来的
1.1 从“收拢数据”拆出四个核心子问题
项目代号 REA 在团队里没有正式全称,文档里只提了一句“运行数据聚合”,后来我们内部叫它 Resource Event Aggregator。接手后我没有第一时间选技术栈,而是把所有“收拢数据”相关的动词全部列了出来:哪些数据要收、从哪收、收多少、收来干什么。这么一列,问题立刻清楚了。
实际拆解下来无非四块:采集端要适配多少种数据源、缓冲层要扛住多大的峰值、处理层要做多少清洗和归一化、存储和查询要支撑什么样的使用场景。这四块是串行依赖,前面的选择会直接决定后面的模型,所以我决定从最终场景倒推:使用者到底是想在实时大盘上看趋势,还是等出了问题之后做历史回溯?
这个问题的答案直接决定了链路设计的重心。如果要实时看趋势,那端到端延迟必须控制在秒级,整条链路都要为低延迟让路;如果只是事后回溯,那就可以放心大胆地做批量写入、批量重放,很多实时场景下的坑从一开始就能避免。我们当时明确了两类用户都存在的,所以架构上必须同时兼顾实时观察和离线重算。
1.2 非功能需求先谈清楚,再谈技术选型
很多从零开始的内部项目死掉,不是因为功能做不出来,而是需求边界太模糊。一个“把数据收拢起来”,如果不谈规模、不谈延迟、不谈数据保留时间、不谈可用性,那么任何人做出来的方案都可能被一句“这里怎么不支持”给推翻。
我当时拿着一个表格,挨个找相关方确认了几个问题,每一条都很关键。
| 问题 | 为什么必须问 | 我们的拍板结果 |
|---|---|---|
| 高峰期每秒产生多少条数据? | 决定链路每一层的吞吐预算 | 按 5 万条/秒峰值设计 |
| 数据从产生到可查询,允许多久延迟? | 决定实时链路的写入和查询策略 | 绝大多数场景 10 秒内可查 |
| 数据要保留多久? | 决定存储容量和成本 | 热数据 7 天,冷数据 30 天 |
| 丢少量数据可不可以接受? | 决定是否需要端到端对账 | 核心数据不允许丢,允许重复不接受丢失 |
| 查询通常是按什么维度? | 决定索引和分区策略 | 按业务线、节点、时间范围查询为主 |
这几条拍板之后,很多技术选型就不再是拍脑袋了。比如存储层,如果只保留 7 天热数据,那压缩策略、磁盘采购、集群规模全部可以算出来;如果查询维度主要是时间和业务线,那分区键怎么设也基本确定。需求越早量化,后面返工越少,这一点在这个项目里体现得淋漓尽致。
1.3 一版被否掉的初步方案,错在哪里
第一版方案我其实是照着“标准大数据链路”做的:数据进来之后先走一套实时计算引擎,再做一套离线批处理,两套结果合并输出。理由是这套组合拳很成熟,什么场景都能覆盖。但我忽略了一个致命问题——这是一个人力严重不足的内部项目,不是一个大平台。
两套链路意味着两套逻辑、两套运维、两套数据口径,任何一个环节出问题,排障时间都要翻倍。而团队当时连一个专职运维都没有。我把方案讲给一位资深同事听的时候,他只问了一句:你打算凌晨三点起来处理哪套链路的告警?这句话把我问住了。
后来一版方案收敛成“单链路 + 可重放”的设计:数据只走一条主链路,存储原始数据;如果处理逻辑有 bug,就从原始数据重新跑一遍。这样既不需要维护两套代码,又能通过重放弥补错误。这个取舍是整个 REA 项目里我认为最关键的一个决定,后面很多稳定性问题能快速解决,都得益于没有把战线拉得太长。
2. REA 的数据链路设计:每一层我做了什么取舍
2.1 采集端:只做“原样采集”,拒绝在源头清洗
采集端最容易犯的错误,是顺手在采集器里做一堆字段过滤、日志解析、格式转换。比如把日志里的 IP 提取出来,把 JSON 字段拍平,甚至做数据脱敏。当时我也动过这个心思,觉得“都到采集端了,顺便处理一下多省事”。但后来想明白一个道理:采集端离数据源最近,也最不稳定,一旦清洗逻辑出错,原始数据已经被污染,后面再想重放都没有干净的原始数据可用。
所以我定了一条硬规矩:采集端只负责把数据完整、顺序地搬到缓冲层,不做任何语义处理。最多做压缩、加时间戳、记录来源信息。数据清洗和归一化全部统一放到处理层做。这样做的最大收益是,处理逻辑改动之后,随时可以从原始数据重新跑一遍,而不需要重新对接各个节点。
这里我举一个实际例子。我们有一类日志本身就是多行文本,如果采集端按行拆分,就可能把一条完整记录撕成几截。因为采集端只做原样搬运,这个问题从一开始就不会出现。这也是一个典型的新手容易踩的坑:过度在源头处理,导致问题发生时无法回退。
2.2 缓冲层:扛得住峰值的队列,才算合格的缓冲层
数据从几十个节点涌进来,如果直接打到存储层,存储一定会被瞬间的流量尖刺打垮。所以我们中间必须有一层缓冲。缓冲层的作用不只是削峰填谷,它更重要的作用是故障隔离——采集端和处理端之间是解耦的,任何一端重启或者升级,都不影响另一端继续工作。
关于缓冲层的大小,我犯过一个小错误:一开始只看平均每秒的数据量,忽略了分钟级的突发。后来压测发现,某个业务线会在整点时刻产生 3 倍于平均值的流量,而队列容量只按 1.5 倍估算,结果频繁触发背压。
背压这个词新手可能不太理解,我用一个生活化的类比:就像高峰期地铁闸机,前面出站的人多了,后面的人就只能慢慢排。处理端会在拿不到数据的时候通过信号通知采集端“先别发了”,这就叫背压。REA 里的做法是给每一类数据源设置独立的队列,并且把队列容量和消费者数量绑定,当积压超过阈值时,优先保证核心数据的通道,非核心的数据可以暂时降级丢弃——这比把所有数据混在一个队列里,然后把所有通道全部堵死要合理得多。
2.3 处理层与存储层:归一化做到什么程度才算够
数据到了处理层,面临的第一个问题是什么格式入库。不同数据源的时间戳格式千奇百怪:有 Unix 时间戳,有带时区的 ISO 格式,有的甚至只有日期没有时间。如果我们直接原样入库,查询的时候就只能写一堆 CASE WHEN,效率极低。所以处理层做的第一件事是统一所有记录的内部时间戳,默认按接收端时间,同时保留原始时间字段。
处理层需要提取的核心字段包括:数据来源标识、业务线、节点标识、事件类型、事件时间、原始载荷。这里有一个容易忽略的点:不要过度展开原始 JSON 的每个字段。我们当时讨论要不要把所有嵌套字段全部展开成独立列,这个想法看起来很美好,但实际维护成本会非常高:字段一旦变化,表结构就得跟着变,查询端也得跟着改。
最终我们只把最高频查询的字段做成了独立列,其余内容原样存在一个宽字段里。需要深层分析的时候,再用宽字段在查询阶段解析。这个设计保留了对未知结构的兼容性,又不牺牲常规查询的便利,实践下来还是很稳的。
存储层的选择,我们用的是列式存储思路。为什么不用常规的行式存储?因为 REA 这个场景的查询特点是:按时间范围扫大量行,但只取少数几个字段。行式存储在读取时会整行加载,大量无关字段也被读出来,导致 IO 被浪费;列式存储只读需要的列,对这种场景非常友好。再加上布隆过滤器和分区裁剪,查询性能能比行式高出一个量级。
需要注意的一点是,列式存储不擅长高频更新。所以 REA 的写入模型是只追加、不更新。每条记录生成时就带一个全局唯一 ID,后续也不会被修改。下游如果发现某条数据有问题,不是去更新它,而是新增一条对应的“更正事件”。这个模型让写入路径非常简单,存储层也不容易产生碎片。
2.4 查询接口:小步快跑,先做最小可用版本
查询层是最容易被过度设计的。我们最初设想的查询接口非常复杂:支持复杂聚合、支持自定义指标、支持多表关联。但真正上线之后发现,使用者每天都在重复同样几种操作:按业务线 + 时间范围查原始日志、按节点查数据量变化、搜索某个关键字。
于是我们把复杂查询全部砍掉,只做了一个最小可用接口:指定时间范围、指定维度条件、返回原始记录或简单统计。这个接口上线两周后,我们才根据真实使用反馈,慢慢加了聚合能力和自定义字段筛选。
我的经验是:查询接口不要一上来就做成万能工具,而是先给一个能用的版本,让真实使用数据驱动下一步优化。否则花两个月做一个没人用的复杂功能,不仅浪费人力,还会让使用方误以为系统很难用。
3. 部署落地:编排细节决定 SRE 的睡眠质量
3.1 资源预估:先算账,再上机器
部署之前一定要先做资源预估。如果等系统上线后才发现磁盘写满或者带宽不够,那基本就是事故级别的问题了。REA 上线前的预估过程,我把核心公式记录下来:
- 单个采集节点每秒产生约 20MB 原始日志,高峰期可以到 60MB。
- 50 个节点同时接入,平均每秒 1GB 原始数据,一小时就是 3.6TB。
- 原始文本压缩比按 6:1 估算,压缩后一小时约 600GB。
- 热数据保留 7 天,需要压缩后容量约 100TB。
- 存储层再算上索引和副本,实际分配空间按 1.5 倍预留,最终确定 160TB 可用空间。
这个计算过程说明了一件事:峰值带宽和数据压缩比必须实测,不能靠猜。我们最开始按文档里的压缩比估算,实际一测发现跟理想值差了将近一倍,因为我们的日志大部分是重复结构,压缩率比想象中好很多。但过度乐观也会出问题,所以我建议把磁盘预留量至少放宽到估算值的 1.5 到 2 倍,磁盘扩容总是比心理预期慢得多。
3.2 编排配置的组织:配置要进版本库,不要手动改
REA 的所有组件都是容器化部署的。容器化带来的最大好处是环境一致,但也会带来一个新问题:配置管理变得复杂。如果每个组件的配置都散落在各个节点上手动修改,那只要多改几次,环境之间必然出现漂移。
我当时做的最正确的一件事,是把所有配置写进版本库,按照环境和组件拆分目录。每一个组件的配置文件都通过统一的部署流程下发,运行时只读环境变量。任何配置变更都走评审、提交、发布三步,不能在生产环境直接改文件。
global: env: production data_retention_days: 7 checkpoint_interval_sec: 30 collector: source_id: node-01 original_only: true compression: zstd buffer_max_bytes: 524288000 processor: normalizer: timezone: UTC field_policy: keep_nested_raw retry: max_retries: 5 backoff_base_ms: 1000 backoff_factor: 2这套配置组织方式的收益,在第一次故障排查时就体现出来了。我们只需要通过版本库找到对应时间点的配置,就能确定某个异常是否由配置变更引起,而不用到处问人“你昨天是不是改过配置”。这个习惯看起来不起眼,但长期维护一个项目,配置版本化的重要性怎么强调都不为过。
3.3 滚动更新与幂等消费:重启之后日志重复了怎么办
容器化部署还有一个容易被忽略的问题:滚动更新时,组件重启会导致数据重复消费。比如处理端在跑了一批任务之后没有及时提交检查点,重启就会从头重新读一遍数据,下游存储端就会收到重复记录。
这个问题的根源是“at least once”语义。很多分布式系统为了保证不丢数据,都会选择这个语义,代价就是可能重复。解决重复有两种思路:一种是在数据源头生成全局唯一的事件 ID,下游收到后做幂等判断;另一种是让存储端对同一 ID 只保留第一次写入的结果。
REA 的做法是组合拳:每个采集端在记录原始数据时,就生成“来源标识 + 文件路径 + 偏移量 + 序号”组成的幂等键。处理层在写入存储时先检查幂等键,如果已经存在,直接忽略。这个设计虽然增加了一点存储开销,但换来了重启时的从容,不用提心吊胆担心增量重复。
我这里想提醒一句:单纯靠“时间戳 + 内容”做幂等键是危险的,因为内容可能相同,时间戳可能因为时钟偏移而错乱。一定要用来源端的绝对位置信息做幂等,不能用内容本身。
4. 上线后的观测与调优:别被无关指标淹没
4.1 端到端延迟拆解:每一段都值得单独看
REA 上线后,我们做的最重要的一件事,是建立从数据产生到可查询的端到端延迟观测。这个延迟不是单一指标,而是可以拆成几段独立追踪的,每一段出问题都会有完全不同的表象。
首先是采集端到缓冲层。这段延迟主要受网络和批量发送策略影响。如果采集端攒了一批数据才发送,延迟就会增大;如果发送频率太高,带宽压力又会上升。我们在采集端采用时间阈值和大小阈值双重触发:超过 2 秒或者达到 64MB 就发送一次,实测在绝大多数场景下都能做到秒级上传。
其次是缓冲层到处理层的等待时间。这里主要看队列积压量和消费速度。如果消费者数量不足,那么队列就会持续积压,端到端延迟也会同步变大。我们当时遇到过一个蹊跷的现象:整体延迟比预期高了三倍,但处理端 CPU 和内存都正常,查了许久才发现是一个消费者线程被某个外部调用阻塞了。从那之后,我要求所有外部调用必须带超时时间和熔断逻辑。
最后是写入端到存储库的批处理时间。列式存储在批量写入时吞吐很高,但批量过大会导致单次写入时间过长,反而恶化延迟。我们把批量大小和最大等待时间配成一对参数,先到先写,不让任何一侧拖住另一侧。
4.2 数据完整性对账:宁可每天多做一次,也要知道丢没丢
实时链路最怕的就是数据悄悄丢掉。在 REA 项目里,我最终加了一道“窗口对账”的机制。原理很简单:每个采集端按小时统计自己发送了多少条数据,把这个计数上报到对账服务;存储端也按小时统计自己实际接收并落盘了多少条数据。两边一对比,就能发现哪一小时的哪一段数据对不上。
对账本身不需要非常精确,因为单条级的核对在超大流量下成本太高。我们按小时、按来源分组做总数对比,如果差了超过某个阈值,就打一个告警出来,然后由处理端从原始数据重新补跑那一个小时。补跑路径从一开始就预留好了,所以后面真出问题的时候,处理速度非常快。
我强烈建议任何一个需要长期维护的数据项目,都尽早把对账机制加上。不要等到数据出问题才想起来要排查哪一段丢了。做一次全链路对账,你会发现很多之前不知道的隐患,比如某些节点凌晨三点因为内网抖动断连、某些采集端升级后配置丢失。这些都是靠对账才暴露出来的。
4.3 慢查询调优:分区裁剪和字典编码带来的性能提升
查询端的调优,我们的体会也很深。上线初期,查询一个小时的日志通常要 4 到 5 秒,虽然不算慢,但随着数据量增长,很快就恶化到十几秒。排查之后发现,查询居然没有走分区裁剪,因为查询条件里的时间字段被格式转换函数包了一层,索引完全失效。
解决这个问题的方法很简单:不要在查询条件里对时间字段做函数转换,而是让查询条件直接落在原始时间范围上。这样查询引擎才能在第一步就过滤掉大量无关分区,扫描量直接降了一个量级。
另外,我们发现日志中有大量重复度很高的字段,比如业务线名称、节点编号、事件类型,这些字段的数量级很低,就特别适合做字典编码。查询引擎会自动把这些字段转成紧凑的字典 ID,既大幅降低存储空间,又提高了扫描速度。优化之后,同样的查询从十几秒降到了两秒以内,几乎感觉不到等待。
这里的经验是:调优之前一定要先确认查询是否真正利用了分区和过滤机制,盲目加索引往往事倍功半。以时间分区为核心,以低频维度字段为辅助,这是日志类系统最实用的查询优化组合。
5. 踩坑记录:三个让团队连续加班的实际问题
5.1 时钟不同步导致的数据覆盖
REA 项目上线后第一个让人抓狂的问题,表现在查询结果上:同一节点的某条记录,后面那条更新记录反而被“覆盖”掉了,查询出来的一直是旧值。刚开始我们怀疑是存储层的更新逻辑写错了,排查了很久发现存储层根本没有更新操作,所有记录都是追加。
最后定位到根因:不同机器之间的系统时钟偏差很大,有几台甚至慢了好几分钟。处理层统一按“事件时间”排序之后,一个更新事件因为时间戳比前一条还早,被当成了更旧的数据。我们用了接收端时间作为统一的排序基准,同时在上游压缩时保留原始时间,问题才算解决。
这个坑提醒了我:在分布式环境里,永远不要默认所有机器的时钟是一致的。要么统一使用接收端时间,要么在数据里显式携带一个逻辑序号,用序号来定顺序。依赖物理时间的场景,一定要对时钟偏移有心理预期。
5.2 批量写入超时与背压引发的连锁 OOM
第二个印象深刻的问题是处理层内存持续上涨,最后直接内存溢出。根因不是一个参数的问题,而是一整套连锁反应。
事情是这样的:某一次存储端出现性能抖动,批量写入开始超时。处理层的重试逻辑没有退避机制,失败之后立刻原样重试,结果越积越多。积压占满了内存队列,新数据进不来,采集端因为背压也开始堆积,最终整个链路雪崩。
这个问题暴露了我们缺失的两个机制:一是重试必须有指数退避,每次重试间隔翻倍,不能原地疯狂重试;二是处理层必须有一个待发送字节数的硬上限,一旦超过这个上限,宁可丢弃非核心数据,也不能把整个进程拖垮。修复之后,我们做了三次故障注入演练,确定在存储端抖动 30 秒的情况下,处理层内存波动可以控制在 30% 以内,才敢把这个坑标记为关闭。
5.3 升级发布时的重放风暴
最后一个坑发生在某次处理逻辑升级时。新版本修复了一个解析 bug,逻辑上看起来很简单,所以团队直接全量发布了。结果发布后,一批长时间任务重新启动,把过去三天的数据全部重新读了一遍,写入端的瞬时流量直接飙了好几倍,差点把下游存储集群打挂。
这个问题的本质是:处理逻辑变更之后,消费者重启会从最近未提交的检查点继续消费,而检查点的保留策略是把积压任务一次性全部重放。多个消费者同时重放,就形成了风暴。
我们之后的发布流程固定为三步:先灰度一台节点,观察重放流量和下游负载;确认稳定后,分批重启其余节点;同时做重放范围的开关控制,日常只允许重放最近一小时,需要更大范围重放时必须人工确认。这套流程看起来保守,但再也没出现过类似事故。对于长期运行的系统来说,发布流程保守一点,永远比冲得快更重要。
6. 到现在我还会继续优化的几个点
REA 项目跑了一段时间之后,我最大的感受是:一个好的内部系统,不是功能越多越好,而是能够持续稳定地提供核心价值。基于这段时间的经验,我还在持续优化的方向有三个。
第一个是可观测性本身的可观测性。我们已经在监控采集端、处理端、存储端的各项指标,但我更想做的是把整个链路的延迟和丢量情况按照业务线拆分,让任何一条业务线的负责人打开面板就能知道“我的数据现在多久能查到,今天有没有异常”。这种贴近使用者的监控视图,比一堆内部技术指标有价值得多。
第二个是自动扩缩容。目前消费者数量还是靠人工调整,遇到大型促销活动就需要提前手动扩容。我计划根据队列积压的梯度来触发自动扩缩容,同时保证扩容和缩容的速率是受控的,避免出现一批消费者同时启动导致下游抖动的情况。
第三个是数据质量监控。现在对账只能发现数量不一致,很难发现内容层面的质量问题,比如某个字段突然大量为空、某个事件类型占比异常。如果能基于历史数据建立简单的基线推断,任何异常波动都能在影响业务之前被识别出来,那就能把很多问题挡在用户发现之前。
最后分享一个我自己的心得体会:做 REA 这类内部项目,最容易犯的错是追求架构的尽善尽美。实际上,先保证核心链路足够简单、可观测、可重放,就已经赢过了绝大多数方案。架构上省下来的复杂度和维护成本,会以手动排查故障效率的方式回报给你。我现在仍然觉得,“单链路 + 可重放”这个决定是整个项目里做得最值的一件事。