知光项目计数模块:高并发下点赞 / 收藏的高性能实现之道

知光项目来源于小红书程序员流年,项目非常优秀,欢迎一起学习。下面是我的计数模块整理笔记。

在社交类产品中,点赞、收藏、粉丝数这类计数功能,看似是 “小功能”,实则是系统高并发、高可用的 “试金石”—— 用户一秒内多次点击点赞、数万用户同时给一条内容点赞、Redis 故障后数据如何恢复…… 这些场景都考验着计数模块的设计功底。

知光项目的计数模块,聚焦于 “点赞 / 收藏 / 粉丝 / 获赞” 等核心计数场景,通过 “位图存事实、事件做异步、SDS 提性能、多机制保可用” 的设计思路,既扛住了高并发的用户操作,又保证了计数的准确性和系统的稳定性。本文结合项目代码与设计文档,拆解这个计数模块的核心实现逻辑和设计巧思。

一、先明确:计数模块要解决的核心问题

在动手写代码前,我们先梳理了计数模块的核心诉求,这也是整个设计的出发点:

  1. 高并发不卡顿:用户频繁点赞 / 取消赞、收藏 / 取消收藏,接口响应必须毫秒级,不能因为计数操作拖慢主流程;
  2. 幂等性保障:用户重复点击(比如连续点两次点赞),不能重复计数,也不能让状态乱掉;
  3. 计数要准确:既要避免并发导致的计数错误,也要能在 Redis 故障后恢复真实计数;
  4. 查询要高效:无论是查单条内容的点赞数,还是批量查多条,都要快;
  5. 异常可恢复:就算 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 脚本保证 “查状态 + 改状态” 是一个原子操作(代码里CounterServiceImplTOGGLE_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:1230字段(点赞指标的索引)会累加 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
    

    方法:

    1. 从数据库查用户的关注数、粉丝数、已发布内容 ID;
    2. 批量查这些内容的点赞 / 收藏数,汇总成 “总获赞 / 总收藏”;
    3. 把这些值写回用户维度的 SDS,保证计数准确。

三、高可用保障:限流、退避、锁、灾备

计数模块的 “高可用”,核心是应对 “Redis 数据丢失”“热点内容重建风暴” 等异常场景,我们设计了多层防护机制:

1. 计数重建:基于位图恢复真实值

如果 SDS 里的汇总计数丢了(比如 Redis 重启),CounterServiceImplgetCounts方法会触发 “计数重建”:

  1. 加分布式锁:用 Redisson 的 RLock 加锁,避免多线程重复重建;

  2. 限流 + 退避

    • 限流:10 秒内最多重建 3 次(counter.rebuild.rate.permits=3),防止频繁重建;
    • 退避:重建失败时,指数级增加下次重建的等待时间(从 500ms 到 30s 封顶),避免热点内容拖垮 Redis;
  3. 位图统计:遍历该内容的所有位图分片,用 BITCOUNT 统计真实的点赞 / 收藏数;

  4. 回写 SDS:把统计结果写回 SDS,重置退避状态。

2. 灾备兜底:Kafka 事件回放

如果 Redis 的位图数据也丢了(极端场景),我们还有CounterRebuildConsumer

  • 开启counter.rebuild.enabled=true后,消费者从 Kafka 的earliest位点回放所有历史事件;
  • 直接把历史事件的增量刷写到 SDS,重建所有计数 —— 这是最后的数据恢复手段。

3. 批量查询优化:管道减少网络往返

查多条内容的计数时(比如用户主页展示多篇内容的点赞数),CounterServiceImplgetCountsBatch方法用 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 次,查询性能提升数倍。

四、设计亮点与避坑总结

核心亮点

  1. 分层解耦:事实层(位图)、事件层(Kafka)、汇总层(SDS)分离,主流程只写事实,异步更汇总,兼顾性能与可维护性;
  2. 极致省资源:位图存储用户状态,4KB 能存 32768 个用户;SDS 存储计数,比 Hash 更省内存、更快;
  3. 多机制保可用:分布式锁、限流、退避、Kafka 回放,层层防护,避免单点故障导致计数丢失;
  4. 幂等性贯穿:从用户操作的 Lua 脚本,到 Kafka 事件的消费,再到聚合桶的扣减,全程保证幂等,不怕重复操作。

踩过的坑 & 经验

  1. 单 Key 膨胀:最初没做位图分片,单 Key 存百万用户状态后,Redis 操作延迟飙升,分片后问题解决;
  2. 同步刷写卡顿:早期试过用户操作时直接改 SDS,高并发下接口响应从 10ms 涨到 100ms+,换成异步事件 + 聚合桶后恢复正常;
  3. 重建风暴:没加限流退避时,热点内容重建导致 Redis CPU 打满,加了限流和指数退避后,Redis 负载稳定;
  4. Kafka 位点提交:消费事件时,必须 “写入聚合桶成功后再提交位点”,否则会丢事件导致计数不准。

五、总结

知光项目的计数模块,核心是 “把复杂问题拆成简单步骤”:

  • 用位图存 “谁操作了什么”,保证事实准确;
  • 用 Kafka 异步解耦,避免主流程阻塞;
  • 用 SDS 提性能,保证查询快;
  • 用限流、锁、回放保可用,应对各种异常。

这套方案既扛住了高并发的用户操作(毫秒级响应),又保证了计数的准确性,还能在故障后恢复数据 —— 是社交类产品计数模块的典型实践。

其实计数模块的设计,本质是 “取舍”:放弃 “实时同步改计数”,换来了高并发下的稳定性;放弃 “简单的 Hash 存储”,换来了更优的查询性能;增加 “多层防护机制”,换来了系统的高可用。这些取舍,也是高并发系统设计的核心思路。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐