4. 用量数据管道与存储

用量计量系统每天要处理的事件量往往在千万到数十亿级别,这些事件从客户端、网关或服务端埋点产生,经过采集、传输、清洗、聚合,最终落到可查询的存储中供计费、配额和账单使用。整条链路的核心矛盾是:事件是持续、无序、可能重复到达的,而计费结果必须准确、可重算、可追溯。本节围绕这条数据管道,讲清楚流式与批式如何分工、去重和聚合在哪里做、乱序怎么处理,以及数据如何分层存储并实现冷热分离。

流式与批式的分工

用量数据管道通常采用 Lambda 或 Kappa 架构的变体。流式处理负责低延迟的实时用量展示、配额扣减和异常告警,延迟目标一般在秒级到分钟级;批式处理负责 T+1 的账单结算、对账和重算,要求结果精确且可重复。两者共享同一份原始事件,但计算路径不同。

流式侧典型链路是:事件写入 Kafka(按 tenant_id + metric_key 分区保证同一计量维度有序),Flink 或类似引擎消费后做去重、窗口聚合,结果写入 Redis 或 OLAP 数据库供实时查询。批式侧则从对象存储中的原始事件或 Kafka 归档中读取,用 Spark 或 Flink 批模式做全量重算,结果写入计费库。关键原则是:流式结果用于“看”和“控”,批式结果用于“算钱”,两者必须能对账,差异要可解释。

事件源 API / 网关 / 埋点 Kafka 事件总线 按 tenant+metric 分区 流式处理 (Flink) 去重 → 窗口聚合 延迟 秒级~分钟级 实时用量 / 配额扣减 批式处理 (Spark) 全量重算 / 对账 延迟 T+1 账单结算 / 精确计费 热存储 Redis / ClickHouse 近 7~30 天明细 实时查询 / 配额校验 冷存储 S3 / Parquet 归档 历史明细 / 审计追溯 低成本 / 按需加载 计费聚合库 按租户+指标+周期 账单 / 发票数据源 乱序与去重关键机制 • event_id 幂等去重(Redis SETNX / 布隆过滤器) • Watermark 容忍窗口:允许迟到 5~15 分钟 • 迟到事件进入侧输出流,批式重算时合并 • 聚合键:tenant_id + metric_key + 时间窗口 冷热分离策略 • 热数据:近 30 天,SSD/内存,支持高并发查询 • 温数据:30~180 天,普通磁盘,按需查询 • 冷数据:180 天以上,对象存储,归档压缩 • 生命周期策略自动迁移,查询层透明路由

去重:幂等键与状态管理

重复事件是分布式采集的常态:客户端重试、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 天的分区自动迁移到冷存储;查询层根据时间范围路由到不同存储,对上层应用透明。这样既控制了成本,又保证了常用查询的性能。

去重、聚合、乱序处理三者是耦合的:去重状态 TTL 必须大于 Watermark 容忍时间,否则迟到事件可能被误判为新事件;聚合窗口必须与批式重算窗口对齐,否则流批结果无法对账。设计时要统一这三个参数。

对账与可观测性

管道上线后必须能回答“流式和批式结果差多少、差在哪里”。建议每天跑一次对账任务,按 tenant_id + metric_key + 日期 对比流式聚合值和批式聚合值,差异超过阈值(例如 0.1%)就告警。同时监控 Kafka 消费延迟、Flink 反压、迟到事件比例、去重命中率等指标,这些是判断管道健康度的关键信号。

处理阶段关键参数典型值作用
去重dedup TTL30 分钟覆盖重试和乱序窗口
窗口聚合窗口大小5 分钟 / 1 小时平衡延迟与吞吐
乱序容忍allowedLateness10 分钟接收迟到事件
热存储保留天数30 天支撑实时查询
冷存储归档格式Parquet + Snappy低成本长期保存

整条管道的设计目标可以概括为:流式保实时,批式保准确,去重保幂等,Watermark 保乱序,分层保成本。这五个环节的参数需要一起调优,任何一处单独优化都可能破坏整体一致性。