4. 用量数据管道与存储
用量计量系统每天要处理的事件量往往在千万到数十亿级别,这些事件从客户端、网关或服务端埋点产生,经过采集、传输、清洗、聚合,最终落到可查询的存储中供计费、配额和账单使用。整条链路的核心矛盾是:事件是持续、无序、可能重复到达的,而计费结果必须准确、可重算、可追溯。本节围绕这条数据管道,讲清楚流式与批式如何分工、去重和聚合在哪里做、乱序怎么处理,以及数据如何分层存储并实现冷热分离。
流式与批式的分工
用量数据管道通常采用 Lambda 或 Kappa 架构的变体。流式处理负责低延迟的实时用量展示、配额扣减和异常告警,延迟目标一般在秒级到分钟级;批式处理负责 T+1 的账单结算、对账和重算,要求结果精确且可重复。两者共享同一份原始事件,但计算路径不同。
流式侧典型链路是:事件写入 Kafka(按 tenant_id + metric_key 分区保证同一计量维度有序),Flink 或类似引擎消费后做去重、窗口聚合,结果写入 Redis 或 OLAP 数据库供实时查询。批式侧则从对象存储中的原始事件或 Kafka 归档中读取,用 Spark 或 Flink 批模式做全量重算,结果写入计费库。关键原则是:流式结果用于“看”和“控”,批式结果用于“算钱”,两者必须能对账,差异要可解释。
去重:幂等键与状态管理
重复事件是分布式采集的常态:客户端重试、Kafka 至少一次投递、网络抖动都可能导致同一事件被多次处理。去重的核心是给每个事件一个全局唯一的 event_id,并在处理端维护已处理集合。
流式侧常用两种方案。第一种是 Redis SETNX:以 dedup:{event_id} 为键,设置 TTL 为窗口容忍时间的 2~3 倍(例如 30 分钟),写入成功才继续处理。第二种是 Flink 的 KeyedProcessFunction 配合状态后端,按 event_id 做 key,状态中记录是否已处理,状态 TTL 同样设为 30 分钟。对于超大规模场景,可以用布隆过滤器做前置过滤,但布隆过滤器有假阳性,只能作为第一层,不能替代精确去重。
批式侧去重更简单:在 Spark 中对原始事件按 event_id 做 dropDuplicates,或者用窗口函数 row_number() over (partition by event_id order by ingest_time) 取第一条。批式去重是精确的,但代价是全量扫描,所以通常只在 T+1 重算时执行。
聚合:窗口选择与增量计算
用量聚合的粒度取决于计费模型。常见的聚合维度是 tenant_id + metric_key + 时间窗口,窗口可以是 1 分钟、5 分钟、1 小时或 1 天。流式侧用滚动窗口或滑动窗口做增量聚合,每来一条事件就更新累加值;批式侧则按天分区做全量聚合。
一个容易忽略的点是聚合的可重算性。流式聚合结果写入 Redis 后,如果发现某段时间数据异常,不能直接修改 Redis 中的值,而应该触发批式重算,用批式结果覆盖流式结果。因此聚合表需要保留 source 字段(stream/batch)和 version 字段,查询时优先取 batch 版本。
乱序处理:Watermark 与侧输出
事件时间(event_time)和到达时间(ingest_time)往往不一致。移动端上报可能延迟几分钟到几小时,跨地域传输也可能造成乱序。Flink 用 Watermark 来定义“事件时间进展到哪了”,Watermark 之前的窗口可以触发计算,之后到达的事件被视为迟到事件。
工程上通常设置 allowedLateness 为 5~15 分钟,允许窗口触发后继续接收迟到事件并更新结果。超过允许迟到时间的事件进入侧输出流(side output),写入一个单独的迟到事件主题。批式重算时,会把这些迟到事件合并进对应时间窗口重新聚合,保证最终一致性。
// Flink 窗口聚合示例(伪代码)
stream
.keyBy(e -> e.tenantId + ":" + e.metricKey)
.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.allowedLateness(Time.minutes(10))
.sideOutputLateData(lateTag)
.aggregate(new UsageAggregator(), new UsageWindowFunction())
.addSink(redisSink);
// 迟到数据侧输出
lateStream.addSink(kafkaLateTopicSink);
数据分层存储与冷热分离
用量数据有明显的访问热度衰减:最近 7 天的数据被频繁查询用于实时看板和配额校验,30 天前的数据主要用于账单核对,180 天前的数据几乎只在审计或纠纷时才会被访问。因此存储必须分层。
热层用 Redis 或 ClickHouse,保存近 30 天的聚合结果和明细索引,支持毫秒级查询。温层用普通关系库或列存,保存 30~180 天的数据,查询延迟在秒级可接受。冷层用对象存储(S3/OSS)保存 Parquet 格式的原始事件和归档聚合,成本极低,但查询需要先加载再计算,适合离线分析。
冷热分离的工程实现通常依赖生命周期策略:Kafka 中的原始事件保留 7 天后自动归档到对象存储;ClickHouse 中的明细表按天分区,超过 30 天的分区自动迁移到冷存储;查询层根据时间范围路由到不同存储,对上层应用透明。这样既控制了成本,又保证了常用查询的性能。
对账与可观测性
管道上线后必须能回答“流式和批式结果差多少、差在哪里”。建议每天跑一次对账任务,按 tenant_id + metric_key + 日期 对比流式聚合值和批式聚合值,差异超过阈值(例如 0.1%)就告警。同时监控 Kafka 消费延迟、Flink 反压、迟到事件比例、去重命中率等指标,这些是判断管道健康度的关键信号。
| 处理阶段 | 关键参数 | 典型值 | 作用 |
|---|---|---|---|
| 去重 | dedup TTL | 30 分钟 | 覆盖重试和乱序窗口 |
| 窗口聚合 | 窗口大小 | 5 分钟 / 1 小时 | 平衡延迟与吞吐 |
| 乱序容忍 | allowedLateness | 10 分钟 | 接收迟到事件 |
| 热存储 | 保留天数 | 30 天 | 支撑实时查询 |
| 冷存储 | 归档格式 | Parquet + Snappy | 低成本长期保存 |
整条管道的设计目标可以概括为:流式保实时,批式保准确,去重保幂等,Watermark 保乱序,分层保成本。这五个环节的参数需要一起调优,任何一处单独优化都可能破坏整体一致性。