1. 项目代号背后的需求博弈
1.1 “rea”到底是什么
拿到“rea”这个项目代号的时候,团队里开了个短暂的碰头会。三个字母,没有任何上下文,第一反应是“read”少了字母,第二反应是有人拼错了React,第三反应是某个框架里的术语缩写。这种模糊感其实挺常见的,很多项目在立项阶段都只有一个代号,后面的一切全靠推导。
我当时做了一件事:列了一个“可能的含义清单”。比如:
- rea= Realtime Experience Analytics(实时体验分析)
- rea= Resource Exploration API(资源探测接口)
- rea= Read/Evaluate/Act(读-评估-执行,某种决策循环)
- rea= 某个内部系统的缩写,连命名人都忘了原意
这种清单看起来很笨,但非常有效。因为它逼着每个参与者把自己的预期放到台面上,而不是各自抱着一个理解去做开发。讨论之后我们发现,真正能让团队共振、也有商业价值的,是Realtime Experience Analytics这条线——一套面向Web应用的用户体验实时监控系统。
为什么选它?因为当时团队已经有一套定时统计报表,每天凌晨跑批,第二天早上看数据。但业务方提了个很尖锐的问题:昨天下午线上活动页面卡了,为什么今天早上才告诉我们?这个问题暴露的不只是技术债,而是整个监控体系的时效性缺陷。所以“实时”这个属性就成了项目的核心锚点,而“体验分析”则是业务价值的落点。
1.2 从模糊到清晰:锁定核心用户与场景
项目代号确定方向后,下一步就是定义“给谁用、解决什么问题”。这部分我踩过不少坑,早期做数据类项目时习惯先聊技术再聊场景,结果做出来的东西像个技术demo,业务方根本用不上。这次我们把场景摆在了最前面。
核心用户是三类人:
- 前端研发:想知道页面加载慢是网络问题、接口问题还是渲染问题
- 后端研发:想知道某个接口在某段时间内是否成为瓶颈
- 运营/产品:想知道用户是否真的完成了关键转化动作,流失点在哪
围绕这三类用户,我们抽象出几个必做的能力:
- 采集页面性能指标(首屏时间、DOM就绪、接口耗时)
- 采集用户行为链路(点击、停留、跳转、提交)
- 实时聚合与告警(某个指标超过阈值立刻通知)
- 事后回溯(按用户会话查看完整操作序列)
这个“用户-场景-能力”的三角拆解是后续所有技术决策的出发点。说白了,技术选型不是看哪个框架流行,而是看谁能在最短时间内稳定支撑这三块。后来我们砍掉了很多“锦上添花”的需求,比如复杂的漏斗分析、用户分群画像,因为那些会显著拉长项目周期,而且初期大家根本用不上。
2. 技术选型:一套能支撑实时的管道
2.1 为什么是“前端埋点 + 消息队列 + OLAP存储”三段式
实时体验分析类的系统,业界常见的做法是分段处理:采集端负责数据产生与初步加工,传输层负责缓冲与削峰,存储与计算层负责聚合和查询。我们最终选定的链路是:前端SDK → 网关 → 消息队列 → 消费与清洗 → 实时聚合/OLAP存储 → 查询API与看板。
这个三段式结构解决了三个核心问题:
- 解耦采集与消费。前端产生的事件量是脉冲式的,白天活动期间可能每秒上万条,凌晨可能只有几十条。如果采集端直连数据库,要么数据库扛不住高峰,要么闲时资源浪费。中间加一层消息队列,天然起到了削峰填谷的作用。
- 让实时和离线并存。同一份数据进入消息队列后,可以同时被实时链路和离线链路消费,不用维护两套采集逻辑。
- 容错与重放。数据管道上游挂了,消息队列里积压的数据不会丢,恢复后可以继续消费,这是直接写入数据库无法天然实现的。
2.2 关键组件的选型和参数心得
这里逐个说说每个环节的具体选型思路。先说前端SDK,我们选择了自研一个轻量级的SDK,而不是直接嵌入第三方商业方案。原因很简单:数据主权和灵活性。我们需要采集特定的业务事件(比如用户点击了某个业务模块),并且要把SDK的体积控制在很小的范围内,第三方方案很难同时满足这两点。
后端语言选了Java和Spring Boot,不是因为它最“潮”,而是团队存量技术栈就是这组,上手快、排查问题资料多。模块间通信用了Spring的异步事件机制,跨进程的部分依赖消息队列。
消息队列选了Kafka。选型对比时也看过RocketMQ等方案,最终因为Kafka吞吐高、生态成熟、消费模型灵活而定。容量规划上我们做了一个粗略估算:假设日均5000万事件,峰值是均值的5倍,单事件平均1KB,那么峰值每秒约3000条消息,Kafka集群三节点足够。这个估算在后面性能测试中验证过,基本准确。
OLAP存储我们选了ClickHouse。这个选择是经过两类方案对比后的结果:一类是传统MySQL分库分表,一类是ClickHouse。MySQL对于这种带有大量维度字段、需要秒级聚合的查询场景,即使分了库表,查询性能和扩展性也会随着数据量增长迅速恶化。ClickHouse的列式存储和向量化执行在处理这种分析型查询上有数量级的优势。当然代价是我们得为它配套独立的冷热备份机制,这个后面会讲到。
| 环节 | 选型 | 核心考量 |
|---|---|---|
| 数据采集 | 自研轻量JS SDK | 体积可控、事件自定义、采样率可控 |
| 异步缓冲 | Kafka | 高吞吐、可重放、多消费者 |
| 清洗加工 | Java/Spring Boot消费者组 | 与团队技术栈一致、生态完善 |
| 存储 | ClickHouse | 列式存储支持秒级聚合 |
| 查询与看板 | Spring Boot API + Web | 快速搭建、灵活定制 |
3. 核心模块的实操实现
3.1 前端埋点SDK的设计与实现
SDK设计的第一原则是“不要阻碍主业务”。这意味着脚本必须异步加载,不能同步阻塞页面渲染;上报必须走SendBeacon或图片请求这类不干扰主线程的方式;所有内部错误必须捕获并静默处理,绝不能让埋点代码的异常暴露到用户控制台。
我们实现了几种常见的采集项。性能指标用浏览器的PerformanceTiming和PerformanceObserver,事件监听用原生addEventListener,会话标识用cookie或者localStorage存储一个UUID,每次页面加载生成新的会话ID。
一个比较关键的细节是采样率控制。全量埋点数据量太大,而且很多用户的行为特征高度相似,所以我们对普通访问做了动态采样:默认50%采样率,当某个页面流量突然增大时自动降低采样率到10%。这个逻辑放在SDK内部,后端通过配置接口下发,切流量非常方便。
SDK的代码结构大致是这样的思路:
// sdk核心入口:收集数据后统一走report管道 class ReaSDK { constructor(config) { this.config = Object.assign({ endpoint: '', sampleRate: 0.5 }, config); this.sessionId = this.getSessionId(); this.bindEvents(); } bindEvents() { // 监听点击、错误、性能等事件 window.addEventListener('click', this.handleClick.bind(this), true); window.addEventListener('error', this.handleError.bind(this), true); } handleClick(e) { if (Math.random() > this.config.sampleRate) return; const payload = { type: 'click', sessionId: this.sessionId, path: location.pathname, target: e.target ? e.target.tagName : '', timestamp: Date.now() }; this.report(payload); } report(data) { const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); navigator.sendBeacon && navigator.sendBeacon(this.config.endpoint, blob); } }实操中注意几个点:事件监听建议用捕获阶段而不是冒泡阶段,因为捕获阶段能更早拿到事件,且不会被业务侧的stopPropagation拦截;上报数据要做数组批量打包,前端攒够10条或2秒时间窗口再发一次,能显著减少请求量。
3.2 后端数据接入与聚合的方案
后端主要分两个部分:数据接入和实时聚合。数据接入层负责接收SDK上报的HTTP请求,做基本的格式校验、字段补全、内容过滤,然后写入Kafka。这一步设计得尽量简单,避免复杂的业务逻辑,目的只有一个:高并发写入时不丢不重不阻塞。
实时聚合这一层,我们用了两种路径。路径一是流式聚合,消费Kafka后直接做窗口计算,比如每分钟计算一次某个页面的平均加载耗时,结果写入Redis或ClickHouse的汇总表,供看板查询;路径二是明细存储,原始事件全量写入ClickHouse明细表,用于事后下钻分析。两条路径并行,互不干扰。
流式聚合这块我们用了一个比较朴素的方案:消费者线程在内存中维护一个ConcurrentHashMap作为窗口计数器,窗口大小定为10秒,每个窗口结束后把计数结果批量写入聚合表。用10秒这个值的考量是:窗口太大,指标的实时性打折;窗口太小,状态管理和持久化成本高。实际运行下来10秒窗口在实时性和开销之间取得了不错的平衡。
聚合写入ClickHouse时,为了避免高频小批量写入引发Too many parts的问题,我们攒批到5000条或5秒才提交一次。这两个参数可以直接写在ClickHouse的异步插入配置里,也可以由应用层控制。我们选择了应用层控制,因为这样能更灵活地调节批量大小和吞吐量。
数据接入流程:
- 前端SDK以JSON格式批量POST到/collect接口
- 网关层做Basic Auth校验和限流(每IP每秒100次)
- 接入服务校验字段完整性,补齐客户端IP、UserAgent、服务端时间
- 数据序列化后发送到Kafka的rea-events-topic话题
- 消费者组实时消费,一路走聚合写Redis,一路原样写ClickHouse
这套流程上线后,高峰期每秒能够稳定处理5000+条数据,P99延迟在120ms以内。支撑这个吞吐量的关键在于每个环节都做了解耦,任何一环的抖动都不会直接拖垮整体的采集链路。
4. 数据可靠性建设:从“丢数据”到“可追责”
4.1 消费幂等与数据补偿机制
消息队列用久了大家都会遇到一个经典问题:Consumer挂了重启,或者上游重复发送,数据到底会不会丢,会不会重复?Kafka的At Least Once语义保证的是“至少一次”,也就是说它不保证“正好一次”,在极端场景下重复消费是可能的。
我们在线上的确踩到过一次重复数据导致计数翻倍的故障。排查了半天,发现是消费者在处理过程中抛了一个异常,Kafka把消息重新入队,而我们的聚合逻辑没有做去重判断,于是同一批点击事件被统计了两次。
解决的方法是幂等写入。ClickHouse有一个ReplacingMergeTree引擎,它允许指定一个版本列,相同排序键下只保留版本最大的那条记录。我们把事件ID作为排序键,消费时间戳作为版本号,这样即使同一事件被消费两次,存储层也会自动去重。
但这并不是治本的办法。我后来在代码规范里加了一条:消费者必须在下游写入前生成一个全局唯一的事件唯一标识,并且把去重逻辑同时放在应用层和存储层两道防线中。应用层维护一个最近处理过的布隆过滤器,命中则跳过;存储层用合并树兜底。双保险之后,这个类问题基本绝迹。
4.2 延迟与乱序数据的处理策略
实时系统的另一个隐形杀手是数据乱序。前端SDK上报的数据在网络拥塞时可能出现乱序到达,比如点在先的数据因为网关重试,结果后到。直接按时间戳排序会得到错误的结果。
一种简单的思路是采用“事件时间”而不是“到达时间”。每条数据在SDK端就打上客户端时间戳,后端所有聚合计算都以这个时间为准。但客户端时间有可能因为用户本机时间设置错误而偏差,因此在接入层我们会额外记录服务端收到时间,两个时间差超过5分钟的记录直接标记异常并降级处理。
另一个必须应对的场景是延迟数据。消费者从Kafka读取到一条迟到超过窗口边界的数据时,正常窗口计算早就结束了,直接把数据丢弃会丢失有效信息。我们的方案是用延迟缓冲队列加补偿任务:延迟数据先打入一个侧输出流,落到ClickHouse延迟表,然后一个定时任务每隔10分钟扫描延迟表,发现窗口尚未关闭的,把数据补算进对应的汇总指标里。虽然补算的指标晚了一些,但数据是完整的,看板展示时标记为“修正后数据”,避免误导用户。
数据可靠性速查:
| 问题 | 表现 | 我们的应对 |
|---|---|---|
| 重复消费 | 指标翻倍、计数膨胀 | 应用层布隆过滤器 + 存储层ReplacingMergeTree |
| 数据丢失 | 窗口指标断层 | Kafka保留时间延长至7天,消费位点可回退 |
| 乱序到达 | 转化链路错乱、漏斗不准 | 以客户端事件时间为准,网关时间辅助校验 |
| 延迟过高 | 看板指标滞后 | 延迟补偿任务 + 修正后数据标记 |
这套机制上线后再也没有发生过“数据对不上账”的扯皮。基本能做到任意一条数据都能追溯到它来自哪个会话、什么时间被采集、什么时候被消费,这大大提升了团队排查问题的效率。
5. 运维期踩过的坑与排查实录
5.1 ClickHouse运维的“三把火”
第一次在生产环境接入ClickHouse时,我们想当然地把它当成MySQL来用,结果第一个晚上就出了幺蛾子:插入数据时ClickHouse报错“Too many parts”,整个写入链路都堵了,线上看板指标直接变成一条平线。原因是ClickHouse后台的Merge任务速度赶不上高频小批量插入产生的parts数量。
这个问题的教训是,ClickHouse不擅长高频小批量写入,它擅长大批量低频写入。解决办法有两个:一是攒批,让每次插入的数据量达到几千条以上;二是调低后台后台Merge的触发阈值,让parts更频繁地被合并。这两个手段我们最终都用了,写入侧在应用层配置了攒批逻辑,同时建表时设置了专门的Merge参数。经验数据是:单次写入5000条以上,ClickHouse的写入性能才有最好表现。
第二把火是存储空间膨胀。明细表保留30天原始数据,但因为采样率和事件类型逐渐变多,存储一度飙到600GB。后来做了冷热分层:1天以内的数据保留在热存储,全量明细;1天以上的数据按天分片,超过7天的从分区表迁移到归档表,只保留聚合指标。这样热数据查询依然很快,冷数据只是留档,访问频率极低。
第三把火是查询超时。看板页面加载要十几秒,排查发现是聚合查询扫描了全表。优化手段有二:一是把查询字段都建在排序键上,让ClickHouse只扫描必要的分区;二是聚合结果预热,把常用的指标提前算好放进Redis,用户打开看板直接读缓存。优化后看板首屏时间降到1秒以内。
5.2 三个排查过的最“诡异”的问题
第一个问题:线上某段时间突然没有数据,但系统一切正常。后来查到原因是前端SDK上报的域名证书过期了,浏览器拦截了所有上报请求。这个问题的诡异之处在于,它不在服务端日志里留下任何错误痕迹。排查过程从“服务端采集链路是否正常”入手,最后发现页面控制台有证书报错。修复方案是监控SDK内部的上报失败率,失败率超过阈值自动告警,证书有效期提前30天自动提醒。
第二个问题:某一类事件的数据量比预期高出十倍。查了Kafka消费日志发现,某台服务器上的SDK初始化代码被重复执行了多次,每次初始化就会重新绑定一次点击事件,导致同一用户的一次点击被上报了多次。修复方案是SDK内部做防重复初始化判断,绑定事件前先检查标志位。这就是一个很典型的“上报端bug”而不是“接收端bug”。
第三个问题:监控看板显示某接口P95耗时正常,但业务方坚持说该接口很卡。后来把P95拆成P50、P90、P99对比,才发现问题出在P99上:P99每次都会出现一个4秒左右的尖峰,这个尖峰被平均数给掩盖了。聚合指标如果只看平均数和P95,很容易漏掉真正影响体验的尾延迟。从那以后,我们所有接口性能看板都强制展示P50、P90、P99三档,并提供尾延迟分布的直方图。
5.3 常规运维清单
给还在用这套架构跑数据的小伙伴提几个建议:
- Kafka消费者如果长期不消费,重启后从最新偏移量开始,而不是从之前的偏移量继续,程序启动时必须明确设置偏移量策略
- ClickHouse的表引擎分区键定成日期字段(toYYYYMMDD),查询和删除老数据都方便
- 明细表和聚合表的TTL分开设置,明细表保留7天,聚合表保留60天
- 采集网关的限流阈值宁可设高一些,也不要设太低,否则活动突发流量进来直接误伤正常上报
- 前端SDK的发布要做到向后兼容,旧版本SDK上报的数据格式如果已经变更,后端接收逻辑必须兼容旧字段至少一个版本周期
6. 从rea到团队协作:项目推进中的管理心得
6.1 节奏把控与分工建议
技术上聊了这么多,项目落地过程中实际最难的部分往往是进度管理和跨角色协作。我们的团队很精简:一位前端,两位后端,一位测试。四个人把rea拆成了三个里程碑。
第一个里程碑只做最小闭环:前端采集+后端接收+简单看板。这个阶段的目标是让业务方能看到实时数据,哪怕指标不全。第二个里程碑补上性能指标和告警,这是业务方最痛的点。第三个里程碑才做完整的行为链路回溯和漏斗分析。这种节奏的好处是,每个里程碑都有可以演示的成果,业务方会持续给你反馈,而不是憋了三个月拿出一个没人要的东西。
团队分工上有两点值得特别强调:
- 前端和后端之间,接口契约必须先定义清楚。我们用一个JSON Schema描述所有事件字段,前后端共享这份定义,任何一方的改动都要改schema并通知对方。这避免了很多联调事故。
- 测试人员从一开始就参与,而不是做完了才介入。针对这种实时数据系统,我们专门搭建了一个测试用的模拟流量服务,可以按脚本生成各种类型和频率的事件,测试在开发过程中就能验证消费者逻辑是否健壮。
6.2 数据指标定义远比技术选型重要
我刚做这个项目的时候把大量精力花在选型、调参、性能优化上,后来被业务方问了一个问题:你们这个“体验指数”到底想表达什么?我愣住了。我们一直在实现“采集什么数据”,我几乎没有认真思考过“这些数据组合在一起应该变成什么结论”。
这个问题直接推动了指标体系的重新设计。我们最终敲定了三个核心指标:
- 加载体验:首屏时间P50/P90,分别反映一般用户和较差网络用户的体验
- 交互活跃度:会话中的有效点击/有效操作次数,衡量用户是否真的在“用”产品
- 转化流失:从访问到关键动作完成的漏斗转化率,定位流失最严重的环节
技术再好,如果指标体系说不清楚,业务方照样觉得这项目没什么用。这让我深刻认识到,数据项目的第一步永远是“业务和产品定义”,第二步才是技术实现。
7. 想继续做下去,我会先动这些地方
7.1 实时计算引擎替换与算法升级
如果这个项目继续往下做,我认为最重要的改动是放弃手写窗口累加逻辑,换成Flink这样的流式计算框架。原因很简单:手写统计逻辑在业务规则变复杂后,可维护性会迅速恶化。Flink的窗口机制、状态管理、精确一次语义,都是成熟开箱即用的能力,引入它能省掉很多我们自己维护的边界逻辑。
除此之外,异常检测算法也要升级。目前我们靠阈值告警,比如“接口P95超过3秒发告警”,这种静态阈值在业务高峰期很容易误报,半夜又可能漏报。后续想引入动态基线和波动识别,让告警能结合历史数据自动调整阈值,真正做到发现“与往常不一样”,而不是简单超过某个数。
7.2 前端视角的增强能力
体验分析如果只停留在“性能指标”上,说实话有点浪费。我一直觉得,如果能把用户的具体操作路径可视化出来,比任何报表都更有说服力。比如用户进入页面筛选条件、点击多个商品、加入购物车、最后没有提交订单,这条链路如果能用一张图还原出来,产品和运营立刻就能找到优化点。
这块目前的技术实现可以基于已有明细数据和会话ID,把同一会话的所有事件按时间排列,然后前端渲染成轨迹图。难点不在后端,而在于前端在大量事件渲染时的性能。会话较少时可以全量渲染,事件很多的会话可以做聚类降维,只展示关键节点。
7.3 成本控制与资源调优
最后提一下成本。实时数据系统最容易被忽视的就是成本。我们算过一笔账:ClickHouse三节点集群的机器费用加上Kafka的磁盘费用,一年下来是一笔不小的开支。如果业务量没有指数级增长,完全可以做垂直优化,比如降低明细表保留天数、压缩Kafka日志保留时间、将部分低价值事件降级为离线采集。控制成本这件事,不是省出来的,是所有技术选型和架构决策的底层约束。
几点真实的个人体会
做“rea”这个项目,收获最大的一条教训是:面对一个模糊的代号,不要急着写代码,先花时间把“它到底该解决什么问题”聊透。技术选型、数据管道、性能优化都是在这个前提之下自然生长出来的。
还有一条实操层面的体会:在实时系统的开发中,数据可靠性不是靠某一个机制就能完成的,需要从SDK的采集策略到存储层的去重机制做全链路设计。任何一环的疏忽,都会在数据量上涨后变成不可忽略的故障。
如果你也在做类似的项目,希望这篇文章里的选型思路、参数细节和踩坑经验能帮你少走一些弯路。尤其是ClickHouse的写入抖动、Kafka消费幂等、前端埋点防重复这些问题,都是文档里很难看到但实际一定会遇到的内容。