计数模块详解:点赞流程实现与技术分析
好的,我来慢慢给你解释计数模块。让我们从一个具体的业务场景开始:用户点赞帖子。
场景设定:用户小明点赞帖子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脚本在计数模块中解决了以下核心问题:
- 并发冲突:通过原子操作,避免多个操作同时修改数据
- 幂等性:通过检查之前的状态,避免重复计数
- 网络性能:通过减少网络往返,提高系统吞吐量
- 数据一致性:通过锁定机制,保证数据的准确性
这些问题的解决,使得计数模块能够在高并发场景下,既快又准地处理用户的点赞、收藏等操作。
核心本质: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中
- 异步处理:生产者发送消息后立即返回
- 消费者:独立的进程或线程,异步消费消息
实现原理:
- 生产者(计数服务):
- 调用
kafka.send()方法 - Kafka客户端将消息放入本地缓冲区
- 后台线程定期批量发送到Kafka服务器
- 立即返回
ListenableFuture对象
- 调用
- 消费者(聚合服务):
- 独立的线程,持续从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. 异步操作的优势
-
提高响应速度:
- 主流程(用户点赞)立即完成
- 不需要等待计数更新完成
- 提升用户体验
-
削峰填谷:
- 高并发时,Kafka可以缓存消息
- 消费者按自己的节奏处理
- 保护系统不被瞬时流量压垮
-
解耦系统:
- 点赞操作与计数更新解耦
- 两个系统可以独立部署、扩展
- 提高系统的可靠性和可维护性
四、事件消费流程
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
触发缓存失效
七、技术亮点
-
双事件机制:
- Kafka事件:异步聚合,解耦系统
- 本地事件:同步缓存更新,保证实时性
-
可靠消费:
- 手动提交消费位点
- 异常时不提交,支持重试
- 保证事件不丢失
-
性能优化:
- 批量发送:Kafka客户端自动批量处理
- 异步操作:不阻塞主流程
- 聚合桶:减少Redis写入次数
-
容错设计:
- 异常捕获,确保主流程不被阻塞
- 消息持久化,系统崩溃后可以恢复
- 基于位图的事实重建,保证数据可靠
八、总结
事件发布到Kafka的实现:
- 配置Kafka模板:设置服务器地址、序列化方式
- 创建事件对象:包含操作的详细信息
- 序列化事件:将事件对象转换为JSON字符串
- 异步发送:使用KafkaTemplate.send()方法
- 消费处理:独立的消费者线程处理事件,更新聚合桶
异步操作的实现:
- Kafka的消息队列机制:消息先存储,后处理
- 生产者异步发送:立即返回,不等待处理结果
- 消费者独立处理:按自己的节奏消费消息
- 削峰填谷:高并发时保护系统
这种设计使得计数模块能够在高并发场景下,既快又准地处理用户的操作,同时保证系统的可靠性和可扩展性。
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:Afield:字段名,如"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) {
// 不提交位点,以便重试
}
}
执行流程:
- 接收Kafka消息
- 反序列化为
CounterEvent对象 - 构造聚合桶键
- 增量更新聚合桶
- 提交消费位点
四、定时刷写聚合桶
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. 基础概念
SDS 是 Simple 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
执行过程:
- 读取SDS:
cnt = "v1" + 100 + 50 + 20 + 1000(二进制) - 计算偏移量:
off = 1 * 4 = 4 - 读取当前值:
v = 100 - 加上增量:
v = 100 + 3 = 103 - 转换为二进制:
seg = 二进制的103 - 替换对应位置:
cnt = "v1" + 103 + 50 + 20 + 1000 - 写回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):存储最终计数,提供快速读取
这种设计的优势:
- 可靠性:位图记录所有行为,丢了也能重建
- 性能:SDS快速读取,事件解耦写入
- 扩展性:支持百万级用户,易于添加新维度
- 可维护性:模块化设计,职责清晰
四、总结
计数模块通过巧妙的分层设计和技术选型,解决了高并发场景下的计数问题。它不仅满足了社交平台的计数需求,也为其他需要高并发计数的场景提供了参考。
关键技术点:
- 位图分片存储
- Lua原子操作
- 事件驱动架构
- SDS固定结构
- 基于事实重建
- 限流与分布式锁
- 批量操作优化
这些技术的组合,使得计数模块既快又准,能够应对百万级用户的高并发操作。
希望这个解释能帮助你理解计数模块的核心原理和实现方式。如果有任何疑问,随时告诉我!