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

0 阅读7分钟

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

一套面向内容平台的高性能计数架构实践:分片位图 + 事件聚合 + SDS 快照存储本文讨论了分片位图 + 计数重建策略系统,该系统将“事实”与“计数”拆开,在“强一致 + 高并发”与“低成本 + 可重建”之间找到平衡,以满足内容平台对行为数据处理的需求。


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

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

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