重生之我创作出了小红书:计数模块 SDS 位图分片与偏移 异步发送

计数模块详解:点赞流程实现与技术分析

好的,我来慢慢给你解释计数模块。让我们从一个具体的业务场景开始:用户点赞帖子。

场景设定:用户小明点赞帖子A

业务流程图



用户点击点赞按钮
ActionController接收请求

e:\\download\\zhiguang_be-main\\src\\main\\java\\com\\tongji\\counter\\api\\ActionController.java
调用CounterServiceImpl.like

e:\\download\\zhiguang_be-main\\src\\main\\java\\com\\tongji\\counter\\service\\impl\\CounterServiceImpl.java
计算位图分片和偏移

BitmapShard.java
执行Lua脚本原子操作

toggleScript
状态变化?
发布Kafka事件

CounterEventProducer.java
返回false
发布本地事件

ApplicationEventPublisher
返回true
返回结果给前端
Kafka消费事件

CounterAggregationConsumer.java
写入聚合桶

Redis Hash
定时刷写

每1秒
执行Lua脚本更新SDS

INCR_FIELD_LUA
删除聚合桶字段

一、流程详细说明

1. 用户操作

用户小明在前端点击帖子A的点赞按钮。

2. 接口接收

java 复制代码
// ActionController.java:34-43
@PostMapping("/like")
public ResponseEntity<Map<String, Object>> like(@Valid @RequestBody ActionRequest req,
                                              @AuthenticationPrincipal Jwt jwt) {
    long uid = jwtService.extractUserId(jwt);
    boolean changed = counterService.like(req.getEntityType(), req.getEntityId(), uid);
    return ResponseEntity.ok(Map.of(
            "changed", changed, // 标识这次操作是否改变状态
            "liked", counterService.isLiked(req.getEntityType(), req.getEntityId(), uid)
    ));
}

技术点

  • 使用Spring Security的@AuthenticationPrincipal获取当前用户
  • 调用CounterService.like()方法处理点赞
  • 返回操作是否成功及当前点赞状态

3. 服务处理

java 复制代码
// CounterServiceImpl.java:74-77
@Override
public boolean like(String entityType, String entityId, long userId) {
    return toggle(entityType, entityId, userId, "like", CounterSchema.IDX_LIKE, true);
}

技术点

  • 统一调用toggle方法处理点赞/取消点赞
  • 传递参数:实体类型、实体ID、用户ID、指标名、指标索引、是否添加

4. 位图操作

java 复制代码
// CounterServiceImpl.java:113-130
private boolean toggle(String etype, String eid, long uid, String metric, int idx, boolean add) {
    // 固定分片定位:按用户ID映射到 chunk 与分片内 bit 偏移
    long chunk = BitmapShard.chunkOf(uid);
    long bit = BitmapShard.bitOf(uid);
    String bmKey = CounterKeys.bitmapKey(metric, etype, eid, chunk);
    List<String> keys = List.of(bmKey);
    List<String> args = List.of(String.valueOf(bit), add ? "add" : "remove");
    Long changed = redis.execute(toggleScript, keys, args.toArray());
    boolean ok = changed == 1L;
    if (ok) {
        int delta = add ? 1 : -1;
        // 产出计数事件(异步聚合)
        eventProducer.publish(CounterEvent.of(etype, eid, metric, idx, uid, delta));
        // 本地事件:触发缓存失效
        eventPublisher.publishEvent(CounterEvent.of(etype, eid, metric, idx, uid, delta));
    }
    return ok;
}

技术点

  • 使用BitmapShard计算分片和偏移
  • 构造位图键:bm:like:knowpost:A:0
  • 执行Lua脚本进行原子操作
  • 状态变化时发布事件

位图操作详解:从基础概念到实际应用

好的,我来慢慢给你解释位图操作。让我们从最基础的概念开始,一步一步理解它在计数模块中的应用。

一、什么是位图?

基础概念

想象一个巨大的开关面板,上面有很多小开关,每个开关只有两种状态:开(1)或关(0)。位图就是这样的一个数据结构,用二进制位来表示状态。

  • 每一位:代表一个用户的状态(比如是否点赞)
  • 0:表示未点赞
  • 1:表示已点赞

生活中的类比

比如,图书馆的座位表:

  • 座位表上有100个格子,每个格子代表一个座位
  • 格子里打勾(1)表示有人,打叉(0)表示没人
  • 你可以快速知道哪些座位是空的,也可以数出总共有多少人

二、为什么使用位图?

1. 超级节省空间

  • 1个字节 = 8位,可以表示8个用户的状态
  • 1KB = 1024字节 = 8192位,可以表示8192个用户
  • 1MB = 1024KB,可以表示约800万个用户

例子:100万用户的点赞状态,只需要约125KB的空间,比用其他数据结构(比如Hash)节省成百上千倍。

2. 操作速度快

  • GETBIT:直接定位到某一位,瞬间知道状态(O(1)时间)
  • SETBIT:直接设置某一位的状态,瞬间完成(O(1)时间)
  • BITCOUNT:统计1的数量,快速计算总次数

3. 支持位运算

  • AND:交集,比如同时点赞了两个帖子的用户
  • OR:并集,比如点赞了帖子A或帖子B的用户
  • XOR:异或,比如只点赞了其中一个帖子的用户

三、项目中的位图实现

1. 位图分片

为什么需要分片?

  • 如果所有用户都存在一个位图中,这个位图会非常大
  • 大位图会导致:
    • 内存占用高
    • 操作速度变慢
    • 单键压力大,并发性能差

分片策略

  • 每个分片32768位(4KB)
  • 用户ID除以32768得到分片号
  • 用户ID模32768得到分片内的位置

代码实现

java 复制代码
// BitmapShard.java:11-16
public static long chunkOf(long userId) {
    return userId / CHUNK_SIZE; // CHUNK_SIZE = 32768
}

public static long bitOf(long userId) {
    return userId % CHUNK_SIZE;
}

例子

  • 用户ID = 100000
  • 分片号 = 100000 / 32768 = 3
  • 分片内位置 = 100000 % 32768 = 344
  • 位图键 = bm:like:knowpost:A:3

2. 位图操作

点赞操作

  • 计算分片和偏移
  • 执行SETBIT操作,将对应位置为1
  • 检查之前的状态,确保幂等性

代码实现

lua 复制代码
-- CounterServiceImpl.java:443-458
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  -- 已经点过赞,不重复计数
  redis.call('SETBIT', bmKey, offset, 1)
  return 1  -- 状态改变
elseif op == 'remove' then
  if prev == 0 then return 0 end  -- 已经取消点赞,不重复计数
  redis.call('SETBIT', bmKey, offset, 0)
  return 1  -- 状态改变
end
return -1

计数统计

  • 扫描所有分片
  • 对每个分片执行BITCOUNT
  • 汇总结果

代码实现

java 复制代码
// CounterServiceImpl.java:262-279
private long bitCountShardsPipelined(String metric, String etype, String eid) {
    List<String> keys = new ArrayList<>();
    // 扫描所有分片
    Set<String> bmKeys = redis.keys(CounterKeys.bitmapKey(metric, etype, eid, "*"));
    for (String bmKey : bmKeys) {
        keys.add(bmKey);
    }
    if (keys.isEmpty()) {
        return 0;
    }
    // 管道批量执行 BITCOUNT
    return redis.executePipelined((RedisCallback<Object>) connection -> {
        for (String key : keys) {
            connection.bitCount(key.getBytes(StandardCharsets.UTF_8));
        }
        return null;
    }).stream()
      .mapToLong(n -> (Long) n)
      .sum();
}

四、位图在计数模块中的作用

1. 作为事实层

  • 位图记录了所有用户的行为,是最原始的"事实"
  • 即使SDS数据丢失,也可以通过位图重建计数
  • 保证数据的可靠性

2. 实现幂等操作

  • 通过GETBIT检查之前的状态
  • 只有状态变化时才更新计数
  • 避免重复计数,保证数据准确

3. 支持计数重建

  • 当SDS数据异常时,基于位图重建
  • 扫描所有分片,统计1的数量
  • 确保计数的准确性

五、位图操作的技术亮点

1. 分片设计

  • 解决了单键膨胀问题
  • 提高了并发性能
  • 支持用户量线性增长

2. Lua原子操作

  • 保证了操作的原子性
  • 避免了并发冲突
  • 提高了操作的可靠性

3. 管道批量处理

  • 减少了网络往返
  • 提高了批量操作的性能
  • 支持快速重建计数

六、传统方法与位图对比

操作 传统方法(Hash) 位图方法 优势
存储空间 每个用户一个字段,100万用户约需要10MB 100万用户约需要125KB 节省约80倍空间
状态检查 HGET,返回值判断 GETBIT,直接返回0/1 更快,更简单
状态设置 HSET,覆盖操作 SETBIT,位操作 更快,更省空间
计数统计 HGETALL,遍历计数 BITCOUNT,硬件加速 快10-100倍
并发性能 单键压力大 分片分散压力 并发更高

七、实际应用场景

1. 点赞/收藏

  • 记录用户是否点赞/收藏
  • 统计总点赞/收藏数
  • 快速判断用户是否已操作

2. 签到系统

  • 记录用户每天的签到状态
  • 统计连续签到天数
  • 快速判断用户是否已签到

3. 权限管理

  • 记录用户的权限状态
  • 快速判断用户是否有某权限
  • 支持权限的批量检查

八、总结

位图是一种非常高效的数据结构,通过二进制位来表示状态,具有以下特点:

  • 节省空间:100万用户只需要约125KB
  • 操作快速:O(1)时间复杂度
  • 支持位运算:灵活的集合操作
  • 可靠:作为事实层,支持重建

在计数模块中,位图作为事实层,记录了所有用户的行为,保证了数据的可靠性和准确性。通过分片设计和Lua原子操作,解决了高并发场景下的性能问题。

5. Lua脚本执行

lua 复制代码
-- CounterServiceImpl.java:443-458
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
  redis.call('SETBIT', bmKey, offset, 1)
  return 1
elseif op == 'remove' then
  if prev == 0 then return 0 end
  redis.call('SETBIT', bmKey, offset, 0)
  return 1
end
return -1

技术点

  • 检查之前的状态
  • 只有状态变化时才修改并返回1
  • 保证幂等性,避免重复计数

Lua脚本在计数模块中的作用

一、核心问题:并发冲突

场景假设

想象这样一个场景:用户小明在手机上快速点击了两次点赞按钮。

如果没有Lua脚本,可能会发生这样的情况:

复制代码
时间线:
T1: 第一次点击 → 检查位图,发现是0 → 准备设置为1
T2: 第二次点击 → 检查位图,发现还是0 → 也准备设置为1
T3: 第一次操作完成 → 设置为1,计数+1
T4: 第二次操作完成 → 设置为1,计数+1
结果:计数变成了2,但实际只应该+1

这就是并发冲突问题。

二、Lua脚本的作用

1. 保证原子性

什么是原子性?

  • 原子性就是"要么全部执行,要么全部不执行"
  • 中间不会被其他操作打断

Lua脚本如何实现原子性?

  • Redis执行Lua脚本时,会锁定相关的键
  • 脚本执行期间,其他操作无法访问这些键
  • 脚本执行完毕后,才释放锁

实际效果

复制代码
时间线:
T1: 第一次点击 → 开始执行Lua脚本,锁定位图键
T2: 第二次点击 → 等待Lua脚本执行完毕
T3: Lua脚本执行 → 检查位图(0) → 设置为1 → 返回1
T4: 第一次操作完成 → 计数+1
T5: 第二次点击 → 开始执行Lua脚本,锁定位图键
T6: Lua脚本执行 → 检查位图(1) → 返回0(不改变状态)
T7: 第二次操作完成 → 计数不变
结果:计数正确地+1

2. 实现幂等性

什么是幂等性?

  • 幂等性就是"多次执行和一次执行的结果相同"
  • 无论操作多少次,结果都一样

Lua脚本如何实现幂等性?

lua 复制代码
-- toggleScript.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  -- 已经是1,不重复计数
  redis.call('SETBIT', bmKey, offset, 1)
  return 1  -- 状态改变
elseif op == 'remove' then
  if prev == 0 then return 0 end  -- 已经是0,不重复计数
  redis.call('SETBIT', bmKey, offset, 0)
  return 1  -- 状态改变
end
return -1

实际效果

  • 第一次点赞:检查是0 → 设置为1 → 返回1(计数+1)
  • 第二次点赞:检查是1 → 不设置 → 返回0(计数不变)
  • 无论点多少次,计数都只+1

3. 减少网络往返

传统方法的问题

复制代码
客户端 → Redis: GETBIT bm:like:knowpost:A:0 100
Redis → 客户端: 0
客户端 → Redis: SETBIT bm:like:knowpost:A:0 100 1
Redis → 客户端: OK
客户端 → Redis: HINCRBY agg:v1:knowpost:A 1 1
Redis → 客户端: 1

需要3次网络往返。

Lua脚本的方法

复制代码
客户端 → Redis: EVAL toggleScript 1 bm:like:knowpost:A:0 100 add
Redis → 客户端: 1

只需要1次网络往返。

性能提升

  • 网络往返从3次减少到1次
  • 延迟降低约66%
  • 吞吐量提升约3倍

4. 保证数据一致性

SDS增量更新的问题

复制代码
时间线:
T1: 读取SDS,点赞数是100
T2: 另一个操作也读取SDS,点赞数是100
T3: 第一个操作+1,写回SDS,点赞数变成101
T4: 第二个操作+1,写回SDS,点赞数变成101
结果:点赞了2次,但计数只+1

Lua脚本如何解决?

lua 复制代码
-- incrScript.lua
local cntKey = KEYS[1]
local schemaLen = tonumber(ARGV[1])
local fieldSize = tonumber(ARGV[2]) -- 固定为4
local idx = tonumber(ARGV[3])
local delta = tonumber(ARGV[4])

-- 读取当前值
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)
return 1

实际效果

  • 脚本执行期间,SDS键被锁定
  • 其他操作无法读取或修改
  • 保证增量正确累加

三、Lua脚本的具体应用

1. 点赞/取消点赞

解决的问题

  • 重复点赞导致计数错误
  • 并发点赞导致计数不准确
  • 网络往返次数多,性能差

实现

lua 复制代码
-- toggleScript.lua
-- 见上面的代码

效果

  • 幂等性:重复操作不影响计数
  • 原子性:并发操作不会冲突
  • 性能:减少网络往返

2. SDS增量更新

解决的问题

  • 并发更新导致计数丢失
  • 读-改-写操作的数据不一致
  • 网络往返次数多

实现

lua 复制代码
-- incrScript.lua
-- 见上面的代码

效果

  • 原子性:并发更新不会冲突
  • 一致性:增量正确累加
  • 性能:减少网络往返

四、传统方法与Lua脚本对比

问题 传统方法 Lua脚本 优势
并发冲突 可能发生 不会发生 数据准确
幂等性 需要额外处理 天然支持 代码简单
网络往返 多次 一次 性能提升3倍
数据一致性 可能不一致 保证一致 数据可靠
代码复杂度 易于维护

五、总结

Lua脚本在计数模块中解决了以下核心问题:

  1. 并发冲突:通过原子操作,避免多个操作同时修改数据
  2. 幂等性:通过检查之前的状态,避免重复计数
  3. 网络性能:通过减少网络往返,提高系统吞吐量
  4. 数据一致性:通过锁定机制,保证数据的准确性

这些问题的解决,使得计数模块能够在高并发场景下,既快又准地处理用户的点赞、收藏等操作。

核心本质:Lua脚本将多个Redis操作打包成一个原子操作,既保证了数据的一致性,又提高了系统的性能。

6. 事件发布

java 复制代码
// CounterEventProducer.java:27-34
public void publish(CounterEvent event) {
    try {
        String payload = objectMapper.writeValueAsString(event);
        kafka.send(CounterTopics.EVENTS, payload); // 异步写入计数事件主题
    } catch (JsonProcessingException e) {
        // 生产异常不抛出影响主流程
    }
}

技术点

  • 将事件序列化为JSON
  • 发送到Kafka主题:counter-events
  • 异步操作,不阻塞主流程

事件发布与Kafka异步操作详解

一、事件发布流程

1. 什么是事件?

在计数模块中,事件是一个包含操作信息的消息,比如:

  • 用户点赞了帖子A
  • 用户取消收藏了帖子B
  • 用户关注了其他用户

事件结构

java 复制代码
// CounterEvent.java
public class CounterEvent {
    private String entityType; // 实体类型,如"knowpost"
    private String entityId;   // 实体ID,如帖子ID
    private String metric;     // 指标,如"like"、"fav"
    private int idx;           // 指标在SDS中的索引
    private long userId;       // 用户ID
    private int delta;         // 增量,+1或-1
}

2. 事件发布的时机

当用户执行点赞、取消点赞、收藏、取消收藏等操作时:

  • 首先执行位图操作,检查状态是否改变
  • 如果状态改变(返回1),则发布事件
  • 如果状态未改变(返回0),则不发布事件

代码实现

java 复制代码
// CounterServiceImpl.java:113-130
private boolean toggle(String etype, String eid, long uid, String metric, int idx, boolean add) {
    // 计算分片和偏移
    long chunk = BitmapShard.chunkOf(uid);
    long bit = BitmapShard.bitOf(uid);
    String bmKey = CounterKeys.bitmapKey(metric, etype, eid, chunk);
    List<String> keys = List.of(bmKey);
    List<String> args = List.of(String.valueOf(bit), add ? "add" : "remove");
    // 执行Lua脚本
    Long changed = redis.execute(toggleScript, keys, args.toArray());
    boolean ok = changed == 1L;
    if (ok) { // 只有状态改变时才发布事件
        int delta = add ? 1 : -1;
        // 发布Kafka事件(异步聚合)
        eventProducer.publish(CounterEvent.of(etype, eid, metric, idx, uid, delta));
        // 发布本地事件(触发缓存失效)
        eventPublisher.publishEvent(CounterEvent.of(etype, eid, metric, idx, uid, delta));
    }
    return ok;
}

二、Kafka事件发布实现

1. 事件生产者配置

Kafka配置

java 复制代码
// CounterConfig.java
@Configuration
public class CounterConfig {
    @Bean
    public KafkaTemplate<String, String> kafkaTemplate() {
        return new KafkaTemplate<>(producerFactory());
    }

    @Bean
    public ProducerFactory<String, String> producerFactory() {
        Map<String, Object> configs = new HashMap<>();
        configs.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        configs.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        configs.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        return new DefaultKafkaProducerFactory<>(configs);
    }
}

Kafka主题常量

java 复制代码
// CounterTopics.java
public class CounterTopics {
    public static final String EVENTS = "counter-events"; // 计数事件主题
    public static final String REBUILD = "counter-rebuild"; // 重建事件主题
}

2. 事件发布实现

事件生产者

java 复制代码
// CounterEventProducer.java
@Service
public class CounterEventProducer {
    private final KafkaTemplate<String, String> kafka;
    private final ObjectMapper objectMapper;

    @Autowired
    public CounterEventProducer(KafkaTemplate<String, String> kafka, ObjectMapper objectMapper) {
        this.kafka = kafka;
        this.objectMapper = objectMapper;
    }

    public void publish(CounterEvent event) {
        try {
            // 将事件序列化为JSON
            String payload = objectMapper.writeValueAsString(event);
            // 发送到Kafka主题
            kafka.send(CounterTopics.EVENTS, payload);
        } catch (JsonProcessingException e) {
            // 生产异常不抛出,不影响主流程
        }
    }
}

关键技术点

  • 使用KafkaTemplate发送消息
  • 将事件对象序列化为JSON字符串
  • 发送到counter-events主题
  • 异常捕获,确保主流程不被阻塞

三、异步操作实现原理

1. 什么是异步操作?

同步操作

  • 调用方发送请求后,等待响应,直到操作完成
  • 示例:直接更新数据库,等待更新完成后再返回

异步操作

  • 调用方发送请求后,立即返回,不等待操作完成
  • 示例:发送消息到Kafka,立即返回,由消费者异步处理

2. Kafka如何实现异步操作?

Kafka的特点

  • 消息队列:消息先存储在Kafka中
  • 异步处理:生产者发送消息后立即返回
  • 消费者:独立的进程或线程,异步消费消息

实现原理

  1. 生产者(计数服务):
    • 调用kafka.send()方法
    • Kafka客户端将消息放入本地缓冲区
    • 后台线程定期批量发送到Kafka服务器
    • 立即返回ListenableFuture对象
  2. 消费者(聚合服务):
    • 独立的线程,持续从Kafka拉取消息
    • 处理消息(更新聚合桶)
    • 提交消费位点

代码实现

java 复制代码
// CounterEventProducer.java:27-34
public void publish(CounterEvent event) {
    try {
        String payload = objectMapper.writeValueAsString(event);
        // 异步发送,立即返回
        kafka.send(CounterTopics.EVENTS, payload);
    } catch (JsonProcessingException e) {
        // 异常处理
    }
}

3. 异步操作的优势

  1. 提高响应速度

    • 主流程(用户点赞)立即完成
    • 不需要等待计数更新完成
    • 提升用户体验
  2. 削峰填谷

    • 高并发时,Kafka可以缓存消息
    • 消费者按自己的节奏处理
    • 保护系统不被瞬时流量压垮
  3. 解耦系统

    • 点赞操作与计数更新解耦
    • 两个系统可以独立部署、扩展
    • 提高系统的可靠性和可维护性

四、事件消费流程

1. 事件消费者配置

消费者配置

java 复制代码
// CounterConfig.java
@Bean
public KafkaListenerContainerFactory<?> kafkaListenerContainerFactory() {
    ConcurrentKafkaListenerContainerFactory<String, String> factory =
            new ConcurrentKafkaListenerContainerFactory<>();
    factory.setConsumerFactory(consumerFactory());
    return factory;
}

@Bean
public ConsumerFactory<String, String> consumerFactory() {
    Map<String, Object> configs = new HashMap<>();
    configs.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
    configs.put(ConsumerConfig.GROUP_ID_CONFIG, "counter-agg");
    configs.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
    configs.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
    return new DefaultKafkaConsumerFactory<>(configs);
}

2. 事件消费实现

聚合消费者

java 复制代码
// CounterAggregationConsumer.java
@Service
public class CounterAggregationConsumer {
    private final RedisTemplate<String, Object> redis;
    private final ObjectMapper objectMapper;

    @Autowired
    public CounterAggregationConsumer(RedisTemplate<String, Object> redis, ObjectMapper objectMapper) {
        this.redis = redis;
        this.objectMapper = objectMapper;
    }

    @KafkaListener(topics = CounterTopics.EVENTS, groupId = "counter-agg")
    public void onMessage(String message, Acknowledgment ack) throws Exception {
        // 反序列化事件
        CounterEvent evt = objectMapper.readValue(message, CounterEvent.class);
        // 构造聚合桶键
        String aggKey = CounterKeys.aggKey(evt.getEntityType(), evt.getEntityId());
        String field = String.valueOf(evt.getIdx());
        try {
            // 写入聚合桶
            redis.opsForHash().increment(aggKey, field, evt.getDelta());
            // 手动提交位点
            ack.acknowledge();
        } catch (Exception ex) {
            // 不提交位点,以便重试
        }
    }
}

关键技术点

  • 使用@KafkaListener注解监听主题
  • 反序列化消息为CounterEvent对象
  • 写入聚合桶(Redis Hash)
  • 手动提交消费位点,确保可靠消费

五、本地事件发布

除了发送到Kafka,计数模块还发布本地事件:

java 复制代码
// CounterServiceImpl.java:128
eventPublisher.publishEvent(CounterEvent.of(etype, eid, metric, idx, uid, delta));

本地事件的作用

  • 触发本地缓存失效
  • 通知其他组件更新状态
  • 实现系统内部的事件驱动

实现原理

  • 使用Spring的ApplicationEventPublisher
  • 事件在JVM内部传递,不需要网络开销
  • 同步执行,确保缓存及时更新

六、完整流程总结

状态改变
用户操作

点赞/取消点赞
CounterServiceImpl.toggle
执行Lua脚本

检查状态
CounterEventProducer.publish
序列化事件为JSON
KafkaTemplate.send

异步发送
返回结果给用户
Kafka服务器

存储消息
CounterAggregationConsumer.onMessage
反序列化事件
写入聚合桶

Redis Hash
定时刷写

更新SDS
发布本地事件

ApplicationEventPublisher
触发缓存失效

七、技术亮点

  1. 双事件机制

    • Kafka事件:异步聚合,解耦系统
    • 本地事件:同步缓存更新,保证实时性
  2. 可靠消费

    • 手动提交消费位点
    • 异常时不提交,支持重试
    • 保证事件不丢失
  3. 性能优化

    • 批量发送:Kafka客户端自动批量处理
    • 异步操作:不阻塞主流程
    • 聚合桶:减少Redis写入次数
  4. 容错设计

    • 异常捕获,确保主流程不被阻塞
    • 消息持久化,系统崩溃后可以恢复
    • 基于位图的事实重建,保证数据可靠

八、总结

事件发布到Kafka的实现:

  1. 配置Kafka模板:设置服务器地址、序列化方式
  2. 创建事件对象:包含操作的详细信息
  3. 序列化事件:将事件对象转换为JSON字符串
  4. 异步发送:使用KafkaTemplate.send()方法
  5. 消费处理:独立的消费者线程处理事件,更新聚合桶

异步操作的实现:

  1. Kafka的消息队列机制:消息先存储,后处理
  2. 生产者异步发送:立即返回,不等待处理结果
  3. 消费者独立处理:按自己的节奏消费消息
  4. 削峰填谷:高并发时保护系统

这种设计使得计数模块能够在高并发场景下,既快又准地处理用户的操作,同时保证系统的可靠性和可扩展性。

7. 事件消费与聚合

java 复制代码
// CounterAggregationConsumer.java:46-59
@KafkaListener(topics = CounterTopics.EVENTS, groupId = "counter-agg")
public void onMessage(String message, Acknowledgment ack) throws Exception {
    CounterEvent evt = objectMapper.readValue(message, CounterEvent.class);
    String aggKey = CounterKeys.aggKey(evt.getEntityType(), evt.getEntityId());
    String field = String.valueOf(evt.getIdx());
    try {
        // 将增量持久化到 Redis Hash
        redis.opsForHash().increment(aggKey, field, evt.getDelta());
        // 成功后提交位点
        ack.acknowledge();
    } catch (Exception ex) {
        // 不提交位点以便重试
    }
}

技术点

  • 消费Kafka事件
  • 写入聚合桶:agg:v1:knowpost:A
  • 手动提交位点,确保可靠消费

事件消费与聚合详解

一、什么是消费Kafka事件?

1. 生活中的类比

想象一个邮递系统:

  • 生产者:寄信的人(计数服务,发送事件)
  • 邮局:Kafka服务器(存储和转发信件)
  • 消费者:收信的人(聚合服务,处理事件)

消费Kafka事件就是从Kafka服务器中取出消息并处理的过程。

2. 技术实现

消费者代码

java 复制代码
// CounterAggregationConsumer.java:46-59
@KafkaListener(topics = CounterTopics.EVENTS, groupId = "counter-agg")
public void onMessage(String message, Acknowledgment ack) throws Exception {
    // 1. 反序列化事件
    CounterEvent evt = objectMapper.readValue(message, CounterEvent.class);
    // 2. 构造聚合桶键
    String aggKey = CounterKeys.aggKey(evt.getEntityType(), evt.getEntityId());
    String field = String.valueOf(evt.getIdx());
    try {
        // 3. 写入聚合桶
        redis.opsForHash().increment(aggKey, field, evt.getDelta());
        // 4. 手动提交位点
        ack.acknowledge();
    } catch (Exception ex) {
        // 不提交位点,以便重试
    }
}

关键点

  • @KafkaListener:监听Kafka主题
  • topics:监听的主题名称(counter-events
  • groupId:消费者组ID(counter-agg
  • message:收到的消息内容(JSON字符串)
  • ack:确认机制,用于提交消费位点

3. 消费过程详解

步骤1:反序列化事件

java 复制代码
CounterEvent evt = objectMapper.readValue(message, CounterEvent.class);
  • 将JSON字符串转换为CounterEvent对象
  • 提取事件信息:实体类型、实体ID、指标、增量等

步骤2:构造聚合桶键

java 复制代码
String aggKey = CounterKeys.aggKey(evt.getEntityType(), evt.getEntityId());
  • 例如:agg:v1:knowpost:A
  • 表示帖子A的聚合桶

步骤3:写入聚合桶

java 复制代码
redis.opsForHash().increment(aggKey, field, evt.getDelta());
  • 使用Redis的Hash数据结构
  • 增量更新对应字段的值

步骤4:提交消费位点

java 复制代码
ack.acknowledge();
  • 告诉Kafka,这条消息已经处理完成
  • 下次消费时,从这条消息之后开始

二、什么是聚合桶?

1. 生活中的类比

想象一个临时仓库:

  • 临时仓库:聚合桶
  • 货物:增量事件(+1或-1)
  • 定期清仓:定时刷写到SDS

聚合桶就是一个临时存储增量的地方,等积累到一定程度后,再统一处理。

2. 技术实现

聚合桶结构

复制代码
Redis Hash: agg:v1:knowpost:A
├── 1: 5    (点赞数增量:5次点赞)
├── 2: 3    (收藏数增量:3次收藏)
└── 3: 2    (其他指标增量:2次操作)

聚合桶键生成

java 复制代码
// CounterKeys.java
public static String aggKey(String etype, String eid) {
    return "agg:" + CounterSchema.SCHEMA_ID + ":" + etype + ":" + eid;
}
  • 格式:agg:{schemaId}:{entityType}:{entityId}
  • 例如:agg:v1:knowpost:A

3. 聚合桶的作用

为什么需要聚合桶?

假设有1000个用户同时点赞帖子A:

  • 没有聚合桶:每次点赞都更新SDS,1000次Redis写入
  • 有聚合桶:1000次点赞写入聚合桶,1次刷写到SDS

优势

  • 减少Redis写入次数:从1000次减少到1次
  • 降低Redis压力:提高系统性能
  • 批量处理:提高处理效率

三、怎么写入聚合桶?

1. 写入过程

单次写入

java 复制代码
redis.opsForHash().increment(aggKey, field, evt.getDelta());

参数说明

  • aggKey:聚合桶键,如agg:v1:knowpost:A
  • field:字段名,如"1"(点赞)
  • evt.getDelta():增量,如1(+1)或-1(-1)

实际效果

复制代码
初始状态:agg:v1:knowpost:A = {}

第1次点赞:
redis.opsForHash().increment("agg:v1:knowpost:A", "1", 1)
结果:agg:v1:knowpost:A = {"1": 1}

第2次点赞:
redis.opsForHash().increment("agg:v1:knowpost:A", "1", 1)
结果:agg:v1:knowpost:A = {"1": 2}

第3次取消点赞:
redis.opsForHash().increment("agg:v1:knowpost:A", "1", -1)
结果:agg:v1:knowpost:A = {"1": 1}

2. 写入代码详解

完整代码

java 复制代码
// CounterAggregationConsumer.java:46-59
@KafkaListener(topics = CounterTopics.EVENTS, groupId = "counter-agg")
public void onMessage(String message, Acknowledgment ack) throws Exception {
    // 反序列化事件
    CounterEvent evt = objectMapper.readValue(message, CounterEvent.class);
    // 构造聚合桶键
    String aggKey = CounterKeys.aggKey(evt.getEntityType(), evt.getEntityId());
    String field = String.valueOf(evt.getIdx());
    try {
        // 写入聚合桶:增量更新
        redis.opsForHash().increment(aggKey, field, evt.getDelta());
        // 手动提交位点
        ack.acknowledge();
    } catch (Exception ex) {
        // 不提交位点,以便重试
    }
}

执行流程

  1. 接收Kafka消息
  2. 反序列化为CounterEvent对象
  3. 构造聚合桶键
  4. 增量更新聚合桶
  5. 提交消费位点

四、定时刷写聚合桶

1. 刷写过程

定时任务

java 复制代码
// CounterAggregationConsumer.java:65-123
@Scheduled(fixedDelay = 1000L)
public void flush() {
    // 1. 扫描所有聚合桶键
    Set<String> keys = redis.keys("agg:" + CounterSchema.SCHEMA_ID + ":*");
    if (keys.isEmpty()) {
        return;
    }

    for (String aggKey : keys) {
        // 2. 读取聚合桶的所有字段
        Map<Object, Object> entries = redis.opsForHash().entries(aggKey);
        if (entries.isEmpty()) {
            continue;
        }
        // 3. 解析 etype/eid 以定位 SDS key
        String[] parts = aggKey.split(":", 4);
        if (parts.length < 4) {
            continue;
        }

        String cntKey = CounterKeys.sdsKey(parts[2], parts[3]);

        for (Map.Entry<Object, Object> e : entries.entrySet()) {
            String field = String.valueOf(e.getKey());
            long delta = Long.parseLong(String.valueOf(e.getValue()));
            if (delta == 0) continue;
            int idx = Integer.parseInt(field);

            try {
                // 4. 执行Lua脚本,将增量折叠到SDS
                redis.execute(incrScript, List.of(cntKey),
                        String.valueOf(CounterSchema.SCHEMA_LEN),
                        String.valueOf(CounterSchema.FIELD_SIZE),
                        String.valueOf(idx),
                        String.valueOf(delta));
                // 5. 成功后删除该字段,避免重复加算
                redis.opsForHash().delete(aggKey, field);
            } catch (Exception ex) {
                // 留存字段,下一轮重试
            }
        }
        // 6. 如 Hash 已为空,删除聚合桶Key
        Long size = redis.opsForHash().size(aggKey);
        if (size == 0L) {
            redis.delete(aggKey);
        }
    }
}

2. 刷写过程详解

步骤1:扫描聚合桶

java 复制代码
Set<String> keys = redis.keys("agg:" + CounterSchema.SCHEMA_ID + ":*");
  • 使用keys命令扫描所有聚合桶
  • 模式:agg:v1:*

步骤2:读取聚合桶内容

java 复制代码
Map<Object, Object> entries = redis.opsForHash().entries(aggKey);
  • 读取聚合桶的所有字段和值
  • 例如:{"1": 5, "2": 3}

步骤3:构造SDS键

java 复制代码
String cntKey = CounterKeys.sdsKey(parts[2], parts[3]);
  • 从聚合桶键中提取实体类型和实体ID
  • 构造SDS键:cnt:v1:knowpost:A

步骤4:刷写到SDS

java 复制代码
redis.execute(incrScript, List.of(cntKey),
        String.valueOf(CounterSchema.SCHEMA_LEN),
        String.valueOf(CounterSchema.FIELD_SIZE),
        String.valueOf(idx),
        String.valueOf(delta));
  • 执行Lua脚本,原子更新SDS
  • 增量更新对应字段的值

步骤5:删除聚合桶字段

java 复制代码
redis.opsForHash().delete(aggKey, field);
  • 删除已处理的字段
  • 避免重复计数

步骤6:删除空聚合桶

java 复制代码
Long size = redis.opsForHash().size(aggKey);
if (size == 0L) {
    redis.delete(aggKey);
}
  • 如果聚合桶为空,删除整个键
  • 释放内存

五、完整流程示例

场景:3个用户同时点赞帖子A

时间线

复制代码
T1: 用户1点赞 → 发送事件到Kafka
T2: 用户2点赞 → 发送事件到Kafka
T3: 用户3点赞 → 发送事件到Kafka
T4: 消费者消费事件1 → 写入聚合桶 agg:v1:knowpost:A {"1": 1}
T5: 消费者消费事件2 → 写入聚合桶 agg:v1:knowpost:A {"1": 2}
T6: 消费者消费事件3 → 写入聚合桶 agg:v1:knowpost:A {"1": 3}
T7: 定时任务触发 → 刷写聚合桶到SDS
T8: 执行Lua脚本 → SDS点赞数+3
T9: 删除聚合桶字段 → agg:v1:knowpost:A {}
T10: 删除聚合桶 → 删除 agg:v1:knowpost:A

流程图
用户点赞
发送事件到Kafka
消费者接收事件
写入聚合桶

Redis Hash
定时任务触发
扫描聚合桶
读取增量
执行Lua脚本

更新SDS
删除聚合桶字段
删除空聚合桶

六、技术亮点

1. 削峰填谷

  • 高并发时:Kafka缓存消息,聚合桶临时存储增量
  • 低并发时:定时任务处理,避免Redis压力过大

2. 批量处理

  • 减少Redis写入:从N次减少到1次
  • 提高性能:批量操作比单次操作快

3. 可靠性

  • 异常重试:刷写失败时,字段保留,下次重试
  • 位点提交:手动提交位点,确保消息不丢失

4. 内存优化

  • 及时清理:处理完成后删除聚合桶
  • 避免内存泄漏:定期清理空聚合桶

七、总结

消费Kafka事件

  • 从Kafka服务器取出消息
  • 反序列化为事件对象
  • 处理事件(写入聚合桶)
  • 提交消费位点

聚合桶

  • 临时存储增量的Redis Hash
  • 减少Redis写入次数
  • 支持批量处理

写入聚合桶

  • 使用HINCRBY命令增量更新
  • 累积增量,等待刷写
  • 刷写后删除字段

这种设计使得计数模块能够在高并发场景下,既快又准地处理用户的操作,同时保证系统的可靠性和性能。

8. 定时刷写

java 复制代码
// CounterAggregationConsumer.java:65-123
@Scheduled(fixedDelay = 1000L)
public void flush() {
    // 扫描所有聚合桶键
    Set<String> keys = redis.keys("agg:" + CounterSchema.SCHEMA_ID + ":*");
    if (keys.isEmpty()) {
        return;
    }

    for (String aggKey : keys) {
        Map<Object, Object> entries = redis.opsForHash().entries(aggKey);
        if (entries.isEmpty()) {
            continue;
        }
        // 解析 etype/eid 以定位 SDS key
        String[] parts = aggKey.split(":", 4);
        if (parts.length < 4) {
            continue;
        }

        String cntKey = CounterKeys.sdsKey(parts[2], parts[3]);

        for (Map.Entry<Object, Object> e : entries.entrySet()) {
            String field = String.valueOf(e.getKey());
            long delta = Long.parseLong(String.valueOf(e.getValue()));
            if (delta == 0) continue;
            int idx = Integer.parseInt(field);

            try {
                // 原子执行Lua脚本,将增量折叠到SDS
                redis.execute(incrScript, List.of(cntKey),
                        String.valueOf(CounterSchema.SCHEMA_LEN),
                        String.valueOf(CounterSchema.FIELD_SIZE),
                        String.valueOf(idx),
                        String.valueOf(delta));
                // 成功后删除该字段,避免重复加算
                redis.opsForHash().delete(aggKey, field);
            } catch (Exception ex) {
                // 留存字段,下一轮重试
            }
        }
        // 如 Hash 已为空,删除聚合桶Key
        Long size = redis.opsForHash().size(aggKey);
        if (size == 0L) {
            redis.delete(aggKey);
        }
    }
}

技术点

  • 每1秒执行一次
  • 扫描所有聚合桶
  • 执行Lua脚本更新SDS
  • 删除聚合桶字段,避免重复计数

9. SDS更新脚本

lua 复制代码
-- CounterAggregationConsumer.java:125-155
local cntKey = KEYS[1]
local schemaLen = tonumber(ARGV[1])
local fieldSize = tonumber(ARGV[2]) -- 固定为4
local idx = tonumber(ARGV[3])
local delta = tonumber(ARGV[4])

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

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)
return 1

技术点

  • 大端序读写32位整数
  • 自动初始化SDS(如果不存在)
  • 处理负数情况(置为0)
  • 原子更新,保证数据一致性

SDS与SDS更新脚本详解

好的,我来详细解释SDS是什么,以及SDS更新脚本的具体内容。

一、SDS是什么?

1. 基础概念

SDSSimple Dynamic String 的缩写,在这个项目中,它是一种固定结构的二进制数据格式,用于高效存储计数数据。

2. 结构设计

SDS的结构

复制代码
[ 版本号 ][ 点赞数 ][ 收藏数 ][ 评论数 ][ 浏览数 ]
  4字节    4字节    4字节    4字节    4字节

代码定义

java 复制代码
// CounterSchema.java
public static final String SCHEMA_ID = "v1";      // 版本号
public static final int FIELD_SIZE = 4;           // 每个字段的大小(4字节)
public static final int SCHEMA_LEN = 5;           // 字段数量
public static final int IDX_LIKE = 1;             // 点赞数的索引
public static final int IDX_FAV = 2;              // 收藏数的索引
public static final int IDX_COMMENT = 3;          // 评论数的索引
public static final int IDX_VIEW = 4;             // 浏览数的索引

3. 为什么使用SDS?

传统方法的问题

  • 使用Redis Hash:字段多了性能下降
  • 使用多个String:需要多次读取
  • 内存占用大:每个键值对都有额外开销

SDS的优势

  • 快速读取:直接按位置读取,O(1)时间
  • 内存节省:固定20字节,无额外开销
  • 批量友好:一次可以读取多个SDS
  • 结构清晰:固定格式,易于解析

4. 生活中的类比

想象一个固定格式的表格:

  • 每一行有5个格子,每个格子占4格
  • 第一格:版本号
  • 第二格:点赞数
  • 第三格:收藏数
  • 第四格:评论数
  • 第五格:浏览数

你可以直接找到对应格子,快速读取或修改数据。

二、SDS更新脚本详解

1. 脚本功能

SDS更新脚本 是一个Lua脚本,用于原子更新SDS中的某个字段值(比如点赞数),保证并发操作时的数据一致性。

2. 脚本代码

lua 复制代码
-- CounterAggregationConsumer.java:125-155
local cntKey = KEYS[1]
local schemaLen = tonumber(ARGV[1])
local fieldSize = tonumber(ARGV[2]) -- 固定为4
local idx = tonumber(ARGV[3])
local delta = tonumber(ARGV[4])

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

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)
return 1

3. 脚本详解

步骤1:参数解析
lua 复制代码
local cntKey = KEYS[1]          -- SDS键,如 cnt:v1:knowpost:A
local schemaLen = tonumber(ARGV[1])  -- 字段数量,5
local fieldSize = tonumber(ARGV[2])  -- 字段大小,4
local idx = tonumber(ARGV[3])        -- 字段索引,如 1(点赞)
local delta = tonumber(ARGV[4])      -- 增量,如 3(+3)
步骤2:定义辅助函数

read32be:读取32位大端序整数

lua 复制代码
local function read32be(s, off)
  local b = {string.byte(s, off+1, off+4)}  -- 读取4个字节
  local n = 0
  for i=1,4 do n = n * 256 + b[i] end  -- 转换为整数
  return n
end

write32be:写入32位大端序整数

lua 复制代码
local function write32be(n)
  local t = {}
  for i=4,1,-1 do t[i] = n % 256; n = math.floor(n/256) end  -- 分解为4个字节
  return string.char(unpack(t))  -- 转换为字符串
end
步骤3:读取SDS
lua 复制代码
local cnt = redis.call('GET', cntKey)
if not cnt then cnt = string.rep(string.char(0), schemaLen * fieldSize) end
  • 尝试读取SDS
  • 如果不存在,初始化一个全0的SDS(20字节)
步骤4:计算偏移量
lua 复制代码
local off = idx * fieldSize
  • 计算目标字段的偏移量
  • 例如:点赞数的索引是1,偏移量是 1 * 4 = 4
步骤5:更新值
lua 复制代码
local v = read32be(cnt, off) + delta
if v < 0 then v = 0 end
  • 读取当前值
  • 加上增量
  • 如果结果为负数,置为0(避免计数为负)
步骤6:写回SDS
lua 复制代码
local seg = write32be(v)
cnt = string.sub(cnt, 1, off) .. seg .. string.sub(cnt, off+fieldSize+1)
redis.call('SET', cntKey, cnt)
  • 将新值转换为二进制
  • 替换SDS中对应位置的字节
  • 写回Redis

4. 为什么需要这个脚本?

并发问题

  • 多个操作同时更新SDS,可能导致数据丢失
  • 例如:两个操作同时读取值为100,都+1,写回101,结果只+1,而不是+2

脚本解决的问题

  • 原子性:整个脚本作为一个原子操作执行
  • 一致性:确保增量正确累加
  • 效率:减少网络往返,一次操作完成

5. 脚本执行流程示例

场景:SDS中点赞数为100,需要+3

执行过程

  1. 读取SDS:cnt = "v1" + 100 + 50 + 20 + 1000(二进制)
  2. 计算偏移量:off = 1 * 4 = 4
  3. 读取当前值:v = 100
  4. 加上增量:v = 100 + 3 = 103
  5. 转换为二进制:seg = 二进制的103
  6. 替换对应位置:cnt = "v1" + 103 + 50 + 20 + 1000
  7. 写回Redis:SET cnt:v1:knowpost:A cnt

三、SDS与传统方法对比

操作 传统方法(Redis Hash) SDS方法 优势
存储空间 每个字段一个键值对,开销大 固定20字节,无额外开销 节省约80%空间
读取速度 HGET,需要解析 直接按位置读取,O(1) 更快
写入速度 HINCRBY,需要查找字段 直接替换字节,O(1) 更快
并发安全 可能冲突 原子操作,无冲突 更安全
批量操作 多次HGET 一次GET,解析多个字段 更高效

四、SDS的实际应用

1. 读取计数

java 复制代码
// CounterServiceImpl.java
@Override
public Map<String, Long> getCounts(String entityType, String entityId) {
    String key = CounterKeys.sdsKey(entityType, entityId);
    byte[] raw = redis.execute((RedisCallback<byte[]>) c -> c.stringCommands().get(key.getBytes(StandardCharsets.UTF_8)));
    if (raw == null || raw.length != CounterSchema.SCHEMA_LEN * CounterSchema.FIELD_SIZE) {
        // 重建计数
        return rebuildCounts(entityType, entityId);
    }
    // 解析SDS
    long like = readInt32BE(raw, CounterSchema.IDX_LIKE * CounterSchema.FIELD_SIZE);
    long fav = readInt32BE(raw, CounterSchema.IDX_FAV * CounterSchema.FIELD_SIZE);
    return Map.of("like", like, "fav", fav);
}

2. 批量读取

java 复制代码
// CounterServiceImpl.java
@Override
public Map<String, Map<String, Long>> getCountsBatch(String entityType, List<String> entityIds, List<String> metrics) {
    List<String> keys = new ArrayList<>();
    for (String eid : entityIds) {
        keys.add(CounterKeys.sdsKey(entityType, eid));
    }
    // 管道批量GET
    List<Object> results = redis.executePipelined((RedisCallback<Object>) connection -> {
        for (String key : keys) {
            connection.stringCommands().get(key.getBytes(StandardCharsets.UTF_8));
        }
        return null;
    });
    // 解析结果
    Map<String, Map<String, Long>> res = new HashMap<>();
    for (int i = 0; i < entityIds.size(); i++) {
        byte[] raw = (byte[]) results.get(i);
        Map<String, Long> counts = new HashMap<>();
        if (raw != null && raw.length == CounterSchema.SCHEMA_LEN * CounterSchema.FIELD_SIZE) {
            for (String metric : metrics) {
                int idx = CounterSchema.idxOf(metric);
                if (idx >= 0) {
                    long v = readInt32BE(raw, idx * CounterSchema.FIELD_SIZE);
                    counts.put(metric, v);
                }
            }
        }
        res.put(entityIds.get(i), counts);
    }
    return res;
}

五、总结

SDS是一种固定结构的二进制数据格式,用于高效存储计数数据,具有以下特点:

  • 固定结构:5个字段,每个4字节,共20字节
  • 快速读写:直接按位置操作,O(1)时间
  • 内存节省:无额外开销,比Hash节省80%空间
  • 批量友好:一次读取多个计数

SDS更新脚本是一个Lua脚本,用于原子更新SDS中的字段值,解决了并发冲突问题,保证了数据的一致性。

这种设计使得计数模块能够在高并发场景下,既快又准地处理用户的操作,同时保证系统的可靠性和性能。

二、技术亮点与传统方法对比

1. 位图分片

传统方法

  • 单个位图存储所有用户,体积大,性能差

我们的方法

  • 分片存储,每个分片32768位(4KB)
  • 提高并发性能,避免单键膨胀

实现

java 复制代码
// BitmapShard.java:11-16
public static long chunkOf(long userId) {
    return userId / CHUNK_SIZE;
}

public static long bitOf(long userId) {
    return userId % CHUNK_SIZE;
}

业务作用:支持百万级用户的点赞操作,无性能瓶颈。

2. Lua原子操作

传统方法

  • 先读再写,可能出现并发冲突

我们的方法

  • Redis执行Lua脚本,原子操作
  • 保证幂等性,避免重复计数

实现

  • 见上面的toggleScript和incrScript

业务作用:用户重复点赞不会重复计数,保证数据准确。

3. 事件驱动架构

传统方法

  • 同步更新计数,高并发时性能差

我们的方法

  • 异步事件处理,解耦写入和聚合
  • 提高系统吞吐量

实现

  • CounterEventProducer发布事件
  • CounterAggregationConsumer消费事件

业务作用:点赞操作瞬间完成,不阻塞用户,提升体验。

4. SDS固定结构

传统方法

  • Redis Hash存储,字段多了性能下降
  • 内存占用大

我们的方法

  • 固定20字节结构,大端32位编码
  • O(1)时间读取,内存占用小

实现

  • CounterSchema定义结构
  • readInt32BE/writeInt32BE方法

业务作用:快速读取计数,支持高并发查询。

5. 基于事实重建

传统方法

  • 计数丢失无法恢复

我们的方法

  • 位图作为事实层,支持重建
  • 保证数据可靠性

实现

  • bitCountShardsPipelined方法

业务作用:系统崩溃后能恢复计数,保证数据不丢失。

6. 限流与分布式锁

传统方法

  • 重建时可能导致系统压力过大

我们的方法

  • 限流保护,分布式锁避免并发重建
  • 保证系统稳定性

实现

  • inBackoff/allowedByRateLimiter方法
  • Redisson分布式锁

业务作用:保护系统在异常时不被压垮,提高可用性。

7. 批量操作优化

传统方法

  • 逐个查询,网络开销大

我们的方法

  • 管道批量GET,减少网络往返
  • 提高批量读取性能

实现

  • getCountsBatch方法

业务作用:Feed流加载速度快,提升用户体验。

三、核心本质

计数模块的核心设计思想是分层存储

  • 事实层(位图):记录所有用户行为,是最原始的数据源
  • 事件层(Kafka):传递增量事件,解耦写入和聚合
  • 汇总层(SDS):存储最终计数,提供快速读取

这种设计的优势:

  1. 可靠性:位图记录所有行为,丢了也能重建
  2. 性能:SDS快速读取,事件解耦写入
  3. 扩展性:支持百万级用户,易于添加新维度
  4. 可维护性:模块化设计,职责清晰

四、总结

计数模块通过巧妙的分层设计和技术选型,解决了高并发场景下的计数问题。它不仅满足了社交平台的计数需求,也为其他需要高并发计数的场景提供了参考。

关键技术点

  • 位图分片存储
  • Lua原子操作
  • 事件驱动架构
  • SDS固定结构
  • 基于事实重建
  • 限流与分布式锁
  • 批量操作优化

这些技术的组合,使得计数模块既快又准,能够应对百万级用户的高并发操作。

希望这个解释能帮助你理解计数模块的核心原理和实现方式。如果有任何疑问,随时告诉我!

相关推荐
fengxin_rou3 小时前
详解深浅拷贝:从原理到实现的完整指南
java·后端·浅拷贝·深拷贝
tsyjjOvO3 小时前
【SpringMVC 进阶】拦截器、文件上传、异常处理与 SSM 整合全解析
java·后端·spring
计算机学姐3 小时前
基于SpringBoot+Vue的智能民宿预定游玩系统【AI智能客服+数据可视化】
java·vue.js·spring boot·后端·mysql·spring·信息可视化
Mr-Wanter3 小时前
IDEA 借助 docker-compose.yml 一键打包镜像并推送到开发服务器(前端部署终极方案)
服务器·docker·docker-compose·intellij-idea
智_永无止境3 小时前
IntelliJ IDEA 配置与插件全部迁移到其他盘,彻底释放C盘空间
ide·intellij-idea
小江的记录本3 小时前
【泛型】泛型:泛型擦除、通配符、上下界限定
java·windows·spring boot·后端·spring·maven·mybatis
pupudawang3 小时前
springboot下使用druid-spring-boot-starter
java·spring boot·后端
lierenvip3 小时前
Spring Boot 自动配置
java·spring boot·后端