实时统计
日志追踪系统上线后,我以为一切都很完美。直到有一天,一个用户给我发了封邮件:
“你的统计页面显示的是昨天的数据?我刚才点了十几次,数字一点都没变。能不能实时更新啊?”
我打开统计页面看了看,确实如此。点击数据是批量写入数据库的,每隔 5 秒才刷新一次。更糟糕的是,即使数据写入了数据库,SQL 的 COUNT(*) 查询也越来越慢——日志表已经有十几万条记录了。
用户要的是实时的数字,每点一次就能看到变化。
我需要一个能实时计数的方案。Redis 再合适不过了。
我开始研究 Redis 的数据结构。对于计数这个需求,Redis 提供了几种非常强大的工具:
- String + INCR:最简单的计数器,原子性递增
- Hash + HINCRBY:多维度统计,比如按设备、按国家
- HyperLogLog + PFADD:海量去重统计,12KB 内存就能统计百万独立用户
- Sorted Set + ZINCRBY:排行榜,比如热门短链接 Top 10
我先从最简单的开始——用 INCR 统计 PV(总点击量):
落地思路
- 键名按
clicks:total:{short_code}组织,把短码放进键里,一个链接一个计数器。- 每次点击调用一次 INCR,Redis 的单命令原子性保证并发下不丢计数。
- 计数只反映「收到了多少点击」,不区分是否被拦截,也不区分设备。
- Redis 不可用时不能让跳转失败:计数降级为「只写日志、放弃实时计数」。
太简单了。每次点击调用一次 increment,查询时调用一次 get_count,速度飞快。
但实际业务中,用户需要的不只是总点击数。他们想知道:
- 今天点击了多少次?
- 移动端和桌面端的比例?
- 访客来自哪些国家?
- 过去 24 小时的点击趋势?
我设计了多维度计数器:
落地思路
- 维度拆成时间(按天、按小时)、设备、地域、浏览器四类,各自用不同的键名和不同的数据结构。
- 时间维度和总计数用 String + INCR,设备/地域/浏览器用 Hash + HINCRBY,一次只加一个字段。
- 键名里必须带上统计窗口(如
{date}、{h}),否则不同时间段的计数会混在同一个键里。- 每个维度对应一次写入,写入次数随维度数量线性增长,所以维度不要无限加。
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:
落地思路
- 用 Set 存每个访客 IP,内存开销随独立访客数线性增长,每条记录还要带 Set 的额外开销。
- 这些成员还会和短码绑定:链接越多,要同时驻留的 Set 越多。
- 结论是要统计的规模必须和内存占用一起算,否则一个看着无害的「存一下 IP」会成为成本黑洞。
我算了一笔账:100 万独立 IP ≈ 50MB 内存。如果有 10 万条短链接,那就是 5TB 内存。这显然不现实。
Redis 有个神奇的数据结构叫 HyperLogLog——它用概率算法,只需 12KB 内存就能统计百万级独立用户,误差只有 0.81%:
落地思路
- 用 HyperLogLog 代替 Set:PFADD 把访客标识写进去,PFCOUNT 读出估算值。
- 每个短码一个键,元素固定占 12KB 内存,与访客数量无关。
- 代价是只给估算值(误差约 0.81%),并且不能回查具体是哪些 IP。
- 访客标识要选稳定的东西(脱敏后的 IP 加 UA),纯 IP 在 NAT 环境下会低估访客数。
Set vs HyperLogLog:
| 特性 | Set | HyperLogLog |
|---|---|---|
| 精确度 | 100% 精确 | 0.81% 误差 |
| 内存消耗 | 100万用户 ≈ 50MB | 固定 12KB |
| 适合场景 | 精确统计 | 大规模近似统计 |
| 可回查 | ✅ 可以查具体 IP | ❌ 只知道数量 |
对于统计场景,0.81% 的误差完全可以接受。用 12KB 换取百万级统计能力,太划算了。
实时计数有了,接下来就是展示。我花了一个周末做了一个简单的统计仪表盘。
当我第一次看到数字实时跳动的那一刻,成就感爆棚。每有一次点击,图表就自动更新,那种即时反馈太爽了。
落地思路
- 仪表盘固定展示总点击、今日点击、独立访客和设备分布这几项,其余按需展开。
- 数据从 Redis 直接读,接口不查数据库,保证看板刷新不拖慢任何写路径。
- 页面按短码加缓存,短时间内重复刷新不重复打 Redis。
- 页面上的统计时间要标出来,让用户知道这个数字对应的是哪个统计窗口。
API 返回的数据:
配置要点
- 这套统计的口径配置有三项:统计窗口(按天/按小时)、缓存 TTL、以及每次查询要返回哪些维度。
- 这三项随规模调整,但不改变业务规则本身——换成按周统计时,下面的键名规则也要跟着改。
我还加了一个热门排行榜功能,用 Redis 的 Sorted Set 实现:
落地思路
- 排行榜用 Sorted Set:每次点击 ZINCRBY 把该短码的分数加 1。
- 查询用 ZREVRANGE 取分数最高的若干条,Redis 直接返回有序结果,不用在应用层排序。
- 并列分数用短码做第二排序位,保证多次查询的顺序稳定,不会随写入顺序跳动。
- 这个榜单是实时视图,历史 Top N 仍然从数据库的汇总结果出。
每次点击时更新排行,查询时直接 ZREVRANGE,效率极高。
仪表盘上线后,用户很满意。但我发现一个问题:每次查询都要从 Redis 读取 20 多个 Key,虽然 Redis 很快,但 Pipeline 请求也有开销。
而且,用户查询统计页面的频率远高于点击频率——大部分时候数字根本没变化,却还在重复查询。
我想到了一个办法:预聚合 + 缓存。
落地思路
- 看板的完整结果预先算好放在一个缓存键里,查询先读缓存,命中直接返回。
- 写入侧不主动重算,只做缓存失效;下次查询命中失效才重新计算并回填。
- 代价是数字会有最多一个缓存周期的延迟——统计场景可以接受,跳转场景不行。
- 缓存失效要用版本号或删除键,不要用「更新这份数据」的方式,否则并发回填会互相覆盖。
流程变成:
用户查询 → 检查缓存 → 缓存命中直接返回
→ 缓存未命中 → 计算 → 缓存 → 返回
用户点击 → 更新计数器 → 清除缓存 → 下次查询重新计算
这样,大部分查询都直接命中缓存,Redis 压力骤降。10 秒的延迟对于统计场景完全可以接受——用户感知不到差异。
我还设置了 Redis Key 的过期策略,避免无限增长:
落地思路
- 按天和按小时的键自带时间窗口,窗口过后就该消失,不留到永久统计里。
- 用 TTL 把过期时间设在窗口结束之后,让当天的数据在当天始终可查。
- 总计数、设备、地域这类累计键不设过期(或设一个很长的兜底 TTL),否则历史总量会被悄悄清零。
- 过期清理和每日归档的时点要错开,避免归档还没读走数据就被过期删掉。
Redis 存实时数据,数据库存历史数据。两者需要定期同步:
┌─────────────────────────┐
│ 实时数据(Redis) │
│ - 最近 24 小时点击量 │
│ - 实时设备/地域分布 │
│ - 独立访客数 │
│ - 热门排行榜 │
└─────────────┬───────────┘
│ 定期同步
┌─────────────┴───────────┐
│ 历史数据(数据库) │
│ - 每日汇总统计 │
│ - 详细点击日志 │
│ - 历史趋势数据 │
└─────────────────────────┘
落地思路
- 同步任务每天凌晨跑一次:把前一天的日计数和设备分布从 Redis 读出来,写成数据库里的每日汇总。
- 写库时用「按天 + 短码」唯一键做幂等,重复执行只会覆盖不会翻倍。
- 汇总写完再删 Redis 里对应的日键,顺序不能反。
- 同步失败的当天不删 Redis,保留数据等下次重跑,Redis 里的日键用 TTL 做兜底。
每天凌晨,脚本自动把前一天的统计数据从 Redis 同步到数据库,然后清理 Redis 中的过期 Key。这样既保证了实时性,又保留了历史数据。
实时统计系统运行了一个月,效果很好。用户终于能看到秒级更新的数据了,老板也很满意。
但问题又出现了——日志表已经有 1500 万条记录了。
即使 Redis 承担了实时统计的压力,原始日志数据仍然要写入数据库。查询历史数据越来越慢,磁盘空间也在告急。
我需要想办法解决这个大数据挑战……
- HyperLogLog 的 0.81% 误差在什么场景下可以接受?什么场景下不能?
- 如果 Redis 统计数据和数据库统计数据不一致,以哪个为准?
- 如何设计一个”秒级”更新的实时大屏?
发现内容有问题?
告诉我们哪里需要改进。
