实时统计

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

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

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

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

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

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

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:

特性SetHyperLogLog
精确度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 存实时数据,数据库存历史数据。两者需要定期同步:

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

落地思路

  • 同步任务每天凌晨跑一次:把前一天的日计数和设备分布从 Redis 读出来,写成数据库里的每日汇总。
  • 写库时用「按天 + 短码」唯一键做幂等,重复执行只会覆盖不会翻倍。
  • 汇总写完再删 Redis 里对应的日键,顺序不能反。
  • 同步失败的当天不删 Redis,保留数据等下次重跑,Redis 里的日键用 TTL 做兜底。

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

下一个问题

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

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

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

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


想一想

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

发现内容有问题?

告诉我们哪里需要改进。