☰
轻量级实时事件分析工具rea:从事件采集到告警可视化的实践
2026/10/21 4:00:39 网站建设 项目流程

后台数据越堆越多,每次想临时查个指标都得现写脚本,查完又扔。后来实在受不了,我就花了两周时间做了个代号叫“rea”的小东西——一个轻量级的实时事件分析工具。简单说,就是给日志和用户行为事件装了一根“实时水管”,数据从一头进来,另一头直接变成图表、统计和告警,不用再折腾临时脚本,也不用上一套很重的监控平台。

rea 的全名我取的是 Realtime Event Analyzer,名字取得随意,但定位很明确:不搞大而全的监控全家桶,只解决“事件实时进来,能看、能查、能告警”这一层问题。整套系统从后端到前端,再到部署,都是我自己一个人写的,技术栈也刻意选了比较好维护的组合,不是越复杂越好。这篇文章就把 rea 的完整设计思路、核心实现、部署过程和我实际踩过的坑都整理出来,适合那些平时写业务系统、又想做一点轻量实时分析能力的开发者参考,也适合想入门实时数据链路搭建的朋友。

1. 整体设计与思路拆解

1.1 这个项目到底想解决什么问题

先说背景。当时我手上有几个服务,每天产生大量事件数据:用户点击、接口调用、任务状态变更、异常日志,通通散落在各服务自己的日志表里。要回答“今天哪个接口失败率最高”这类问题,我得先想清楚数据在哪个库、哪个表、哪个字段,再写一堆 SQL 或者临时 Python 脚本,跑完再画个图,麻烦得很。

rea 的目标就是把这些散乱的事件收集起来,统一丢到一个专门为分析设计的存储里,然后提供三件事:实时趋势预视图、多维度筛选查询、简单告警通知。听起来像个迷你版监控系统,但我不打算做指标采集和异常检测那一整套,只关注“事件流”这一件事。

在设计上,rea 有几个原则:第一,数据入口要简单,最好是一个 HTTP 接口就能上报一条事件。第二,写入链路要能扛住峰置流量,普通服务器不能因为几万条 QPS 就提示超时。第三,查询要快,聚合统计尽量秒级返回,不能让人盯着加载圈等。第四,部署成本要低,用 Docker Compose 一键起来,不要搞 Kubernetes。

这几个原则决定了后面所有的技术选型。很多人一开始就会想,要不要引入一个实时计算框架、要不要上全链路追踪,我的建议是:先不做。把一条最简单的链路跑通,比一开始就搞复杂架构重要得多。

1.2 技术栈选型,为什么是这套组合

rea 的技术栈是 Python FastAPI + ClickHouse + Redis + WebSocket + React Vite,前端图表用 ECharts。每一项都经过取舍,不是随便拼的。

后端选 FastAPI,是因为 Python 写起来快,而且异步性能比 Flask、Django 这些更适合做事件入口。对外接口用自动生成的文档,调试起来很方便。如果你更熟悉其他语言,其实也没关系,只要接口协议统一,底层换成 Go、Node 都行。

存储选 ClickHouse,这是 rea 里最关键的决定。ClickHouse 是一个列式数据库,聚合查询非常快,特别适合“对一批事件按时间、类型做统计”这种场景。打个比方,传统关系型数据库像一本每一行都记满所有字段的档案册,统计时要一页一页翻;ClickHouse 则像把档案按“姓名”“时间”“类型”分别整理到几个柜子里,统计某一列时只需要抽对应柜子,速度快很多。

Redis 在这里不是主存储,它干两件事:一是当事件量大的时候做批量写入的缓冲队列,先把事件塞进 Redis 列表,再由后台任务定时批量写到 ClickHouse;二是缓存热点查询结果和告警状态,避免每次都打数据库。

WebSocket 是为了实时推送。传统的定时轮询虽然简单,但延时高,而且如果要展示最近一分钟的滚动趋势,轮询会有明显停顿感。用 WebSocket 后,后端每隔几秒就算好统计结果,主动推到浏览器,页面像“活”的一样。

前端没有用很重置的后台框架,直接 Vite + React + ECharts。图表、筛选器、规则列表这些都是很标准的东西,不依赖重型组件库,保持页面体积小,刷新快。

这里有一个值得说的点:我为什么不上消息队列 Kafka?很多实时链路标配是 Kafka,但 rea 的场景是百万级以下日事件量,直接用 Redis 缓冲完全够用。加了 Kafka 虽然更专业,但部署运维多了一个组件,对个人项目和中小企业来说是没有必要的复杂度。等量级真上去了,再迁移到 Kafka 也不迟,因为写入接口和消费逻辑提前做了隔离。

1.3 数据流转的整体架构

整个链路的流转,我用一句话概括:事件进来,进存储,出结果。拆成五个环节会更清楚。

  1. 上报阶段:任何服务或前端应用,通过 HTTP 接口把事件发给 rea 的接入端。事件带最基本的字段:事件类型、发生时间、业务属性、用户标识。
  2. 预处理阶段:接入端校验数据格式,如果数据格式不合法,直接丢弃或标记。这一步很重要,因为下游存储对脏数据的容忍度很低。
  3. 缓冲写入阶段:合法事件先进入 Redis 队列,后台有一个 Writer 服务每秒钟批量取一次,攒够 N 条或者隔 T 秒,批量写入 ClickHouse。
  4. 实时统计阶段:一个独立的定时任务每隔几秒跑一次最近时间窗口的聚合查询,把结果推送到 WebSocket 再展示到前端;同时检查告警规则,触发后回调 Webhook。
  5. 查询分析阶段:用户在前端设置过滤条件,查询接口直接对接 ClickHouse 聚合,返回结构化结果。

这种拆解让每个阶段都能独立调优。比如上报阶段慢了,可以加接入实例;统计阶段慢,可以调聚合查询,或者加物化视图;Redis 堆积严重,就调批量写的时间窗口。改动任何一段都不会影响整条链路。

对比很多把“接收、处理、存储、展示”揉在一个服务里的做法,rea 这种分阶段的架构写起来会多花点时间,但维护起来非常舒服。我也踩过揉在一起的坑:一个服务里既要接收事件又要做定时聚合又要推 WebSocket,最后 CPU 和内存互相抢占,定位问题都费劲。

2. 核心细节解析与实操要点

2.1 事件上报协议的设计与校验

rea 的事件协议是我最早定下来的部分,因为一旦开始接入数据再改协议,改造成本极高。我用的是一个 JSON 结构,看起来非常简单。

{ "event_type": "api_request", "timestamp": 1735689600000, "user_id": "u_1024", "device": "web", "properties": { "path": "/api/order/create", "status_code": 200, "duration_ms": 35 } }

字段含义分别是:event_type 事件类型,timestamp 事件发生时间的 UNIX 毫秒时间戳,user_id 用户标识,properties 里放业务自定义属性。为什么不用字段平铺?因为事件种类太多了,不同事件的属性完全不同,用一个 properties 对象可以避免建大量字段,也方便后续做嵌套查询。

协议定好之后,服务端校验是必须要做的。我见过不少类似系统在数据校验上偷懒,最后存储里全是空字符串、错类型、缺失字段的脏数据,查询结果出来根本没法看。rea 做了四层校验。

  • event_type 必须是非空字符串,长度限制 64 个字符。
  • timestamp 必须是数字,如果不传就取当前时间;如果传了但和服务器时间相差超过 24 小时,说明客户端时钟有问题,直接接受但打日志告警。
  • properties 必须是 JSON 对象,且序列化后大小不超过 10KB,防止有人拿这个接口当文件上传用。
  • 所有字符串字段去除首尾空白,禁止控制字符。

除了这些,我加了一个简单的防滥用机制:上报接口需要带一个 API Key,在 Header 里传,服务端校验通过才受理。这个不指望绝对安全,但能挡住大部分误打误撞的无效请求。

关于批量上报,rea 同时提供了一个批量接口,一次可以传最多 500 条事件。单条接口方便调试,批量接口给正式环境用。批量接口我实测过,单线程压测能跑到 8000 条每秒,对大多数场景都够用了。

批量上报的数据格式是这样:

{ "events": [ {"event_type": "api_request", "timestamp": 1735689600000, "user_id": "u_1024", "properties": {"path": "/a"}}, {"event_type": "api_request", "timestamp": 1735689601000, "user_id": "u_1025", "properties": {"path": "/b"}} ] }

2.2 时间窗口统计的原理与计算方式

实时事件分析里,最核心的难点不是“统计”,而是“窗口怎么划分”。如果这个弄不明白,后面做再多的图表都是错的。

rea 里主要用了滚动窗口和滑动窗口两种概念。滚动窗口是固定的时间块,比如每分钟内的事件归到这一分钟的桶里,计算简单,适合生成柱状图和日粒度报表。滑动窗口则更平顺,看最近 5 分钟的走势,适合做折线图,因为每秒钟都会出一个新点。

那 ClickHouse 里具体怎么算?核心就是两个分组函数,toStartOfMinute 和 toStartOfFiveMinutes,它们把时间列归一到窗口起点,再配合 group by 做聚合。

SELECT toStartOfMinute(event_time) AS minute_start, event_type, count() AS cnt FROM events WHERE event_time >= now() - INTERVAL 30 MINUTE GROUP BY minute_start, event_type ORDER BY minute_start

这个查询跑在 ClickHouse 上非常快,因为它只扫描必要的列。我在写 rea 时发现,对 clickhouse 来说,给 event_time 建好分区,再把统计集中在最后几分钟,查询耗时基本都在 50 毫秒以内。

还有一件事决定实时统计的准确度,就是“事件时间”和“处理时间”的选择。事件时间是客户端上报时携带的 timestamp,处理时间是 rea 接收到事件的时刻。两者很可能不一致,比如手机断网一小时后才上报。如果按处理时间做统计,那断网期间的数据全部堆到恢复那一刻,曲线会产生一个幽灵高峰。rea 的做法是:优先使用事件时间,只有没带 timestamp 才用处理时间兜底。

当然这带来一个小问题,就是乱序数据。可能一点零一分的事件比一点零分五十秒的事件更晚到达。为了应对乱序,rea 没有做复杂的 Watermark 机制,而是干脆把统计数据都加上“晚到补偿”:统计窗口向后多算 30 秒,前端展示时有略微延迟但更稳。

2.3 告警规则引擎的设计思路

告警是 rea 的加分项,也是我认为最容易设计过度的一块。最初我只想实现“某个事件类型在最近 N 分钟超过阈值就通知我”,但后来发现光做到这一点,就有很多边界要考虑。

rea 的告警规则用 JSON 表达,存到 ClickHouse 的 rule 表里。结构像这样:

{ "rule_id": "rule_api_error_high", "event_type": "api_request", "aggregate": "count", "threshold": 500, "window_minutes": 5, "operator": "gt", "webhook": "http://internal-alert-server/hooks/send", "enabled": true }

含义是:最近 5 分钟内,api_request 事件总数量超过 500 就触发告警。检查任务每 30 秒跑一次。这里有一个关键细节:触发完告警之后,同一个规则在同一个窗口内不能重复触发,否则每分钟给你发几十条消息,那就没法看了。

我的做法是把“规则 ID + 窗口起始时间”组合作为去重键,在 Redis 里设置一个 5 分钟的过期 key。如果 key 存在,说明这个窗口已经通知过了,跳过。窗口结束或者规则条件恢复后,会自动生成新的 key,自然就能再次触发。

除了简单的数量阈值,rea 还支持一个进阶一点的功能:条件过滤。例如不仅统计 api_request 总数,而且只看 status_code 大于等于 500 的事件,那么规则里可以带一个 filter 对象:

"filter": { "field": "properties.status_code", "operator": "gte", "value": 500 }

实现起来其实就是把 filter 拼到聚合查询的 where 条件里,查询时多一层过滤,逻辑不复杂。在实际使用中,带过滤条件的告警比纯总量告警更有用,因为总量告警很容易被大量正常请求稀释,导致异常淹没在总量里。

告警动作方面,rea 当时只接了 Webhook,收到通知后由下游手机应用再推送到人。开始我还想过写邮件、企业微信机器人、钉钉机器人这些,但说实话,把 Webhook 出口留好,你要接什么都没问题。

2.4 可视化展示的关键交互

前端的可视化页面如果只把数据渲染成折线图,那也太没门槛了。rea 的页面有几个交互点比较讲究,比如时间范围选择、事件类型筛选、属性条件过滤和图表联动。

时间范围选择是我最早实现的。默认显示最近 15 分钟,也可以快速切到 1 小时、 6 小时、 24 小时。选完之后,前端把时间参数带到查询接口,ClickHouse 在带分区的情况下会快速裁剪数据。这里要提一个我自己犯过的错:最开始我用了浏览器本地时区,直接把本地时间字符串传给后端,结果 ClickHouse 里的是 UTC 时间,图表上出现了一个八小时的偏移,排查了半天才知道是时区问题。后面养成一律传时间戳的习惯,再也没出过事。

事件类型筛选是一个下拉框,选项是系统自动从事件表里 DISTINCT 出来的。由于事件类型数量不会太多,这个查询几百毫秒出结果,足以支撑每次进页面刷新选项。

属性条件筛选是 rea 一个比较有特色的功能。因为 properties 是动态对象,我用 ClickHouse 的 JSONExtractString 和 JSONExtractInt 链路来做属性提取,允许用户在页面上以“字段路径 + 操作符 + 值”的方式加条件。比如“properties.status_code = 500”“properties.duration_ms >= 100”。前端每次查询前把条件组装配成查询参数,后端动态构造 where 段。

图表联动其实本身不复杂,就是给柱状图添加了点击事件,点击某根柱子后,页面的列表自动同步展示那个窗口明细数据。细看代码也就是监听一下鼠标事件,再发一次请求,但体验提升很明显。图表上能看出趋势异常,点击下去要能立刻定位到那批数据长什么样,这才是“分析”工具该有的行为,不能只是“可视化仪表盘”。

3. 实操过程与核心环节实现

3.1 后端服务核心代码实现

先说项目结构,rea 的后端是 monorepo 风格,一个仓库里包含接入服务、存储层、统计任务、前端代码和部署配置。Python 部分按模块分开,目录大概长这样。

rea-server/ ├── app/ │ ├── main.py │ ├── api/ │ │ ├── events.py │ │ ├── query.py │ │ └── rules.py │ ├── core/ │ │ ├── config.py │ │ └── security.py │ ├── services/ │ │ ├── ingest.py │ │ ├── writer.py │ │ ├── analyzer.py │ │ └── notifier.py │ └── models/ │ └── schemas.py ├── web/ # React 前端 ├── docker-compose.yml └── Dockerfile

事件上报接口是整个系统的入口,我直接引 FastAPI 写了一个极简版本。它的核心逻辑是接收 JSON、校验格式、塞进 Redis 缓冲队列。

# app/api/events.py from fastapi import APIRouter, Request, HTTPException from app.services.ingest import validate_event, push_to_buffer router = APIRouter(prefix="/api/v1/events", tags=["events"]) @router.post("") async def create_event(request: Request): payload = await request.json() if payload.get("batch", False): events = payload.get("events", []) if len(events) > 500: raise HTTPException(status_code=400, detail="batch size too large") validated = [validate_event(e) for e in events] push_to_buffer(validated) return {"status": "ok", "received": len(validated)} else: validated = validate_event(payload) push_to_buffer([validated]) return {"status": "ok", "received": 1}

这里的 push_to_buffer 是把事件序列化后推到一个 Redis 列表,键名是 events:pending。注意 validate_event 如果遇到脏数据会抛异常,我捕获后是直接拒绝并记录日志,不会写进队列里。

写入 ClickHouse 的 writer 是一个后台异步任务。我用了 asyncio.create_task 在服务启动时拉起循环,每 2 秒从队列取一批,再拼接参数批量插入。

# app/services/writer.py import asyncio, json from redis import Redis from clickhouse_driver import Client BATCH_SIZE = 1000 BATCH_INTERVAL = 2 async def writer_loop(): while True: await asyncio.sleep(BATCH_INTERVAL) raw_events = redis_conn.lrange("events:pending", 0, BATCH_SIZE - 1) if not raw_events: continue redis_conn.ltrim("events:pending", BATCH_SIZE, -1) events = [json.loads(e) for e in raw_events] rows = [( e["event_type"], e["timestamp"], e.get("user_id", ""), json.dumps(e.get("properties", {})) ) for e in events] clickhouse_client.execute( "INSERT INTO events (event_type, event_time, user_id, properties) VALUES", rows )

ClickHouse 建表语句我用的是 MergeTree 引擎,按天分区,这个对实时统计是决定性的。如果建表时没有分区,全表扫描会吃掉所有性能优势。

CREATE TABLE IF NOT EXISTS events ( event_type String, event_time DateTime64(3, 'UTC'), user_id String, properties String, received_at DateTime DEFAULT now() ) ENGINE = MergeTree() PARTITION BY toYYYYMMDD(event_time) ORDER BY (event_time, event_type) TTL toDateTime(event_time) + INTERVAL 30 DAY SETTINGS index_granularity = 8192;

TTL 设了 30 天,过期的数据自动删除,这样不用手动定期清理表数据。这里我要强调一句,ClickHouse 的 TTL 默认是异步清理,空间不会立刻释放你不要慌,过一段时间它会自己处理。

3.2 实时推送链路实现

实时推送这块我一开始被卡了一下。WebSocket 在反向代理里的配置坑很多,但代码本身反而不复杂。rea 后端维护了一个连接管理器,存放所有在线连接,然后后台统计任务每 5 秒算一次最近窗口,推给前端。

# app/services/realtime.py class ConnectionManager: def __init__(self): self.active_connections = [] async def connect(self, websocket): await websocket.accept() self.active_connections.append(websocket) def disconnect(self, websocket): self.active_connections.remove(websocket) async def broadcast(self, message): for conn in self.active_connections: try: await conn.send_text(message) except Exception: self.active_connections.remove(conn) manager = ConnectionManager()

在统计任务里,计算最近的聚合结果,然后广播给所有预览页面:

async def analytics_broadcast_loop(): while True: await asyncio.sleep(5) rows = query_recent_minutes() metrics = build_metrics_payload(rows) await manager.broadcast(json.dumps(metrics))

前端这边用了一个很小的封装,收到数据直接更新图表的 series。我特意没有用重型状态管理,一个 useRef 存最新序列就够了。ECharts 的 setOption 是增量合并的,不用每次全量重设。

// web/src/components/RealtimeChart.jsx import * as echarts from 'echarts'; import { useEffect, useRef } from 'react'; export default function RealtimeChart() { const chartRef = useRef(null); const socketRef = useRef(null); useEffect(() => { const chart = echarts.init(chartRef.current); socketRef.current = new WebSocket( `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/metrics` ); socketRef.current.onmessage = (event) => { const payload = JSON.parse(event.data); chart.setOption({ xAxis: { data: payload.timestamps }, series: [{ data: payload.series }] }); }; const heartbeat = setInterval(() => { if (socketRef.current.readyState === WebSocket.OPEN) { socketRef.current.send('ping'); } }, 30000); return () => { clearInterval(heartbeat); socketRef.current.close(); }; }, []); return <div ref={chartRef} style={{ height: 400 }} />; }

这一套在本地开发环境测试时一切正常,但是一到部署,问题就冒出来了。Nginx 默认对 WebSocket 的 idle 连接有 60 秒超时,我加了心跳保活之后,连接稳定了很多。具体配置我放到后面部署部分详细说。

3.3 前端页面快速搭建

前端的页面结构分了四块:顶部筛选栏、实时趋势折线图、事件分布柱状图、明细事件列表。我用 Vite 初始化了 React 项目,然后装了 echarts 和 axios,其他工具库基本没怎么加。

顶部筛选栏的表单逻辑其实是最枯燥的部分,但不写又不行。它要能选择时间范围、事件类型、属性和值。我把筛选状态统一放在一个对象里,任何一次变更都会重新触发查询。这块代码不复杂,但我要提醒一个细节:筛选条件和时间范围要持久化成 URL 参数,不然刷新页面就全没了,调一次页面还要重新设置,很烦。

事件分布柱状图我用了 ClickHouse 的 group by event_type,返回的每一根柱子表示一种事件类型的数量。前端柱子颜色按类型做一个 hash 映射,避免每次渲染颜色都乱跳。

明细列表是使用场景里最常用的。点击任何一张图表的某个点,页面底部就会出现对应窗口的明细数据。实现方式就是监听图表的 click 事件,带上时间窗口参数请求 /api/v1/events/query 接口。

rea 的前端整体做下来,最需要留意的反而是加载性能。我第一次接数据时,图表组件把 24 小时的 10 万条数据全部塞到前端,结果页面卡死了。后来把粒度降低,超过 24 小时就用小时聚合,前端只显示 24 个点。这个优化朴素但有效,直接解决了页面卡顿的问题。

3.4 部署与上线

rea 的部署最后是用 Docker Compose 搞定的,总共四个容器:clickhouse、redis、api、web。clickhouse 和 redis 用官方镜像,api、web 各自打包镜像。这一套东西我在一台 2 核 4G 的服务器上跑得非常稳。

docker-compose.yml 的关键部分长这样:

services: clickhouse: image: clickhouse/clickhouse-server:23.8 volumes: - ch_data:/var/lib/clickhouse - ./clickhouse/init.sql:/docker-entrypoint-initdb.d/init.sql ulimits: nofile: soft: 262144 hard: 262144 redis: image: redis:7-alpine command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru api: build: . depends_on: - clickhouse - redis environment: CLICKHOUSE_HOST: clickhouse REDIS_HOST: redis web: build: ./web ports: - "8080:80"

这里有两个部署上的坑值得单独说。第一,ClickHouse 容器默认的 ulimit 很低,高负载时会有 too many open files 的问题,我加上了 nofile 软硬限制后解决。第二,Redis 必须限制最大内存和淘汰策略,不然事件堆积时 Redis 会先把系统内存吃光,导致整站宕机。这两个问题都是我实打实遇到过的。

Nginx 放在了宿主机层做反代,把 443 的 HTTPS 请求转发到 web 和 api 容器。WebSocket 的配置我特别加了两行,少了这两行,浏览器端的 WebSocket 过一会儿就被切断。

location /ws/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }

整个部署过程最耗时的不是容器编排,而是排查 ClickHouse 初始化脚本的权限问题。第一次启动时 init.sql 没有执行成功,事件表没建,接口是 500,我查了很久日志才发现是容器启动顺序导致的竞态。后面把 clickhouse 的健康检查加到 api 的 depends_on condition 里才稳定。

4. 常见问题与排查技巧实录

4.1 时间戳与时区导致的八小时漂移

这个问题我前面提到过一次,但它值得单独立项,因为它太容易犯了,而且症状非常隐蔽。rea 上线第一天,前端图表显示每分钟请求量只有真实的一半,而且高峰总比预期晚八个小时。

排查步骤我当时是这么走的:先看前端请求参数,发现传的是一个形如“2024-11-01 12:00:00”的字符串;再看后端收到后按 ClickHouse 的 DateTime 类型写入;最后查询时又用 ClickHouse 的 now() 函数做时间过滤。问题就很清楚了,ClickHouse 的 DateTime 默认是 UTC 存储,前端传的字符串没带时区,被当作 UTC 解析,而 now() 返回的 UTC 和浏览器显示的东八区差了八小时。

解决办法是统一用 epoch 毫秒。前端传时间戳,ClickHouse 存 DateTime64,查询条件里也用 toTimestamp 转换。这件事做完之后,realtime 曲线和原始日志对齐,再也没有漂移过。

4.2 WebSocket 频繁断开与心跳保活

rea 前端的图表在部署后经常出现“连接中断,点击重连”的提示。我一开始以为后端 WebSocket 管理器有 bug,后来打开 Chrome 开发者工具,看到网络面板里 WebSocket 连接总是正好在 60 秒左右断开,才意识到是 Nginx 代理层超时。

Nginx 默认的 proxy_read_timeout 是 60 秒,WebSocket 连接如果没有任何消息来往,就会被判定为空闲并断开。我的前端虽然每 30 秒发一次 ping,但 Nginx 只计算数据帧,如果后端不主动回 pong,代理层依然认为连接空闲。所以正确的做法是:前端发心跳,后端回 pong,Nginx 调大超时时间。三者缺一不可。

还有一个容易被忽略的点:如果 WebSocket 连接有大量初始化数据,而 Nginx 的 proxy_buffering 没有关闭,前端可能收到被缓冲区堆叠的旧数据,出现“数据延迟几秒才动”。关闭 proxy_buffering 之后,推送的数据才能实时到达浏览器。

4.3 Redis 队列堆积导致数据延迟

有一次我模拟了高流量压测,发现 rea 图表的数据延迟越来越严重,从一开始的 2 秒延迟逐渐涨到 30 秒。第一反应去查 ClickHouse 的写入情况,但看起来数据库压力不大,后来一看 Redis 的 LLEN events:pending,发现有几十万条积压。

原因是我把批量写入的阈值设置得太保守了,队列消费速度跟不上生产速度。虽然理论上每秒能写 1000 条,但 ClickHouse 单次插入如果批次太小,开销反而高,形成“一直 insert 一直慢”的循环。

解决办法是把 writer 改成双参触发:队列长度达到 5000 条就立刻写入,或者距离上次写入超过 1 秒也写入。这样高流量时走批量阈值,低流量时走时间阈值,两全其美。修改之后积压很快就清了,数据延迟也恢复正常。

4.4 ClickHouse 聚合查询越来越慢

rea 跑了大概一个月之后,我发现查询最近 24 小时聚合结果的接口明显变慢,从几十毫秒涨到两三秒。这是因为事件表越来越大,而我的分区索引虽然按天,但一次要聚合 24 个分区的数据时,ClickHouse 也要扫描不少数据。

针对这个,rea 加了两层加速手段。第一层是按小时聚合的物化视图,后台实时写入事件时物化视图同步更新,查询小时级趋势直接查视图,速度回到毫秒级。第二层是在查询条件里强制带上 event_time 的范围,不让你模糊搜索全表,否则 ClickHouse 也是傻乎乎全扫。

物化视图建表语句我当时是这样做的:

CREATE MATERIALIZED VIEW events_hourly_mv ENGINE = SummingMergeTree PARTITION BY toYYYYMMDD(hour_start) ORDER BY (hour_start, event_type) AS SELECT toStartOfHour(event_time) AS hour_start, event_type, count() AS cnt FROM events GROUP BY hour_start, event_type;

这里的核心是把“按小时、按类型统计”这类固定查询固化到写入链路里,用空间换查询时间。日常排查时,我一般先看单条查询的执行计划和扫描行数,然后用 EXPLAIN 判断是不是走了物化视图。

4.5 快速诊断速查表

日常运维中我整理了一个速查表,遇到问题直接按表格排查,比重新分析快得多。

症状可能原因排查命令或方式修复动作
图表数据延迟Redis 队列堆积redis-cli LLEN events:pending调整批次阈值,双参触发
WebSocket 周期掉线Nginx 代理超时查看 Nginx error.log配置心跳并调大超时
查询慢没有走物化视图看 ClickHouse system.query_log启用物化视图或加时间过滤
数据偏少时区不一致对比原始日志时间戳统一 epoch 毫秒传参
写入报 Too many open filesClickHouse ulimit 不足查看容器日志调整 nofile ulimit
接口 500初始化建表失败查看 clickhouse init 日志加健康检查依赖

这张表在 rea 的 README 里一直保留着,任何人接手都能快速定位问题。

5. 实操后的真实体会

代码写完了,部署也跑起来了,但真正让我觉得 rea 值得分享的,反而不是那些技术实现,而是开发过程中的几个认知。

第一,很多东西不必一步到位。我最初也想上 Kafka、上 Flink、上真正的实时数仓,但后来被自己按下去了。先用最简单的 Redis + 批量写 ClickHouse 把链路跑通,流量大了再替换某一段,这个过程给了我很大的灵活性。如果一开始就上大技术栈,两周时间大概率还卡在搭建基础设施的路上。

第二,数据协议越早定越好。事件上报格式我前后调整过三次,每一次调整都意味着接入方要改代码。如果一开始就把 event_type、timestamp、properties 这三个元素定死,后面的改动就会少很多。建议做类似系统的人,先在 laid 上把协议想清楚,不要边写边改。

第三,日志是你最好的排查工具。rea 在开始时的日志打印得比较少,排查问题时总是抓瞎。后来我在接入、写入、聚合、推送这四个链路的关键节点都加上了耗时和数量日志,再用结构化方式输出,排查问题的时间缩短了一半。尤其是 WebSocket 这类的长连接问题,没有日志根本没法定位是 Nginx 断的、还是后端断的、还是浏览器断的。

最后分享一个小技巧。如果你也想自己搭一个类似的小系统,不要急着写代码,先画一张数据流转图,标明每个环节的输入输出和时延目举。哪怕只是在纸上画,等真正写代码和排查问题时,你会发现这份结构帮了很大的忙。我后来能在一个晚上定位到 Redis 积压的问题,就是因为我心里清楚整条链路的每个环节现在该是什么状态。虽然这道理听起来很朴素,但它是我从 rea 这个项目里得到的最实在的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询