“昨天UV多少?”——隔天的数据谁都能查。

“现在有多少人在线?”——这才是实时数据的价值。

这篇文章,我会教你设计一个毫秒级延迟的实时数据统计系统。


一、为什么需要实时数据?

1.1 批处理 vs 实时处理

维度批处理(T+1)实时处理(秒级)
延迟次日秒级/毫秒级
价值分析决策实时监控/运营
场景日报、周报活动监控、异常告警
成本低高
复杂度低高

1.2 实时数据的典型场景

场景需求延迟要求
活动监控裂变活动进行中,实时看参与人数秒级
异常告警UV突降50%,立即通知毫秒级
实时推荐用户正在浏览,推荐相关内容毫秒级
在线人数当前在线用户数秒级
A/B测试实时看实验组数据分钟级

二、实时系统架构

2.1 整体架构

code复制

┌──────────────────────────────────────────────────────────┐
│                       实时展示层                          │
│              WebSocket + Vue/React                        │
├──────────────────────────────────────────────────────────┤
│                       实时计算层                          │
│           Flink / 自研实时计算引擎                         │
├───────────────┬──────────────────┬───────────────────────┤
│   实时存储     │    离线存储       │     缓存              │
│  Redis/TDengine│  ClickHouse     │    Redis              │
├───────────────┴──────────────────┴───────────────────────┤
│                       消息队列                            │
│                    Kafka / Pulsar                         │
├──────────────────────────────────────────────────────────┤
│                       采集层                              │
│              前端SDK → 上报网关                            │
└──────────────────────────────────────────────────────────┘

2.2 数据流

code复制

前端埋点 → 上报网关 → Kafka → Flink → Redis(实时) + ClickHouse(落盘)
                                    ↓
                              WebSocket → 看板

三、时序数据库选型

数据库写入速度查询速度时序支持适合实时
MySQL低中差❌
ClickHouse高(批量)高(批量)中❌
Redis极高极高差(需自己设计)✅
InfluxDB高高好✅
TDengine极高极高好✅

推荐方案:Redis + TDengine

用途数据库理由
当前状态Redis读写极快,适合存当前在线人数
时序数据TDengine专为时序数据设计,写入极快
离线分析ClickHouse批量分析,留存/漏斗/归因

四、实时计算设计

4.1 实时指标定义

指标定义计算方式精度
实时在线最近5分钟内活跃的用户数Redis Set,5分钟过期精确
分钟UV每分钟独立用户数滑动窗口去重精确
实时PV每分钟页面浏览量滑动窗口计数精确
实时转化率实时支付人数/实时访问人数双窗口比率精确

4.2 Redis实时状态设计

javascript复制

class RealtimeState {
  constructor(redis) { this.redis = redis; }

  async userActive(userId) {
    const now = Date.now();
    const pipe = this.redis.pipeline();

    // 1. 在线用户集合(5分钟过期)
    pipe.sadd('online:users', userId);
    pipe.expire('online:users', 300);

    // 2. 分钟UV(HyperLogLog)
    const minuteKey = `uv:min:${this.getMinuteKey(now)}`;
    pipe.pfadd(minuteKey, userId);
    pipe.expire(minuteKey, 3600);

    // 3. 分钟PV
    const pvKey = `pv:min:${this.getMinuteKey(now)}`;
    pipe.incr(pvKey);
    pipe.expire(pvKey, 3600);

    // 4. 在线时长追踪(Sorted Set)
    pipe.zadd('online:timeline', now, userId);
    pipe.zremrangebyscore('online:timeline', '-inf', now - 300000);

    await pipe.exec();
  }

  async getOnlineCount() { return await this.redis.scard('online:users'); }

  async getMinuteUV(minutesAgo = 0) {
    const key = `uv:min:${this.getMinuteKey(Date.now() - minutesAgo * 60000)}`;
    return await this.redis.pfcount(key);
  }

  async getMinuteUVTrend(minutes = 30) {
    const result = [];
    for (let i = minutes - 1; i >= 0; i--) {
      const uv = await this.getMinuteUV(i);
      result.push({ time: this.getMinuteKey(Date.now() - i * 60000), uv });
    }
    return result;
  }

  getMinuteKey(ts) {
    const d = new Date(ts);
    return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`;
  }
}

4.3 TDengine时序存储设计

sql复制

CREATE DATABASE IF NOT EXISTS miniprogram_stats PRECISION 'ms' KEEP 3650 BUFFER 96 PAGES 256;
USE miniprogram_stats;

-- 事件时序超级表
CREATE STABLE IF NOT EXISTS events (
  ts            TIMESTAMP,
  event_name    NCHAR(50),
  session_id    NCHAR(50),
  user_id       NCHAR(64),
  properties    NCHAR(1024)
) TAGS (app_id NCHAR(32), platform NCHAR(16), version NCHAR(16));

-- 分钟汇总超级表
CREATE STABLE IF NOT EXISTS minute_summary (
  ts            TIMESTAMP,
  uv            INT,
  pv            INT,
  new_user      INT,
  online_count  INT
) TAGS (app_id NCHAR(32));

-- 查询:最近30分钟UV趋势
SELECT ts, uv, pv, online_count
FROM minute_summary
WHERE app_id = 'your_app_id' AND ts >= now - 30m
ORDER BY ts;

-- 查询:今日每小时UV
SELECT _wstart AS hour, sum(uv) AS uv, sum(pv) AS pv
FROM minute_summary
WHERE app_id = 'your_app_id' AND ts >= today()
INTERVAL(1h) ORDER BY hour;

4.4 Flink实时计算

java复制

public class RealtimeStatsJob {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        KafkaSource<TrackEvent> source = KafkaSource.<TrackEvent>builder()
            .setBootstrapServers("kafka:9092")
            .setTopics("track-events")
            .setGroupId("flink-realtime-stats")
            .setStartingOffsets(OffsetsInitializer.latest())
            .setValueOnlyDeserializer(new TrackEventDeserializer())
            .build();

        DataStream<TrackEvent> events = env.fromSource(
            source, WatermarkStrategy.noWatermarks(), "kafka-source"
        );

        // 1. 实时在线人数(5分钟滑动窗口,10秒滑动)
        events.keyBy(e -> e.getAppId())
            .window(SlidingProcessingTimeWindows.of(Time.minutes(5), Time.seconds(10)))
            .aggregate(new OnlineCountAgg())
            .addSink(new RedisSink<>("online:count"));

        // 2. 分钟级UV/PV(1分钟滚动窗口)
        events.keyBy(e -> e.getAppId())
            .window(TumblingProcessingTimeWindows.of(Time.minutes(1)))
            .aggregate(new MinuteStatsAgg())
            .addSink(new TDengineSink<>("minute_summary"));

        // 3. 异常检测
        events.keyBy(e -> e.getAppId())
            .window(TumblingProcessingTimeWindows.of(Time.minutes(1)))
            .aggregate(new AnomalyDetectAgg())
            .filter(alert -> alert != null)
            .addSink(new AlertSink());

        env.execute("Realtime Stats Job");
    }
}

五、WebSocket推送设计

5.1 服务端

javascript复制

const WebSocket = require('ws');
const Redis = require('ioredis');

const wss = new WebSocket.Server({ port: 8081 });
const redis = new Redis();
const clients = new Map();

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    const data = JSON.parse(message);
    if (data.type === 'subscribe') clients.set(ws, { appId: data.appId });
  });
  ws.on('close', () => clients.delete(ws));
});

// 每10秒推送
setInterval(async () => {
  for (const [ws, config] of clients) {
    if (ws.readyState !== WebSocket.OPEN) continue;
    const data = await getRealtimeData(config.appId);
    ws.send(JSON.stringify({ type: 'realtime_update', data, timestamp: Date.now() }));
  }
}, 10000);

5.2 前端

vue复制

<template>
  <div class="realtime-dashboard">
    <div class="realtime-card online">
      <div class="label">当前在线</div>
      <div class="value pulse">{{ data.online_count || 0 }}</div>
    </div>
    <div ref="uvChart" style="height: 200px;"></div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import * as echarts from 'echarts';

const data = ref({});
let ws = null;

onMounted(() => {
  ws = new WebSocket('ws://localhost:8081');
  ws.onopen = () => ws.send(JSON.stringify({ type: 'subscribe', appId: 'your_app_id' }));
  ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    if (msg.type === 'realtime_update') data.value = msg.data;
  };
});
onUnmounted(() => { if (ws) ws.close(); });
</script>

六、异常告警设计

6.1 告警规则

规则条件级别通知方式
UV突降当前分钟UV < 上分钟UV × 50%P0企业微信 + 短信
PV异常当前PV > 日常PV × 3P1企业微信
在线归零在线人数 = 0 超过5分钟P0企业微信 + 电话
上报延迟Kafka消费延迟 > 5分钟P1企业微信
错误率上升错误率 > 5%P2邮件

6.2 告警服务

javascript复制

class AlertService {
  constructor(options) {
    this.redis = options.redis;
    this.alertCallback = options.alertCallback;
    this.rules = options.rules || [];
    this.cooldown = new Map();
  }

  async check(appId, currentData) {
    for (const rule of this.rules) {
      const shouldAlert = await rule.evaluate(appId, currentData, this.redis);
      if (shouldAlert) {
        const cooldownKey = `${appId}:${rule.name}`;
        if (this.cooldown.get(cooldownKey) > Date.now()) continue;
        await this.alertCallback({
          appId, ruleName: rule.name, level: rule.level,
          message: shouldAlert.message, data: currentData, timestamp: Date.now()
        });
        this.cooldown.set(cooldownKey, Date.now() + rule.cooldownMs);
      }
    }
  }
}

七、性能优化

7.1 写入优化

优化方案效果
批量写入Kafka攒批,每1000条或每秒写入吞吐量提升10倍
异步写入写入和查询分离查询不受写入影响
预聚合Flink窗口聚合后写入数据量减少90%
冷热分离热→Redis,温→TDengine,冷→ClickHouse查询性能提升5倍

7.2 查询优化

优化方案效果
缓存Redis缓存,10秒过期秒级→毫秒级
降采样长范围按分钟/小时/天聚合数据量减少100倍
预计算提前计算常用指标复杂查询变简单

7.3 推送优化

优化方案效果
增量推送只推送变化数据数据量减少80%
按需推送客户端订阅减少无用推送
压缩permessage-deflate数据量减少60%

八、方案选型

规模方案月成本
0-1万UV不需要实时,微信后台T+1够用0
1万-10万UV轻量:Redis + 定时任务~500元
10万-100万UV标准:Redis + TDengine + WebSocket~3000元
100万UV+重量:全链路实时 Flink + Kafka~15000元

写在最后

实时数据的核心价值不在于"看",而在于"做"——实时发现问题,实时做出决策。

但切记:实时系统的复杂度和成本远高于离线系统。在业务不需要的时候,不要过早引入实时架构。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐