如何更好地设计公司统计平台

chihqiang chihqiang 2026-11-13 约 19 分钟 7287 字

从一条数据库变更到一块经营看板:一套可演进的指标平台设计方法

几乎每家公司都会长出一个“统计平台”:老板要看的经营大盘、运营要看的渠道报表、财务要看的营收口径、客服要查的订单明细。

一开始它可能只是几个查询、几张表格;后来变成定时任务、宽表、缓存、报表服务;再后来,数字开始对不上,没人说得清“转化率”到底按哪个时间算。

这篇文章不聊具体框架和代码,只聊一套能活过三年的统计平台应该怎么设计。文中的经验来自一次真实的订单数据平台重构:从业务库同步到搜索文档,再由指标服务统一对外。


0. 先说结论

如果一个统计平台只能满足“今天这张报表能跑出来”,那它不是平台,是一堆查询脚本。

一套能长期使用的统计平台,通常同时解决四个问题:

  1. 数据从哪来、怎么保证最终一致——采集与同步;
  2. 数据长什么样、能不能被复用——模型与文档;
  3. 指标怎么定义、口径怎么统一——指标引擎;
  4. 怎么展示、怎么下钻、怎么解释——展示层。

下面按这四层展开,最后给一份可以逐项对照的清单。


1. 统计平台到底难在哪

统计平台的复杂度,从来不在“把数字算出来”,而在下面三件事同时成立:

难点表现根因
数据一致性报表数字和业务库对不上、今天对明天错多数据源、多时间字段、重复或乱序的变更
口径一致性两个页面同一个指标差 3%,没人说得清谁对口径散落在查询、前端、表格和口头约定里
性能与可解释大区间查询超时、点开明细对不上总数宽表缺失、下钻条件和聚合口径不一致

更麻烦的是,这三件事会互相放大:

  • 为了性能做预聚合,口径就被固化,改一次要重跑历史;
  • 为了灵活做实时关联,口径就散落在每个查询里;
  • 为了口径统一做宽表,数据新鲜度和存储成本又上来了。

所以设计统计平台,本质是在做取舍:哪些东西固化、哪些东西实时算、哪些东西允许不一致。


2. 整体架构:四层加一条主链路

一套典型的统计平台可以拆成四层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌────────────────────────────────────────────────────────────┐
│ 展示层 运营后台 │
│ 页面壳 → 业务组件 → 统一请求层 → 同源代理 │
└──────────────────────────▲─────────────────────────────────┘
│ 统一响应:业务码 + 数据 + 追踪号
┌──────────────────────────┴─────────────────────────────────┐
│ 指标层 指标服务 │
│ 指标注册 → 条件映射 → 时间桶 → 聚合 → 间接指标 → 下钻 │
└──────────────────────────▲─────────────────────────────────┘
│ 只读
┌──────────────────────────┴─────────────────────────────────┐
│ 模型层 搜索文档(宽文档) │
│ 订单、商品、支付、退款、物流、渠道、会员、标签…… │
└──────────────────────────▲─────────────────────────────────┘
│ 幂等写入
┌──────────────────────────┴─────────────────────────────────┐
│ 采集层 变更同步服务 │
│ 数据库变更 → 消息队列 → 任务路由 → 回库读当前状态 │
└──────────────────────────▲─────────────────────────────────┘
│ 变更日志
业务数据库

四个关键设计决定:

  1. 采集用事件驱动,但文档内容以数据库当前状态为准;
  2. 模型用宽文档,而不是查询时多表关联,一次写入、多次读取;
  3. 指标用注册表加引擎,口径写在配置和实现里,不是散落的查询片段;
  4. 展示层不写口径,只做参数、展示和交互。

3. 采集层:把“同步”当成事件处理

3.1 事件只是线索,文档以库为准

很多同步方案会把变更日志里的新值直接拼成搜索索引里的一条记录(后文简称“文档”)写进去。这样做很快,但有一个致命问题:事件是增量的,文档需要的是全量。

订单文档里有一个字段来自订单主表,另一个来自退款表,还有一个来自评价表。当退款表变更时,事件里只有这一张表的新值,拼不出完整文档。

所以更稳妥的做法是:

1
收到变更 → 只取“哪张表、哪个主键变了” → 回业务库读当前完整状态 → 拼文档 → 写入

好处:

  • 重复投递、乱序投递、补数、重放,最终写进去的都是当前真实状态;
  • 不需要维护“事件字段到文档字段”的映射,模型改字段不用改消费逻辑;
  • 出问题可以随时按主键重跑。

代价:

  • 一条订单要查十几张表,天然存在大量小查询;
  • 对数据库压力大,必须做批量与并发。

这个取舍在统计场景是划算的:统计要的是“对”,不是“快一秒”。

3.2 任务抽象:主表加子表,两种运行方式

采集服务只做一件事:把一张主表和它的子表,按变更同步成一份文档。

抽象成约定就是:

  • 一张主表,声明主键;
  • 若干子表,声明它们指向主表的关联列;
  • 实时链路按“库名加表名”路由到对应任务;
  • 补数链路按时间列分页取主键,再走同一套拼装逻辑。

新增一张业务表,只需要加一个任务声明加一个拼装片段,所有运行方式自动生效:实时同步、按主键补、按区间补、定时补偿。

3.3 幂等:写入可重复,删除不改历史

统计平台的写入必须满足两个条件:

  1. 重复执行结果不变(幂等);
  2. 删除不改变历史口径。

第一点靠“合并更新加冲突重试”实现:同样的文档写十次,结果和写一次一样。

第二点更反直觉:订单被删除时,不删搜索文档,而是写一个“排除”标记。

原因很简单:报表是按历史区间重算的。如果删除文档,昨天的报表今天重算就会变;打标之后,“这单存在过、但被排除了”这件事本身就是历史事实。

代价是索引里会留下一些只带排除标记的占位文档,查询必须默认带上排除条件。这是用存储换口径稳定的典型取舍。

3.4 补数:稳定分页加多时间列并集

补一段历史区间,最容易踩的坑是:

  • 用偏移量分页——越翻越慢,还会漏;
  • 只按创建时间过滤——退款、关闭、发货的订单补不到。

更稳妥的做法是:

  • 按主键游标分页,每页固定条数,永远从上一页最后一个主键往后取;
  • 把多个时间列一起纳入筛选:创建、支付、关闭、发货、完成、退款,只要任一落在区间内就要补。

区间语义固定为“左闭右开”,时间统一用秒级时间戳。补数任务和实时链路共用同一套拼装逻辑,所以补出来的文档和实时写的一模一样。

3.5 定时补偿:窗口加游标

实时链路会丢事件(消费端重启、消息重复或乱序、下游限流),所以还需要一层兜底:定时按窗口补。

  • 每分钟补“刚结束的一分钟”,每小时补“刚结束的一小时”,每天补“刚结束的一天”;
  • 每个窗口维护一个游标,成功才推进,失败下次从失败处重试;
  • 同一任务加锁,上一轮没跑完就跳过,而不是排队。

这里有一个经典坑:一条坏数据会让游标永远停住。区间里只要有一条订单持续报错,每分钟都会从同一个起点重跑。

解法不是“忽略错误”,而是:

  • 单条失败记错误、继续处理其他订单;
  • 区间结束统一返回聚合错误;
  • 对已知脏数据做白名单或降级,而不是让整个窗口卡死。

4. 模型层:文档模型是数据契约

4.1 为什么用宽文档

统计查询的典型形态是:按时间、按维度、按指标聚合。如果每次都关联十几张表,查询会又慢又难优化。

宽文档(把多张表的数据拼成一份文档)的思路是:写入时把关联做完,读取时只做过滤和聚合。

一份订单文档里通常包含:

部分用途
订单主体时间、状态、金额、类型
商品项品类、SKU、数量
会员会员等级、客户维度
退款退款金额、完成率
评价评分、投诉、催发货
来源渠道、分享
标签排除、售后、测试单

宽文档的代价是模型变更成本高:加一个字段要改写入端、索引模板、指标端、前端。所以模型必须被当成契约来管理。

4.2 片段化:把“拼文档”拆成可插拔模块

最初的实现往往是一个几百行的函数,从上到下把十几张表查一遍。改一个字段要在几百行里找位置,加一张表要动三处。

更好的做法是拆成十几个“片段”,每个片段只负责一张表(或一组相关表),只做三件事:

  • 从库里读出自己的数据;
  • 把数据写到文档的对应位置;
  • 失败时决定是中止整单,还是只告警。

拆分的价值不在代码行数,而在于变更边界:新增字段只加一个片段,注册一行,其他片段不受影响。

4.3 两条隐式契约

片段方案有两个必须写进文档的约束。

第一,注册顺序是契约。

  • 主表片段必须第一个跑,且失败时中止(主表失败整单不可用);
  • 标签片段依赖字段片段、商品片段、退款片段的结果;
  • 分享片段依赖记录片段和来源片段。

这些依赖没有类型系统保护,只能靠注释和评审。所以拼装器顶部必须写清楚顺序规则,并且给每个片段写一句“我依赖谁”。

第二,空值必须显式写成空,不能省略。

因为写入用的是合并语义:

  • 字段显式写空,清掉上一次的值;
  • 字段被省略,旧值永远留着。

这就是为什么文档模型里几乎所有可选字段都要显式输出空值,并且有专门的测试锁定“空片段必须序列化成空”。

4.4 容错分级

不是所有字段都同等重要:

  • 主表失败 → 整单失败,打错误日志,等待重试;
  • 附属表失败 → 只告警,字段置空,文档照常写;
  • 慢片段 → 打日志,用于定位大量小查询。

这个分级让“少数字段缺失”不会阻塞整条链路,也让问题可以被观测到,而不是静默降级。


5. 指标层:口径写在一处

5.1 指标只有三类

指标引擎里只有三样东西:

  • 直接指标:一个聚合查询、一个时间字段、一个取值方式;
  • 间接指标:依赖若干直接指标,加一个计算规则(比率、差值、求和、换算);
  • 指标目录:按业务域把指标分组,供查询和展示使用。

例如“支付率”,就是“支付量”除以“下单量”;“营收”就是“支付金额减退款金额,再加其他调整项”。口径只有一处,可组合、可测试。

好处:

  • 口径唯一:分子分母、时间窗口、过滤条件都在一处;
  • 指标可组合:复杂指标由简单指标拼出来;
  • 可测试:间接指标只做计算,不依赖搜索集群。

5.2 每个指标自带时间字段

这是统计平台最容易忽略的设计点。

“下单量”按创建时间算,“支付量”按支付时间算,“退款额”按退款完成时间算。如果引擎只支持一个全局时间字段,就会出现两种坏结果:

  • 要么所有指标都按同一个时间算,口径错;
  • 要么给每个指标单独发一次查询,性能差。

正确做法是:时间字段是指标的属性,不是请求的属性。

一次请求里,引擎收集所有指标的时间字段,分别建时间桶(按时间切分的统计区间),最后按时间合并。间接指标必须在跨时间桶合并之后再计算,因为分子分母可能来自不同的时间桶。

5.3 条件下推:多时间字段用并集

如果只按时间桶过滤,搜索集群会先扫描整个索引再聚合。为了减少扫描量,可以把时间范围下推到查询层,多个时间字段之间用“或”的关系取并集。

这样任何会进入某个时间桶的文档,必然在它自己的时间字段上落在区间内——扫描集变小,结果不变。

同时要注意:间接指标的明细下钻不能用单一时间字段,否则会漏掉“只在退款窗口命中”的订单。

5.4 字段映射:逻辑字段到物理字段

指标层对外暴露的应该是逻辑字段,比如店铺、渠道、品类、品牌;内部维护一张映射表,把逻辑字段翻译成索引里的物理字段。

好处:

  • 索引结构变化(字段改名、加子字段)不影响前端;
  • 同一套参数可以跨多个数据域复用;
  • 下钻时按同样的映射改写条件,指标和明细永远一致。

5.5 查询条件宁严勿宽,不能静默放宽

这是指标层最危险的一类问题:用户传了一个非法条件,引擎静默丢掉,结果返回全量数据。

比如“店铺集合为空”——本来想查“没有权限的店铺”,结果变成“不筛选”,数据全出来了。

正确做法是让非法条件永远不命中:条件写错、取值集合为空、权限范围为空,都应该返回零行,而不是退化成无筛选。

原则:查询条件可以更严格,但不能更宽松。宁可少给数据,不能多给。


6. 展示层:页面是壳,口径只有一份

6.1 页面薄,组件厚

在成熟的后台里,大部分页面文件不到十行,只负责“挂哪个组件”;筛选、请求、弹窗、下钻全在业务组件里。

好处:

  • 页面权限、菜单、面包屑可以统一处理;
  • 组件可以复用到“页签”“弹窗”“下钻页”等不同容器;
  • 路由变更不影响业务逻辑。

6.2 一个代理层,收敛所有请求

所有接口调用都走同一层:组件调用服务函数,服务函数走统一请求层,请求层走同源代理,最后到网关。

请求层只做几件事:拼查询参数、注入凭证、统一错误码、刷新登录态、解包响应。代码不多,却支撑了全部接口调用。

关键设计:

  • 响应解包只做一次:业务码非零就抛错,业务层不用重复判断;
  • 登录态刷新用单例:并发请求同时失效时,只发一次刷新,避免把刚换新的凭证冲掉;
  • 按业务码分流:同样是请求冲突,有的是“重复提交”,有的是“需要人工确认”,HTTP 状态表达不了,必须看业务码。

6.3 口径共用件

报表页面最怕“两个页面同一个指标数字不一样”。根因通常是:每个页面自己算环比、自己拼参数、自己处理汇总行。

解法是抽一个“口径共用件”:

  • 汇总行固定标识,固定置顶、加粗;
  • 环比窗口只认后端返回的值,前端不自己算;
  • 缺失值、未分组值统一处理;
  • 下钻条件、导出条件、明细条件用同一份参数。

原则只有一句:口径只此一份。抄第二份,就等于埋一个两页数字看起来不一致的雷。

6.4 下钻:指标和明细必须同一套条件

统计平台最常被质疑的一句话是:“你这个数点开,明细怎么对不上?”

根因是:指标用聚合算,明细用文档检索查,两边条件写法不同。

约束是:指标的下钻条件由指标定义决定,明细只复用同一套条件。

  • 指标只有一个最外层过滤条件时才支持明细下钻;
  • 多一层过滤的指标直接标记“不支持”,而不是给一张对不上的表;
  • 明细分页用游标,不用偏移量。

6.5 前端缓存与状态

统计页面数据量大、筛选多,缓存策略要克制:

  • 指标字典、店铺、渠道分组这类“低频变化”数据,用共享快照加本地缓存;
  • 页面数据不缓存,靠参数驱动重新请求;
  • 大盘配置存浏览器本地,不落服务端(降低后端复杂度,代价是换设备丢失)。

7. 治理与可运维性

平台能不能长期活下去,取决于四件事。

7.1 指标字典

每个指标必须有:

  • 唯一标识、中文名;
  • 口径说明(时间字段、分子、分母、过滤条件);
  • 对应的实现位置;
  • 是否支持下钻。

指标字典要和实现保持同源:新增或修改指标时同步更新,评审时逐项对照。

7.2 变更流程

  • 新增指标:只新增定义,不改引擎;
  • 修改口径:必须新建指标标识,旧指标保留(历史报表不能变);
  • 修改模型:先加字段(兼容),再切读,最后删旧字段。

7.3 可观测性

统计平台的日志要能回答三个问题:

  1. 这次查询慢在哪?(准备还是写入、扫描量、聚合规模)
  2. 哪个片段或指标出了问题?(带任务名、指标名、订单号)
  3. 这次失败该找谁?(下游错误码加原话回带)

几个具体做法:

  • 区间补数日志打出“准备、写入、耗时”,一眼看出瓶颈;
  • 单个片段超过阈值打慢日志;
  • 每次查询打一行“形状”:时间字段数、指标数、聚合规模;
  • 错误码按依赖分段(取数失败、快照没读到、依赖未配置……),而不是统一 500。

7.4 成本与留存

  • 统计索引按月分索引,保留一段时间,定期清理;
  • 明细查询限制时间跨度和返回条数;
  • 导出走异步任务加流式下载,避免大查询打爆服务。

8. 十条设计原则

如果你正在设计或重构公司统计平台,可以拿这十条逐项对照:

  1. 事件只是线索,文档以库为准:重复、乱序、补数都安全。
  2. 写入必须幂等:合并更新加冲突重试;删除用打标,不删历史。
  3. 模型是契约:宽文档一次写、多次读;空值显式写空,不能省略。
  4. 抽象跟着变化频率走:一张表一个片段、一个指标一个注册项、一个页面一个壳。
  5. 时间字段是指标的属性:不要用一个全局时间字段套所有指标。
  6. 口径写在一处:指标定义集中管理,间接指标只做计算,可测试。
  7. 条件只能更严格,不能更宽松:非法条件要落到“永远不命中”,而不是被忽略。
  8. 环境差异集中管理:不同部署环境、不同依赖版本,只留一个出口。
  9. 指标和明细同一套条件:不支持下钻就明确标记,不要给对不上的明细。
  10. 先观测,再优化:把耗时拆开、把慢查询打出来,再决定并发、批量还是加索引。

9. 结语

统计平台没有银弹。

它是一堆很具体的取舍:宽文档还是实时关联、预聚合还是现算、删除还是打标、并发还是排队、兼容还是重写。

每做一个取舍,都要问自己两个问题:

  • 三年后,这个决定会让口径更稳定,还是更脆弱?
  • 出问题时,能不能从一条日志定位到一处逻辑、一个订单?

把这两个问题回答好,平台就能从“能跑”走到“可信”。

分享: Twitter Facebook
chihqiang

chihqiang

代码与生活的点滴记录