百万并发的点赞计数:我们为什么要把事实和计数拆开?

作者:袖梨 2026-07-09

百万并发的点赞计数我们为什么要把"事实"和"计数"拆开


一、问题一个小小的点赞按钮背后有多重

在任何一个 UGC 内容平台里点赞、收藏、关注这些交互看似轻量实则对系统提出了两套截然不同的需求

百万并发的点赞计数我们为什么要把“事实“和“计数“拆开

前台交互侧用户点了赞按钮状态必须立刻变化——"我到底点没点赞"这个事实要求毫秒级可读。

后台汇总侧"这篇文章总点赞数""这个作者累计获赞多少"这类数据可以容忍秒级延迟但必须稳定、准确、可扩展。

更棘手的是第三个隐形需求当数据出现异常或漂移时系统必须能从源头可控地重建计数而且不能影响线上其他业务。

这三者放在一起本质上是一个「强一致 + 高并发」与「低成本 + 可重建」之间的平衡问题。


二、核心思路把"事实"和"计数"拆成两层

我们的答案是事实层走强一致汇总层走最终一致异常时反向依赖事实层重建

┌──────────────┐ 事件流 ┌──────────────┐ 批量刷写 ┌──────────────┐ │ 分片位图 │ ──────────────→ │ 聚合桶 │ ────────────→ │ SDS 快照 │ │ 事实层 │ │ 中间层 │ │ 汇总层 │ │ 强一致 │ │ 缓冲 + 聚合 │ │ 最终一致 │ └──────────────┘ └──────────────┘ └──────────────┘ ↑ │ └──────────────── 异常时回溯重建 ───────────────────────────────┘

  • 分片位图Bitmap牢牢记住了"谁对谁做了什么"的事实天然幂等天然支持高并发原子操作。
  • SDS 快照Self-Describing Structure用紧凑的定长二进制结构高效承载"现在计数结果是多少"一次读取即可拿到全部指标。
  • 事件 + 聚合桶作为连接两层的桥梁把高频的增量事件在内存中聚合再批量刷写到 SDS既消峰又降本。

三、分片位图用 Redis Bitmap 承载"用户态事实"

3.1 键空间设计

位图键格式为

bm:{metric}:{etype}:{eid}:{chunk}

  • metric行为指标如 like、av
  • etype实体类型如 rticle、ideo
  • eid实体 ID
  • chunk分片编号由用户 ID 通过 userId / CHUNK_SIZE 计算得出

每个用户在一个分片内占据固定的一位itOffset = userId % CHUNK_SIZE点赞置 1取消置 0。一个分片大小为 32K 位 = 4KB这个数字不是拍脑袋定的——它刚好在 Redis 单键操作开销和分片管理成本之间取了一个平衡点。

3.2 为什么要分片

如果不用分片假设某个爆款内容有 1000 万点赞用户那么这一个 key 就要存储 1000 万个 bit ≈ 1.25MB。每次 BITCOUNT 或 BITOP 都会成为 CPU 瓶颈。

分片之后

收益说明
避免单键过热同一内容的读写压力被哈希打散到多个 key单键不会"胖死"
并行统计总点赞数 = Σ 各分片 BITCOUNT可管道化并行执行
按需迁移冷分片可以整体迁到冷存储热分片保留在高配实例
易于扩展将来用户量级增长只需调整映射规则或增加分片数

3.3 读写路径

写路径极其简单——一次 SETBIT天然幂等

用户点赞 → 映射(chunkId, bitOffset) → SETBIT bm:like:article:42:chunk_0 bitOffset 1 用户取消 → 同上 → SETBIT ... 0

多次点赞也只会置 1不会产生副作用。

读路径同样轻量——一次 GETBIT 即可回答"我是否已点赞"满足前台的毫秒级交互要求

public boolean isLiked(String entityType, String entityId, long userId) {
    long chunk = BitmapShard.chunkOf(userId);
    long bit   = BitmapShard.bitOf(userId);
    return getBit(CounterKeys.bitmapKey("like", entityType, entityId, chunk), bit);
}

位图作为"事实层"长期保留不设短 TTL。删除内容时由后台任务按前缀清理相关分片。


四、事件 → 聚合桶 → SDS高效承载"最终计数"

位图解决了事实层的问题但每次查询"某文章总点赞数"都去扫分片 BITCOUNT 显然不现实——那是偶尔重建时才用的重型操作。日常的高频计数查询我们需要一个轻量的汇总层。

4.1 增量事件的生产与消费

当用户行为落到位图后同时产出一条增量事件到 Kafka

{ etype: "article", eid: "42", metric: "like", delta: +1, ts: 1717584000 }

消费者侧不对每条事件单独写 SDS——那会导致 Redis 写入 QPS 爆炸。而是引入聚合桶Aggregation Bucket

  • 消费者在内存中按 (etype, eid) 维度聚合将同一实体的多次 +1/-1 在本地累加
  • 到达一定时间窗口如 200ms或积压阈值后一次性批量刷写到 SDS

这样原来每秒上万次的事件流被压缩成每秒几十次的批量 SDS 更新。

4.2 SDS定长紧凑的计数存储

SDS 不再用 Hash 或 JSON 存储计数而是用定长字节数组

[like:4B][fav:4B][comment:4B][share:4B][view:4B]

每个指标占 4 字节大端 32 位无符号整数一次 GET 拿回完整的 20 字节在应用层直接按偏移量解析。相比 HGETALL 或 JSON 反序列化这种方式

  • 一次网络往返拿到全部指标
  • 零解析开销——直接按偏移量读 int32
  • 内存紧凑——比 Hash 结构节省数倍空间

五、鲁棒性设计防并发、可重建

5.1 分布式锁防并发重建

当检测到 SDS 数据异常长度不对、缺失等系统会触发自动重建。为避免多个节点同时扫位图做 BITCOUNT引入基于 Redis SET NX EX 的轻量分布式锁

private boolean tryLock(String key, String token, long ttlMillis) {
    Boolean ok = redis.execute((RedisCallback<Boolean>) connection ->
            connection.stringCommands().set(
                    key.getBytes(), token.getBytes(),
                    Expiration.milliseconds(ttlMillis),
                    RedisStringCommands.SetOption.SET_IF_ABSENT
            ));
    return Boolean.TRUE.equals(ok);
}

拿到锁的节点执行 BITCOUNT 重建没拿到锁的节点直接返回降级数据保证接口可用。

5.2 从事实层管道化重建

内容维度的计数重建对所有分片并行执行 BITCOUNT汇总即得正确答案

private long bitCountShardsPipelined(String metric, String etype, String eid) {
    String pattern = String.format("bm:%s:%s:%s:*", metric, etype, eid);
    Set<String> keys = redis.keys(pattern);
    // 管道批量 BITCOUNT
    List<Object> res = redis.executePipelined((RedisCallback<Object>) connection -> {
        for (String k : keys) connection.stringCommands().bitCount(k.getBytes());
        return null;
    });
    return res.stream().mapToLong(o -> ((Number) o).longValue()).sum();
}

用户维度的计数重建则更复杂一些——需要从关系表读关注/粉丝数从内容表读作品列表再聚合各作品 SDS 中的 like/fav 值最后一次性回写用户 SDS。

5.3 降级保障

重建失败时接口不抛异常而是返回默认零值保证前端不白屏

if (raw == null || raw.length < 20) {
    // 尝试重建失败则兜底返回 0
    try { userCounterService.rebuildAllCounters(userId); } catch (Exception ignored) {}
    raw = redis.execute(...);
    if (raw == null || raw.length < 20) {
        return Map.of("followings", 0L, "followers", 0L, "posts", 0L, "likedPosts", 0L, "favedPosts", 0L);
    }
}

六、总结

这套架构的核心哲学可以浓缩为一句话用分片位图牢牢抓住"谁对谁做了什么"的事实用固定结构的 SDS 高效承载"现在计数是多少"中间用异步事件 + 聚合桶作为桥梁

层次职责一致性典型操作
分片位图用户行为事实强一致SETBIT / GETBIT
事件 + 聚合桶缓冲消峰、增量聚合Kafka consume → 内存聚合
SDS 快照最终计数查询最终一致批量 SET / 单次 GET

几个关键取舍值得强调

  1. 位图不存计数只存事实。计数的"权威来源"是位图的 BITCOUNTSDS 只是高性能的快照缓存。
  2. 分片是免费的性能放大器。把一个热 key 拆成 N 个冷 key换来的是并行度和水平扩展能力。
  3. 重建能力是最后的保险。无论中间层出了什么问题只要事实层完好所有计数都可以无损重建——这让系统在运维层面有了极大的底气。

这套方案已经在我们的内容平台上平稳运行支撑着百万级用户的高频点赞、收藏、关注场景。希望这些思路能对同样面临"高并发计数"难题的读者有所启发。


相关文章

精彩推荐