实时统计
问题:“你的统计是昨天的数据?”
日志追踪系统上线后,我以为一切都很完美。直到有一天,一个用户给我发了封邮件:
“你的统计页面显示的是昨天的数据?我刚才点了十几次,数字一点都没变。能不能实时更新啊?”
我打开统计页面看了看,确实如此。点击数据是批量写入数据库的,每隔 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 → 1000HyperLogLog 统计 UV
用户还想知道”有多少独立访客”访问了短链接,而不仅仅是总点击数。
传统的做法是用 Set 存储每个 IP:
落地思路
- 这里省略具体语法,只保留设计层面的职责边界。
- 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。
我算了一笔账:100 万独立 IP ≈ 50MB 内存。如果有 10 万条短链接,那就是 5TB 内存。这显然不现实。
Redis 有个神奇的数据结构叫 HyperLogLog——它用概率算法,只需 12KB 内存就能统计百万级独立用户,误差只有 0.81%:
落地思路
- 这里省略具体语法,只保留设计层面的职责边界。
- 读这段时重点看:输入是什么、系统做哪些判断、状态如何变化、失败时如何兜底。
Set vs HyperLogLog:
| 特性 | Set | HyperLogLog |
|---|---|---|
| 精确度 | 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 承担了实时统计的压力,原始日志数据仍然要写入数据库。查询历史数据越来越慢,磁盘空间也在告急。
我需要想办法解决这个大数据挑战……
想一想
- HyperLogLog 的 0.81% 误差在什么场景下可以接受?什么场景下不能?
- 如果 Redis 统计数据和数据库统计数据不一致,以哪个为准?
- 如何设计一个”秒级”更新的实时大屏?