通用计数系统
请使用电脑访问
https://myblog-ad4.pages.dev第 8 章 通用计数系统
本章介绍一个常见但很重要的服务:计数服务。
本章主要内容:
8.1 计数的常见用途 |
8.1 计数的常见用途
具有累计性质的计数数据,在用户产品里非常常见。
典型场景包括:
作品维度: |
作品维度的计数通常反映一个作品的受欢迎程度;用户维度的计数则能直观体现用户的活跃度和影响力。

很多初学者会直接从数据记录本身统计计数,例如:
点赞数 = 作品点赞记录总数 |
这种做法看起来直观,但在高并发场景下风险很大。更可靠的做法是:
数据记录本身 |
也就是说,**计数数据应该从业务记录中独立出来,由单独的服务维护。 **
本节总结
计数数据广泛存在于作品、用户、评论等场景中。
它不是简单的“从记录表里 count 一下”,因为在高并发访问下,实时统计记录总数会给存储系统带来很大压力。
更合理的方式是把计数数据从业务记录中拆出来,形成独立的计数服务。
一句话总结:计数服务的核心价值,是把高频展示和高频更新的累计数值从原始记录中解耦出来。
8.2 如何存储计数数据
本节从计数数据的特点出发,说明为什么计数数据需要单独存储,以及为什么最终选择 Redis。
8.2.1 计数数据的特点
1. 读请求量巨大
作品计数、用户计数、评论计数都会被频繁展示。
例如用户刷到一个作品时,通常会同时看到点赞数、评论数、分享数、收藏数等,所以计数系统必须支持高并发读取。
2. 写请求量巨大
点赞、点踩、评论、转发、收藏等动作触发成本很低,用户可以非常频繁地操作。
因此计数不仅要能读,还要能承载大量并发写入。
3. 非产品的绝对强依赖
计数数据通常属于产品体验上的加成信息。
它和钱包余额这类核心资产不同。极端情况下,如果因为网络抖动、服务 Bug、机房故障导致计数暂时无法展示,通常不会影响产品主流程。
这意味着计数服务可以在可用性、精确性、成本之间做更灵活的取舍。
4. 精确性要求与数值大小成反比
当点赞数较小时,用户可能非常关注精确数字,甚至会查看是谁点了赞。
但当点赞数达到千、万级后,用户更关注的是数量级变化,而不是个位数变化。
例如:
1090 可以展示为 1100 |
所以计数值越大,用户对精确性的要求往往越低。
8.2.2 关系型数据库的困境
以作品点赞数为例,如果点赞记录存储在 MySQL 中,想得到点赞总数,最直接的方式是执行:
SELECT COUNT(1) FROM like_record WHERE content_id = ?; |
不同 MySQL 存储引擎对 COUNT(1) 的实现不同。
MyISAM
MyISAM 会把表的总行数存储在磁盘上,执行 COUNT(1) 时可以直接返回,效率较高。
InnoDB
InnoDB 执行 COUNT(1) 时,需要把数据一行行读出来再累计。
现实业务里,MySQL 大多数场景会选择 InnoDB,因为它提供更好的事务保证。对于点赞数很少的作品,扫描少量记录问题不大;但对于点赞数成千上万的作品,频繁 COUNT(1) 会扫描大量记录,对 MySQL 性能影响很大。
这里的问题本质是:我们只是想知道一个总数,却不得不关心每一条明细记录。
所以依赖实时统计记录总数来展示计数数据,通常不可取。
那能不能像 MyISAM 一样,把计数值单独存到一张关系型数据库表里?
这种做法依然是不可取的。因为计数类场景写请求量很大,让关系型数据库承载高并发计数写入的风险很高。
8.2.3 是否要使用关系型数据库
一般业务中,如果关系型数据库扛不住写压力,可以采用:
先更新缓存 |
如果关系型数据库扛不住读压力,可以采用:
用缓存对外提供读服务 |
但对于计数服务,这种“缓存 + 数据库”的结构显得有些多余。
原因有两点:
- 计数的读写请求量都很大,几乎任何时候都在和缓存系统直接交互。
- 计数数据通常不是绝对不可丢失的数据,而且可以由相关数据记录流水反推修正。
因此:对于计数数据,可以直接把 Redis 这样的内存型数据库作为最终存储层,不再额外维护一份关系型数据库。
这样可以同时满足高并发服务能力,并减少非必要存储,使架构更简洁、可维护性更高。
8.2.4 使用 Redis 存储计数数据
Redis 天然适合计数场景,因为它提供了增减数字的命令,例如:
INCRBY |
Redis 本身是基于内存的单进程 Reactor 高性能服务器,对高并发读写有较好支持;业界也有成熟的 Redis 分布式集群方案,可以提供高可用性和可扩展性。
Redis 持久化问题
Redis是内存型数据库,一般用于缓存系统,不太在意数据的丢失。
但是,计数服务把 Redis 作为最终存储层使用,所以需要格外考虑数据丢失问题。
Redis 提供两类成熟持久化方案:
1. RDB 持久化
RDB(默认开启):快照式持久化,将某一时刻的内存数据以二进制格式完整备份到磁盘。
- 优点: 文件紧凑(二进制),恢复速度极快(直接加载内存)。适合全量备份。
- 缺点: 数据丢失风险大。因为是隔一段时间存一次,如果两次快照之间 Redis 挂了,这期间的数据就丢了。
- 触发方式:
- 手动触发:
save(阻塞主线程,不推荐)或bgsave(后台异步)。 - 自动触发: 配置文件中设置
save 900 1(900秒内有1个改动就保存)。
- 手动触发:
- Copy-on-Write (写时复制): 子进程共享父进程的内存数据进行写入RDB文件。当父进程需要修改某页数据时,操作系统才会把这页数据复制一份给父进程修改,子进程依然读取旧数据。这就保证了在快照期间,主进程依然可以处理写请求,且内存不会瞬间翻倍。
2. AOF 持久化
AOF(默认关闭):日志式持久化,把 Redis 的所有写指令(如 SET、HSET)按执行顺序记录到磁盘。
- 优点: 数据安全性高,默认
everysec策略下最多只丢 1 秒数据。 - 缺点: 文件体积大,恢复速度慢(需要重放命令)。
- AOF刷盘策略:Redis 执行写指令后,不会直接刷到磁盘,而是先写入内存缓冲区(AOF Buffer),再由刷盘策略决定何时将缓冲区的指令刷到 AOF 文件 —— 这样做是为了减少磁盘 IO 次数(磁盘 IO 是慢操作,频繁刷盘会严重降低 Redis 性能)。
appendfsync always:每条命令都刷盘。数据最安全,但性能最差。appendfsync everysec:默认策略。每秒刷一次盘。最多丢 1 秒数据,性能和安全的平衡点appendfsync no:操作系统看心情刷盘。性能好,但不可靠。
- AOF 重写 (Rewrite)
- 问题: 随着时间推移,日志文件会变得无限大(比如你对
count加了 100 次,日志里就有 100 条命令,但其实只需要记录set count 100一条)。 - 解决: Redis 会定期在后台对 AOF 文件进行“瘦身”,只保留恢复当前状态所需的最小命令集。
- 问题: 随着时间推移,日志文件会变得无限大(比如你对
- AOF重写的触发方式:
- 自动触发:由
redis.conf中的两个配置共同决定,满足即自动触发:- 当前 AOF 文件大小 ≥
auto-aof-rewrite-min-size(默认 64MB); - 当前 AOF 文件大小比上次重写后的大小大 ≥
auto-aof-rewrite-percentage(默认 100%)。
- 当前 AOF 文件大小 ≥
- 手动触发:执行
BGREWRITEAOF指令,异步非阻塞(和 BGSAVE 一样,fork 子进程完成重写,主进程不阻塞)—— 生产环境手动瘦身时使用。
- 自动触发:由
3. 混合持久化:
混合持久化 (开启AOF后默认开启):既能像 RDB 一样秒级恢复,又能像 AOF 一样保证数据不丢。
痛点: RDB 丢数据多,AOF 恢复慢。能不能都要?
做法:AOF 重写时使用 RDB 格式作为基础,后续增量用 AOF 日志
原理:触发 AOF 重写后,Redis 主进程 fork 子进程,子进程按RDB 格式将当前内存的全量数据写入临时文件的前半部分;子进程完成 RDB 部分写入后,主进程将重写缓冲区中的增量写指令按AOF 格式追加到临时文件的后半部分;用临时文件替换旧 AOF 文件,完成混合持久化;重写期间产生的新增命令,以 纯AOF 格式追加在后面。
结果: AOF 文件 = RDB 数据 (基量) + AOF 日志 (增量)。
生产环境推荐该方案。极端情况下可能丢失约 1 秒的数据,但这个风险通常可以接受。后续也可以通过异步检查,用数据记录总行数周期性修正计数,或者人工介入修复。
本节总结
计数数据读写都很频繁,直接依赖关系型数据库实时统计或承载高并发写入都不合适。
由于计数数据不是强资产类数据,且可以从业务记录流水中反推修正,因此可以直接使用 Redis 作为计数服务的最终存储层。
Redis 的增减命令、集群能力和持久化能力,使它适合承载计数系统。
8.3 海量计数服务设计
前面已经确定使用 Redis 存储计数数据,本节围绕 Redis 设计一个海量计数服务。
8.3.1 Redis 数据类型
Redis 支持 5 种常见数据类型:String、List、Set、Hash、ZSET。
直观来看,可以使用 String 保存计数数据。
以作品维度计数为例,可以用 5 个 Redis String Key 保存一个作品的计数。
| 数据 | Redis String Key |
|---|---|
| 评论数 | count_{content_id}_comment |
| 点赞数 | count_{content_id}_like |
| 分享数 | count_{content_id}_share |
| 转发数 | count_{content_id}_forward |
| 收藏数 | count_{content_id}_collect |
点赞时:
INCRBY count_{content_id}_like 1 |
取消点赞时:
DECRBY count_{content_id}_like 1 |
读取作品全部计数时:
MGET count_{content_id}_comment |
这种方案简单,但有两个问题。
问题 1:MGET 在集群中可能带来额外开销
互联网公司的 Redis 往往是集群形态。
MGET 获取 N 个 Key,最多会启动 N 个线程到不同 Redis 节点取数据。当作品有大量的读取请求时,Redis集群会有较大的线程资源开销。而且只要其中一个线程失败,整个 MGET 就整体失败,计数读取的可用性会下降。
解决方案:
主流 Redis 集群(如 Codis)提供了较好的解决方案。可以为前缀相同的 Key 打上 hashtag 标签。Redis 集群会保证命中相同 hashtag 的 Redis Key 被存储在集群中的同一个 Redis 服务节点上。这里可以约定 hashtag 为 count_{content_id),于是同一个作品的各个 String 对象类型的计数数据都被分配到同一个 Redis 节点结果 MGET 命令也仅会被一个Redis节点执行,与单机Redis的情况完全一致。
问题 2:String 对象数量太多,内存消耗大
一个作品需要 5 个 String 对象。
对于亿级用户应用,发布作品数量可能达到百亿级,Redis 作为内存数据库会消耗大量资源。
解决方案:
对于问题2, 使用Redis String对象存储计数数据,并没有太好的解决方案。如果想要彻底解决这个问题,那么只能放弃使用 Redis String 对象存储计数数据的方式,改用 Redis Hash 保存计数数据。
为什么改用 Hash?
理由有两个:
- Hash 可以把同一业务场景下的多个计数聚合到一个 Key 下。
- Hash 在 Key-Value 数量较少时,底层会使用压缩列表编码,更节省内存。
作品维度计数可以设计为:
Hash Key: count_{content_id} |
Redis Hash 在满足以下条件时,底层会使用压缩列表编码:
Key-Value 对数量少于 512 个 |
作品计数、用户计数这类多维计数组合通常满足这个条件,因此使用 Hash 比多个 String 更节约内存。
8.3.2 计数累计与读取的示例
以作品 content_id = 123 为例,可以用如下命令初始化计数:
HMSET count_123 comment 10 like 12 share 5 forward 6 collect 3 |
用户点赞时:
HINCRBY count_123 like 1 |
用户取消点赞时:
HINCRBY count_123 like -1 |
读取作品全部计数时:
HGETALL count_123 |
这些命令在 Redis 底层执行效率都很高、延迟低,适合承载高并发访问。
8.3.3 优化内存的调研
使用 Hash 后,Redis 底层会用压缩列表编码来维护数据,已经比 String 方案节省不少内存。
但内存资源昂贵,让我们进一步分析 Redis 压缩列表,看是否还有优化空间。
压缩列表的基本思想
Redis 官方对 ziplist 的说明大意是:
压缩列表是一种经过特殊编码的双向链表, |
普通双向链表中,每个节点独立占用一块内存,并通过指针(地址)连接,容易产生内存碎片,指针本身也需要额外空间。
压缩列表不同,它把所有条目依次放在连续内存空间中,更像一个数组。

其主要组成如下:
| 字段 | 字节数 | 含义 |
|---|---|---|
zlbytes |
4 字节 | 压缩列表总字节数 |
zltail |
4 字节 | 最后一个 entry 的内存偏移量,可快速定位压缩列表尾部 |
zllen |
2 字节 | entry 个数 |
entry |
不固定 | 具体条目 |
zlend |
1 字节 | 结束标记,值为 255 |
每个 entry 内部又包括:
prevlen 前一个 entry 的字节数,支持从后往前遍历 |
Redis Hash 的实际内存占用
Redis 可以通过 DEBUG OBJECT 查看对象实际内存占用。
以 count_123 为例,保存评论数、点赞数、分享数、转发数、收藏数 5 个字段。
$ redis-cli |
观察到对象序列化长度为 78 字节。
继续优化的思路
在一个固定业务场景中,需要统计哪些计数通常是确定的。
例如作品维度固定要统计评论数、点赞数、分享数、转发数和收藏数这 5 个数据。
那么每个作品都重复存储这 5 个 Hash Field 名称,其实是一种浪费。
如果事先知道某类 Key 包含哪些字段,以及字段顺序,就可以不再存储字段名,只存储计数值。
例如用长度为 5 的 long 数组保存:
[comment, like, share, forward, collect] |
5 个 long 类型占用:
5 × 8 = 40 字节 |
相比 Redis 原生压缩列表的 78 字节,已经接近减少一半。
当然,这并不是否定 Redis 压缩列表设计。Redis的压缩列表已经在内存优化上做到了比较极致的设计,但是在我们的计数场景中依然有较大的优化空间。
8.3.4 优化内存:定制化 Redis
在海量用户应用中,大部分计数值不会超过 100 亿。即便极端情况下超过 100 亿,产品展示上也常常会粗略显示为 100 亿+。
计算机用 5 字节已经可以表示远超 100 亿的整数(2^40 >> 10,000,000,000)
所以一个计数仅需要占用 5 字节即可,不需要使用占用 8 字节的 long 数据类型。我们定义这个5字节的整数类型为 Int5 。
CountInt 对象
可以使用 Int5 数组存储一组计数,并把这个数组设计成 Redis 的一种新对象类型:CountInt
CountInt 对象占用的字节总数永远是 5 的整数倍。每个 Int5 计数的含义不存储在对象内部,而是作为元信息存放在计数元数据中心。

如图展示了一个 25 字节的 CountInt 对象,它包含 5 个 Int5 整数,分别表示 5 个计数字段。通过提供Countlnt对象,我们实现了一个定制化的Redis。
那么,这个定制化的Redis究竟如何在计数系统中工作呢?
元数据中心
首先,计数系统需要提供一个元数据中心,让业务开发工程师注册计数场景信息。
注册信息包括:唯一 Redis Key 模式、计数个数、计数数据格式(或称计数 Schema)。
作品维度配置如下:
| 唯一 Redis Key 模式 | 计数个数 | 计数 Schema |
|---|---|---|
count_content_{%d} |
5 | 1: comment 评论数2: like 点赞数3: share 分享数4: forward 转发数5: collect 收藏数 |
用户维度配置如下:
| 唯一 Redis Key 模式 | 计数个数 | 计数 Schema |
|---|---|---|
count_user_{%d} |
4 | 1: following 关注数2: follower 粉丝数3: content 作品数4: hot 热度 |
在计数服务的Redis存储集群中,每个 Redis 节点本地都会缓存元数据中心已经注册的场景信息,用于对 CountInt 对象的读/写进行语义分析。整体关系如下图所示。

写入时如何工作
假设用户对作品 content_id = 123 点赞,计数服务发送命令:
HINCRBY count_content_123 like 1 |
定制化 Redis 收到命令后,会根据 Key count_content_123 匹配到模式:
count_content_{%d} |
再从元数据中心找到 Schema,知道 like 是第 2 个字段,于是对 CountInt 对象中的第 2 个 Int5 整数加 1。
读取时如何工作
假设读取作品 content_id = 123 的全部计数,计数服务发送:
HGETALL count_content_123 |
Redis 读取 CountInt 数组中的所有数值,然后按照 Schema 依次映射字段:
comment = 100 |
最终我们的定制化Redis Countlnt对象可以像原生 Hash 一样返回字段和值。
内存节省效果
以作品维度计数为例:
Redis 原生 Hash 压缩列表:78 字节 |
减少:
53 字节 |
如果有 100 亿个作品:
原生 Hash 约 730GB |
如果你认为 这节省 500G 不足以反映内存消耗的奢侈,那么我们再试想另一个计数场景:评论的点赞和点踩计数。在一个亿级用户应用中,评论总数可以轻松达到万亿级别(假设100亿作品,每个作品平均100个评论),采用 Redis 原生 Hash 对象方案来存储计数数据就会多消耗太多的内存资源。
是否必须定制 Redis ?
定制化 Redis 不是任何时候都必须做。它可以极致节约内存,但开发量大、后期维护成本高。
如果公司机房内存资源比较充足,直接使用原生 Redis 更简单。
8.3.5 冷热数据分离
即使通过定制 Redis 降低了内存占用,但是计数数据仍然全部存放在内存中。
随着用户活跃度长期提升,数据会越来越多,纯内存方案会越来越难以持续。
互联网应用通常有一个共同特点:
热门数据有海量访问 |
因此可以做冷热分离:
热数据:保留在 Redis 内存中 |
冷热判断可以结合 LRU 和 LFU:
LRU:最近最少访问 |
最近使用较多、访问频繁的数据持续放在 Redis 中;最近使用少、访问低频的数据迁移到磁盘。
冷数据存储
如何在磁盘上存储计数数据?建议可以使用 RocksDB 存储冷数据。
RocksDB 是基于 LSM 树的磁盘 KV 存储引擎,支持单个和批量 Key-Value 读写,在分布式数据库、大数据存储引擎、图数据库等应用级数据库的底层都或多或少地使用了 RocksDB 存储引擎。
业界也有兼容 Redis 协议的分布式磁盘 KV 数据库。例如 360 开源的 Pika,它基于 RocksDB 构建,用于解决 Redis 因存储量巨大导致内存不足的问题。
冷热如何区分
不同业务场景差异较大。
例如:
用户维度计数: |
冷热如何迁移
如果计数数据自带时间属性,例如唯一 ID 基于时间戳生成,可以做自动化冷热分离。
如果计数数据不携带时间属性,可能会依赖 周期性异步扫描 或 异步流式计算 的方式甄别冷热数据,然后以半自动或纯人工形式进行冷数据向外迁移的工作。
8.3.6 应对过热数据
计数数据的海量读取比较容易应对。
Redis 主从复制可以让每个 Redis 服务节点以多副本方式提供读取能力;再叠加 Local Cache,可以进一步减少打到 Redis 集群的读请求。
真正需要重点处理的是过热 Key 的写请求。
例如某个热点娱乐内容突然爆火,大量用户同时点赞、评论、转发、分享。与该作品相关的计数更新请求会集中访问同一个 Redis Key,也就是同一个 Redis 服务节点,可能导致该节点压力过大。为了防止出现这种意外情况,我们可以采用异步写和写聚合的方式。
异步写和写聚合需要引入在应对海量写请求时非常常用的一种中间件:消息队列。
异步写
对热点 Key 的计数更新,计数系统可以不立即执行 Redis 更新,而是先把请求放入消息队列,然后返回成功。
用户请求 |
写聚合
消息队列消费者可以批量读取计数更新请求,把指向同一个 Redis Key 的多次更新聚合成一次更新。
例如批量读取到 10 个请求,都指向:
count_content_123 |
其中包括:
点赞 +5 |
就可以聚合成一个 Lua 脚本执行:
redis.call("hincrby", "count_content_123", "like", 5) |
最终效果和 10 个请求逐个执行相同,但 Redis 只需要处理一个聚合请求。
写聚合的效果依赖批量请求是否能聚合:
没有热点数据: |
8.3.7 计数服务架构图
至此,计数服务的计数要点都已经讲清楚了。接下来,我们看一下完整的架构图。
核心组件如下:
| 组件 | 作用 |
|---|---|
| Counter Service | 对外提供计数读取和更新逻辑;同步访问 Redis 集群,或作为消息队列生产者实现计数更新的异步写 |
| Message Queue | 接收和分发异步写请求 |
| Counter Updater | 消息队列消费者,接收消息队列中的异步写请求,做写聚合后,更新计数存储集群 |
| Redis Proxy | 作为 Redis 集群和分布式磁盘 KV 数据库对外服务的总代理 |
| 在线 Redis 集群 | 存储全部计数数据并提供读写能力;如果做了冷热分离,则主要存储近期活跃、热门数据 |
| 冷数据分布式 KV 存储系统 | 存放冷数据,对外提供计数读写能力 |
8.3.8 计数服务的适用范围
计数服务适合那些需要大数据统计才能精确得出数值,但大数据平台又难以做到实时统计和展示的场景。
典型例子:作品点赞数、用户粉丝数、评论点赞数
这些场景通常有两个特点:
- 展示请求量和更新请求量都很大。
- 对具体数值的精确性要求没有那么严格。
但不是所有“数字类业务”都适合计数系统。最典型的反例是:用户钱包账户余额
账户余额虽然也是数字,但和普通计数完全不同:
账户数据是用户核心资产, |
另外,账户数据访问权限通常只限用户自己,请求量并不大,用支持事务的关系型数据库维护才是正解。
本节总结
海量计数服务可以使用 Redis Hash 承载多维计数,并通过压缩列表节省内存。
如果数据规模极大,可以进一步定制 Redis,把固定字段名从每条数据中抽离出去,只保存计数值数组,从而显著降低内存占用。
随着数据持续增长,还需要通过冷热分离把低频访问数据迁移到磁盘 KV 存储。
面对过热 Key 的高并发写入,可以使用消息队列实现异步写,并通过写聚合降低实际 Redis 写请求数量。
8.4 本章小结
本章介绍了海量用户应用中计数数据的设计思路。
首先,计数数据不应该直接依赖业务记录表实时统计,而应该和数据记录解耦,独立设计计数服务。
然后,根据计数数据读写频繁、精确性要求相对可放宽、可从记录流水反推修正等特点,选择 Redis 作为计数数据存储系统。
接着,通过分析 Redis 内存存储的底层原理,进一步提出定制化 Redis、冷热数据分离等方案,降低内存资源消耗。
最后,根据计数数据高并发访问的特点,引入主从读取、Local Cache、消息队列、异步写和写聚合,形成一个高性能、高可用的通用计数服务。
需要注意的是:计数服务不能滥用。它只适合对计数精确性要求不是非常严格的场景。