知光项目计数模块:高并发下点赞 / 收藏的高性能实现之道
知光项目计数模块针对高并发场景下的点赞/收藏等计数需求,设计了"位图存事实、事件异步化、SDS高性能存储"的三层架构。通过Redis位图记录用户操作事实保证幂等性,Kafka事件解耦实现异步处理,自定义SDS结构存储汇总数据提升查询性能。该方案采用分片位图节省空间、聚合桶减少写压力、定时刷写保证数据一致性,有效解决了高并发计数场景下的性能瓶颈和数据准确性问题。
知光项目计数模块:高并发下点赞 / 收藏的高性能实现之道
知光项目来源于小红书程序员流年,项目非常优秀,欢迎一起学习。下面是我的计数模块整理笔记。
在社交类产品中,点赞、收藏、粉丝数这类计数功能,看似是 “小功能”,实则是系统高并发、高可用的 “试金石”—— 用户一秒内多次点击点赞、数万用户同时给一条内容点赞、Redis 故障后数据如何恢复…… 这些场景都考验着计数模块的设计功底。
知光项目的计数模块,聚焦于 “点赞 / 收藏 / 粉丝 / 获赞” 等核心计数场景,通过 “位图存事实、事件做异步、SDS 提性能、多机制保可用” 的设计思路,既扛住了高并发的用户操作,又保证了计数的准确性和系统的稳定性。本文结合项目代码与设计文档,拆解这个计数模块的核心实现逻辑和设计巧思。
一、先明确:计数模块要解决的核心问题
在动手写代码前,我们先梳理了计数模块的核心诉求,这也是整个设计的出发点:
- 高并发不卡顿:用户频繁点赞 / 取消赞、收藏 / 取消收藏,接口响应必须毫秒级,不能因为计数操作拖慢主流程;
- 幂等性保障:用户重复点击(比如连续点两次点赞),不能重复计数,也不能让状态乱掉;
- 计数要准确:既要避免并发导致的计数错误,也要能在 Redis 故障后恢复真实计数;
- 查询要高效:无论是查单条内容的点赞数,还是批量查多条,都要快;
- 异常可恢复:就算 Redis 数据丢了,也能基于 “用户操作事实” 重建计数,不丢核心数据。
基于这些诉求,我们没有采用 “用户操作→直接改数据库计数” 的简单方案(扛不住高并发),而是设计了 “三层架构”:
- 事实层:用 Redis 位图记录 “谁对谁做了什么操作”(比如 “用户 A 给内容 B 点了赞”),这是计数的 “原始依据”;
- 事件层:用户操作触发状态变化时,发送 Kafka 事件异步通知计数更新,避免同步操作阻塞接口;
- 汇总层:用 Redis SDS(固定字节结构)存储最终的计数结果(比如内容 B 的点赞数),保证查询性能。
二、核心实现:从用户操作到计数展示的全流程
1. 事实层:用分片位图存 “谁操作了什么”(幂等 + 省空间)
用户点击 “点赞” 的那一刻,系统首先要做的不是改 “总点赞数”,而是记录 “这个用户给这条内容点了赞” 这个事实 —— 这一步是整个计数模块的根基,我们用Redis 位图(Bitmap)+ 分片 来实现。
(1)为什么选位图?
位图是 Redis 的一种特殊字符串结构,把 “用户 ID” 映射成位图里的一个 “位”(0/1):1 代表 “已点赞 / 已收藏”,0 代表 “未操作”。它的核心优势有两个:
- 极致省空间:1 个 Redis Key 的位图能存数亿用户的状态(比如 32768 位 = 4KB,能存 32768 个用户的操作状态);
- 原子操作:GETBIT/SETBIT 操作都是 O (1),且能通过 Lua 脚本保证 “查状态 + 改状态” 的原子性,避免并发问题。
(2)分片:避免单 Key 膨胀
如果把所有用户的操作都存在一个位图 Key 里,当用户量上亿时,这个 Key 会变得极大,性能下降。因此我们做了固定分片(代码里BitmapShard类):
// 每个分片存32768个用户的状态(4KB/分片)
public static final int CHUNK_SIZE = 32_768;
// 根据用户ID计算所属分片
public static long chunkOf(long userId) {
return userId / CHUNK_SIZE;
}
// 计算用户在分片内的位偏移
public static long bitOf(long userId) {
return userId % CHUNK_SIZE;
}
比如用户 ID=100000,会被分到100000/32768=3号分片,在分片内的偏移是100000%32768=3456,操作的是bm:like:knowpost:123:3这个 Key 的第 3456 位 —— 既避免了单 Key 过大,又能快速定位用户状态。
(3)原子切换:保证幂等性
用户点击点赞 / 取消赞时,我们用 Lua 脚本保证 “查状态 + 改状态” 是一个原子操作(代码里CounterServiceImpl的TOGGLE_LUA):
local bmKey = KEYS[1]
local offset = tonumber(ARGV[1])
local op = ARGV[2] -- 'add' or 'remove'
local prev = redis.call('GETBIT', bmKey, offset)
if op == 'add' then
if prev == 1 then return 0 end -- 已点赞,返回0(无状态变化)
redis.call('SETBIT', bmKey, offset, 1)
return 1 -- 状态变化,返回1
elseif op == 'remove' then
if prev == 0 then return 0 end -- 未点赞,返回0
redis.call('SETBIT', bmKey, offset, 0)
return 1
end
这个逻辑保证了:用户重复点击点赞,只会在 “未点赞→已点赞” 时返回 “状态变化”,其他情况都返回 “无变化”,从根本上保证了幂等性。
只有当状态真的变化时(比如从 0→1),才会通过CounterEventProducer往 Kafka 发送一个 “计数增量事件”(比如 “内容 123 的点赞数 + 1”),异步更新汇总计数。
2. 事件层:Kafka 异步解耦,避免同步阻塞
我们没有在用户操作时直接改 “总点赞数”,而是通过 Kafka 事件异步处理 —— 这是高并发场景下的核心优化思路:
(1)生产事件:只在状态变化时发
用户操作触发状态变化后,CounterEventProducer会把事件(实体类型、ID、指标、增量)序列化成 JSON,发送到 Kafka 的CounterTopics.EVENTS主题:
public void publish(CounterEvent event) {
try {
String payload = objectMapper.writeValueAsString(event);
kafka.send(CounterTopics.EVENTS, payload); // 异步发送,不阻塞主流程
} catch (JsonProcessingException e) {
// 生产异常不抛,避免影响用户操作,可接入告警
}
}
比如用户给 “knowpost-123” 点赞,发送的事件就是:entityType=knowpost, entityId=123, metric=like, idx=0, delta=1。
(2)消费事件:先存聚合桶,避免频繁写汇总
CounterAggregationConsumer消费 Kafka 事件后,不会直接改汇总计数,而是先把增量写到Redis Hash 聚合桶(agg:schema:knowpost:123):
// 消费事件,把增量写入聚合桶
String aggKey = CounterKeys.aggKey(evt.getEntityType(), evt.getEntityId());
String field = String.valueOf(evt.getIdx());
redis.opsForHash().increment(aggKey, field, evt.getDelta());
比如 1000 个用户给内容 123 点赞,聚合桶里agg:knowpost:123的0字段(点赞指标的索引)会累加 1000—— 这一步把 “1000 次写操作” 变成了 “1 次累加操作”,极大减少 Redis 写压力。
3. 汇总层:SDS 固定结构 + 定时刷写,兼顾性能与准确性
汇总层的核心是用SDS(固定字节结构) 存储最终计数,搭配 “1 秒一次的定时刷写”,既保证查询性能,又避免频繁更新。
(1)SDS:比 Hash 更快的计数存储方式
SDS 是我们自定义的固定字节结构:把 “点赞数、收藏数、粉丝数” 等指标,按 “4 字节(32 位)/ 指标” 的固定偏移存在一个 Redis 字符串里。比如:
- 点赞数:偏移 0-3 字节;
- 收藏数:偏移 4-7 字节;
- 粉丝数:偏移 8-11 字节。
读取时直接按偏移取字节、转数字,比 Redis Hash 快得多(少了 Hash 的字段解析开销);写入时通过 Lua 脚本原子更新指定偏移的字节(代码里INCR_FIELD_LUA):
-- 读取32位大端整数
local function read32be(s, off)
local b = {string.byte(s, off+1, off+4)}
local n = 0
for i=1,4 do n = n * 256 + b[i] end
return n
end
-- 写入32位大端整数
local function write32be(n)
local t = {}
for i=4,1,-1 do t[i] = n % 256; n = math.floor(n/256) end
return string.char(unpack(t))
end
-- 原子更新计数
local cnt = redis.call('GET', cntKey)
if not cnt then cnt = string.rep(string.char(0), schemaLen * fieldSize) end
local off = idx * fieldSize
local v = read32be(cnt, off) + delta
if v < 0 then v = 0 end
local seg = write32be(v)
cnt = string.sub(cnt, 1, off) .. seg .. string.sub(cnt, off+fieldSize+1)
redis.call('SET', cntKey, cnt)
(2)定时刷写:1 秒一次,批量更新
CounterAggregationConsumer里有个 1 秒执行一次的定时任务(@Scheduled(fixedDelay = 1000L)),把聚合桶里的增量批量刷写到 SDS:
// 扫描所有聚合桶Key
Set<String> keys = redis.keys("agg:" + CounterSchema.SCHEMA_ID + ":*");
for (String aggKey : keys) {
// 读取聚合桶里的增量
Map<Object, Object> entries = redis.opsForHash().entries(aggKey);
// 解析内容类型/ID,定位SDS Key
String[] parts = aggKey.split(":", 4);
String cntKey = CounterKeys.sdsKey(parts[2], parts[3]);
// 批量刷写增量到SDS
for (Map.Entry<Object, Object> e : entries.entrySet()) {
long delta = Long.parseLong(String.valueOf(e.getValue()));
int idx = Integer.parseInt(String.valueOf(e.getKey()));
redis.execute(incrScript, List.of(cntKey),
String.valueOf(CounterSchema.SCHEMA_LEN),
String.valueOf(CounterSchema.FIELD_SIZE),
String.valueOf(idx), String.valueOf(delta));
// 刷写后扣减聚合桶增量,避免重复计算
redis.execute(decrScript, List.of(aggKey), field, String.valueOf(delta));
}
}
这样做的好处是:就算短时间内有大量增量,也只需要 1 秒刷一次 SDS,而非每次操作都写,大幅降低 Redis 的写压力。
4. 特殊场景:用户维度计数(粉丝 / 获赞 / 发文数)
除了 “单条内容的点赞 / 收藏数”,我们还需要统计 “用户的总获赞、总收藏、粉丝数、发文数”—— 这部分逻辑在UserCounterServiceImpl中实现:
-
增量更新:用户涨粉、发新内容、内容获赞时,异步更新用户维度的 SDS(比如
incrementFollowers方法,更新粉丝数偏移的字节); -
全量重建
:如果用户计数不准,触发
rebuildAllCounters方法:
- 从数据库查用户的关注数、粉丝数、已发布内容 ID;
- 批量查这些内容的点赞 / 收藏数,汇总成 “总获赞 / 总收藏”;
- 把这些值写回用户维度的 SDS,保证计数准确。
三、高可用保障:限流、退避、锁、灾备
计数模块的 “高可用”,核心是应对 “Redis 数据丢失”“热点内容重建风暴” 等异常场景,我们设计了多层防护机制:
1. 计数重建:基于位图恢复真实值
如果 SDS 里的汇总计数丢了(比如 Redis 重启),CounterServiceImpl的getCounts方法会触发 “计数重建”:
-
加分布式锁:用 Redisson 的 RLock 加锁,避免多线程重复重建;
-
限流 + 退避
:
- 限流:10 秒内最多重建 3 次(
counter.rebuild.rate.permits=3),防止频繁重建; - 退避:重建失败时,指数级增加下次重建的等待时间(从 500ms 到 30s 封顶),避免热点内容拖垮 Redis;
- 限流:10 秒内最多重建 3 次(
-
位图统计:遍历该内容的所有位图分片,用 BITCOUNT 统计真实的点赞 / 收藏数;
-
回写 SDS:把统计结果写回 SDS,重置退避状态。
2. 灾备兜底:Kafka 事件回放
如果 Redis 的位图数据也丢了(极端场景),我们还有CounterRebuildConsumer:
- 开启
counter.rebuild.enabled=true后,消费者从 Kafka 的earliest位点回放所有历史事件; - 直接把历史事件的增量刷写到 SDS,重建所有计数 —— 这是最后的数据恢复手段。
3. 批量查询优化:管道减少网络往返
查多条内容的计数时(比如用户主页展示多篇内容的点赞数),CounterServiceImpl的getCountsBatch方法用 Redis 管道(Pipeline)批量读取 SDS:
// 批量GET,一次网络往返搞定
List<Object> raws = redis.executePipelined((RedisCallback<Object>) connection -> {
for (String k : keys) {
connection.stringCommands().get(k.getBytes(StandardCharsets.UTF_8));
}
return null;
});
相比 “查一条发一次请求”,管道把 RTT(往返时间)从 N 次降到 1 次,查询性能提升数倍。
四、设计亮点与避坑总结
核心亮点
- 分层解耦:事实层(位图)、事件层(Kafka)、汇总层(SDS)分离,主流程只写事实,异步更汇总,兼顾性能与可维护性;
- 极致省资源:位图存储用户状态,4KB 能存 32768 个用户;SDS 存储计数,比 Hash 更省内存、更快;
- 多机制保可用:分布式锁、限流、退避、Kafka 回放,层层防护,避免单点故障导致计数丢失;
- 幂等性贯穿:从用户操作的 Lua 脚本,到 Kafka 事件的消费,再到聚合桶的扣减,全程保证幂等,不怕重复操作。
踩过的坑 & 经验
- 单 Key 膨胀:最初没做位图分片,单 Key 存百万用户状态后,Redis 操作延迟飙升,分片后问题解决;
- 同步刷写卡顿:早期试过用户操作时直接改 SDS,高并发下接口响应从 10ms 涨到 100ms+,换成异步事件 + 聚合桶后恢复正常;
- 重建风暴:没加限流退避时,热点内容重建导致 Redis CPU 打满,加了限流和指数退避后,Redis 负载稳定;
- Kafka 位点提交:消费事件时,必须 “写入聚合桶成功后再提交位点”,否则会丢事件导致计数不准。
五、总结
知光项目的计数模块,核心是 “把复杂问题拆成简单步骤”:
- 用位图存 “谁操作了什么”,保证事实准确;
- 用 Kafka 异步解耦,避免主流程阻塞;
- 用 SDS 提性能,保证查询快;
- 用限流、锁、回放保可用,应对各种异常。
这套方案既扛住了高并发的用户操作(毫秒级响应),又保证了计数的准确性,还能在故障后恢复数据 —— 是社交类产品计数模块的典型实践。
其实计数模块的设计,本质是 “取舍”:放弃 “实时同步改计数”,换来了高并发下的稳定性;放弃 “简单的 Hash 存储”,换来了更优的查询性能;增加 “多层防护机制”,换来了系统的高可用。这些取舍,也是高并发系统设计的核心思路。
更多推荐
所有评论(0)