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

前台交互侧用户点了赞按钮状态必须立刻变化——"我到底点没点赞"这个事实要求毫秒级可读。
后台汇总侧"这篇文章总点赞数""这个作者累计获赞多少"这类数据可以容忍秒级延迟但必须稳定、准确、可扩展。
更棘手的是第三个隐形需求当数据出现异常或漂移时系统必须能从源头可控地重建计数而且不能影响线上其他业务。
这三者放在一起本质上是一个「强一致 + 高并发」与「低成本 + 可重建」之间的平衡问题。
我们的答案是事实层走强一致汇总层走最终一致异常时反向依赖事实层重建。
┌──────────────┐ 事件流 ┌──────────────┐ 批量刷写 ┌──────────────┐ │ 分片位图 │ ──────────────→ │ 聚合桶 │ ────────────→ │ SDS 快照 │ │ 事实层 │ │ 中间层 │ │ 汇总层 │ │ 强一致 │ │ 缓冲 + 聚合 │ │ 最终一致 │ └──────────────┘ └──────────────┘ └──────────────┘ ↑ │ └──────────────── 异常时回溯重建 ───────────────────────────────┘
位图键格式为
bm:{metric}:{etype}:{eid}:{chunk}
每个用户在一个分片内占据固定的一位itOffset = userId % CHUNK_SIZE点赞置 1取消置 0。一个分片大小为 32K 位 = 4KB这个数字不是拍脑袋定的——它刚好在 Redis 单键操作开销和分片管理成本之间取了一个平衡点。
如果不用分片假设某个爆款内容有 1000 万点赞用户那么这一个 key 就要存储 1000 万个 bit ≈ 1.25MB。每次 BITCOUNT 或 BITOP 都会成为 CPU 瓶颈。
分片之后
| 收益 | 说明 |
|---|---|
| 避免单键过热 | 同一内容的读写压力被哈希打散到多个 key单键不会"胖死" |
| 并行统计 | 总点赞数 = Σ 各分片 BITCOUNT可管道化并行执行 |
| 按需迁移 | 冷分片可以整体迁到冷存储热分片保留在高配实例 |
| 易于扩展 | 将来用户量级增长只需调整映射规则或增加分片数 |
写路径极其简单——一次 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。删除内容时由后台任务按前缀清理相关分片。
位图解决了事实层的问题但每次查询"某文章总点赞数"都去扫分片 BITCOUNT 显然不现实——那是偶尔重建时才用的重型操作。日常的高频计数查询我们需要一个轻量的汇总层。
当用户行为落到位图后同时产出一条增量事件到 Kafka
{ etype: "article", eid: "42", metric: "like", delta: +1, ts: 1717584000 }
消费者侧不对每条事件单独写 SDS——那会导致 Redis 写入 QPS 爆炸。而是引入聚合桶Aggregation Bucket
这样原来每秒上万次的事件流被压缩成每秒几十次的批量 SDS 更新。
SDS 不再用 Hash 或 JSON 存储计数而是用定长字节数组
[like:4B][fav:4B][comment:4B][share:4B][view:4B]
每个指标占 4 字节大端 32 位无符号整数一次 GET 拿回完整的 20 字节在应用层直接按偏移量解析。相比 HGETALL 或 JSON 反序列化这种方式
当检测到 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 重建没拿到锁的节点直接返回降级数据保证接口可用。
内容维度的计数重建对所有分片并行执行 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。
重建失败时接口不抛异常而是返回默认零值保证前端不白屏
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 |
几个关键取舍值得强调
这套方案已经在我们的内容平台上平稳运行支撑着百万级用户的高频点赞、收藏、关注场景。希望这些思路能对同样面临"高并发计数"难题的读者有所启发。