实时统计

问题:“你的统计是昨天的数据?”

日志追踪系统上线后,我以为一切都很完美。直到有一天,一个用户给我发了封邮件:

“你的统计页面显示的是昨天的数据?我刚才点了十几次,数字一点都没变。能不能实时更新啊?”

我打开统计页面看了看,确实如此。点击数据是批量写入数据库的,每隔 5 秒才刷新一次。更糟糕的是,即使数据写入了数据库,SQL 的 COUNT(*) 查询也越来越慢——日志表已经有十几万条记录了。

用户要的是实时的数字,每点一次就能看到变化。

我需要一个能实时计数的方案。Redis 再合适不过了。

Redis 实时计数

我开始研究 Redis 的数据结构。对于计数这个需求,Redis 提供了几种非常强大的工具:

  • String + INCR:最简单的计数器,原子性递增
  • Hash + HINCRBY:多维度统计,比如按设备、按国家
  • HyperLogLog + PFADD:海量去重统计,12KB 内存就能统计百万独立用户
  • Sorted Set + ZINCRBY:排行榜,比如热门短链接 Top 10

我先从最简单的开始——用 INCR 统计 PV(总点击量):

落地思路

  • 这里省略具体语法,只保留设计层面的职责边界。
  • 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。

太简单了。每次点击调用一次 increment,查询时调用一次 get_count,速度飞快。

但实际业务中,用户需要的不只是总点击数。他们想知道:

  • 今天点击了多少次?
  • 移动端和桌面端的比例?
  • 访客来自哪些国家?
  • 过去 24 小时的点击趋势?

我设计了多维度计数器:

落地思路

  • 这里省略具体语法,只保留设计层面的职责边界。
  • 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。

Redis 的数据结构大概是这样:

Redis Key 设计:
├── clicks:total:{short_code}        → String    总点击数
├── clicks:daily:{short_code}:{date} → String    日点击数
├── clicks:hourly:{short_code}:{h}   → String    小时点击数
├── clicks:device:{short_code}       → Hash      设备分布
│   ├── mobile → 3500
│   ├── desktop → 2800
│   └── tablet → 700
├── clicks:country:{short_code}      → Hash      国家分布
│   ├── China → 5000
│   ├── USA → 1200
│   └── Japan → 800
└── clicks:browser:{short_code}      → Hash      浏览器分布
    ├── Chrome → 4000
    ├── Safari → 2000
    └── Firefox → 1000

HyperLogLog 统计 UV

用户还想知道”有多少独立访客”访问了短链接,而不仅仅是总点击数。

传统的做法是用 Set 存储每个 IP:

落地思路

  • 这里省略具体语法,只保留设计层面的职责边界。
  • 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。

我算了一笔账:100 万独立 IP ≈ 50MB 内存。如果有 10 万条短链接,那就是 5TB 内存。这显然不现实。

Redis 有个神奇的数据结构叫 HyperLogLog——它用概率算法,只需 12KB 内存就能统计百万级独立用户,误差只有 0.81%:

落地思路

  • 这里省略具体语法,只保留设计层面的职责边界。
  • 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。

Set vs HyperLogLog:

特性SetHyperLogLog
精确度100% 精确0.81% 误差
内存消耗100万用户 ≈ 50MB固定 12KB
适合场景精确统计大规模近似统计
可回查✅ 可以查具体 IP❌ 只知道数量

对于统计场景,0.81% 的误差完全可以接受。用 12KB 换取百万级统计能力,太划算了。

数据仪表盘

实时计数有了,接下来就是展示。我花了一个周末做了一个简单的统计仪表盘。

当我第一次看到数字实时跳动的那一刻,成就感爆棚。每有一次点击,图表就自动更新,那种即时反馈太爽了。

落地思路

  • 这里省略具体语法,只保留设计层面的职责边界。
  • 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。

API 返回的数据:

配置要点

  • 配置表达的是环境差异和运行参数,不是业务规则本身。

我还加了一个热门排行榜功能,用 Redis 的 Sorted Set 实现:

落地思路

  • 这里省略具体语法,只保留设计层面的职责边界。
  • 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。

每次点击时更新排行,查询时直接 ZREVRANGE,效率极高。

性能优化

仪表盘上线后,用户很满意。但我发现一个问题:每次查询都要从 Redis 读取 20 多个 Key,虽然 Redis 很快,但 Pipeline 请求也有开销。

而且,用户查询统计页面的频率远高于点击频率——大部分时候数字根本没变化,却还在重复查询。

我想到了一个办法:预聚合 + 缓存

落地思路

  • 这里省略具体语法,只保留设计层面的职责边界。
  • 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。

流程变成:

用户查询 → 检查缓存 → 缓存命中直接返回
                     → 缓存未命中 → 计算 → 缓存 → 返回

用户点击 → 更新计数器 → 清除缓存 → 下次查询重新计算

这样,大部分查询都直接命中缓存,Redis 压力骤降。10 秒的延迟对于统计场景完全可以接受——用户感知不到差异。

我还设置了 Redis Key 的过期策略,避免无限增长:

落地思路

  • 这里省略具体语法,只保留设计层面的职责边界。
  • 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。

Redis 与数据库协同

Redis 存实时数据,数据库存历史数据。两者需要定期同步:

           ┌─────────────────────────┐
           │     实时数据(Redis)     │
           │  - 最近 24 小时点击量     │
           │  - 实时设备/地域分布      │
           │  - 独立访客数             │
           │  - 热门排行榜             │
           └─────────────┬───────────┘
                         │ 定期同步
           ┌─────────────┴───────────┐
           │    历史数据(数据库)      │
           │  - 每日汇总统计           │
           │  - 详细点击日志           │
           │  - 历史趋势数据          │
           └─────────────────────────┘

落地思路

  • 这里省略具体语法,只保留设计层面的职责边界。
  • 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。

每天凌晨,脚本自动把前一天的统计数据从 Redis 同步到数据库,然后清理 Redis 中的过期 Key。这样既保证了实时性,又保留了历史数据。

下一个问题

实时统计系统运行了一个月,效果很好。用户终于能看到秒级更新的数据了,老板也很满意。

但问题又出现了——日志表已经有 1500 万条记录了。

即使 Redis 承担了实时统计的压力,原始日志数据仍然要写入数据库。查询历史数据越来越慢,磁盘空间也在告急。

我需要想办法解决这个大数据挑战……


想一想

  1. HyperLogLog 的 0.81% 误差在什么场景下可以接受?什么场景下不能?
  2. 如果 Redis 统计数据和数据库统计数据不一致,以哪个为准?
  3. 如何设计一个”秒级”更新的实时大屏?