在处理高并发的点赞、关注等社交行为时,使用 Redis 作为缓存系统能够显著提升性能,减少对数据库的访问压力。然而,面对大规模数据时,如何高效存储、分片处理和确保数据一致性成为了设计中的关键问题。本方案通过 Redis 哈希表分片存储 、布隆过滤器防止重复操作 和 定时任务同步数据库 等方式,构建了一个高效且可扩展的点赞与计数系统。
系统设计与核心组件:
-
Redis Hash计数与分片存储:
-
使用 Redis 哈希表 (Hash)存储点赞数、关注数等数据。每个哈希表存储一个时间维度的数据(例如按小时分片,
yyyyMMddHH格式),避免了过大的单个 Redis 键。 -
时间分片:数据按小时(或者其他时间维度)分片存储,减少单个哈希表存储的数据量,避免性能瓶颈。
-
-
RedisCursor 批量遍历与数据管理:
-
批量操作 :通过 Redis 游标(Cursor)实现批量扫描和操作,从 Redis 中批量获取或删除数据。每次扫描数据的批次大小可控,有效避免大规模数据加载引发的内存溢出和性能问题。
-
删除过期数据:定期清理历史无用数据,确保 Redis 中的数据保持最新且精简。
-
-
布隆过滤器防止重复点赞:
-
使用 Redis 的 布隆过滤器(Bloom Filter)高效检测用户是否已经对某个内容进行过点赞。
-
布隆过滤器原理 :通过多个哈希函数将用户的
userId映射到位图中,查询时检查该位是否为 1,如果是,表示该用户可能已经点赞过,否则表示没有点赞。
-
-
定时任务同步 Redis 到数据库:
-
通过定时任务,定期将 Redis 中的点赞数、关注数等计数同步到数据库中,确保数据的最终一致性。
-
任务执行:每小时或者指定时间范围内,扫描 Redis 中的计数数据,更新到数据库中,并删除已同步的数据。
-
1、布隆过滤器业务枚举类
java
/**
* 布隆过滤器类型
*
* @author Lucas
* date 2026/3/21 18:11
*/
@Getter
@AllArgsConstructor
public enum BloomFilterType {
/**
* 帖子点赞
*/
UpBlog(100000000, 0.01, 16),
/**
* 帖子收藏
*/
CollectBlog(80000000, 0.01, 4),
/**
* 新闻点赞
*/
UpNews(100000000, 0.01, 16),
/**
* 新闻收藏
*/
CollectNews(80000000, 0.01, 4),
/**
* 消息点赞
*/
UpMessage(100000000, 0.01, 64);
/**
* 每个分片预计插入数量(数量超了只会影响误判率)
* 总容量 = perShardInsertions * shardsNumber
*/
private final long perShardInsertions;
/**
* 误判率(0~1)
*/
private final double falseProbability;
/**
* 分片数量
*/
private final int shardsNumber;
}
2、布隆过滤器工具类
java
/**
* Redis 布隆过滤器工具类
*
* @author Lucas
* date 2026/3/21 17:53
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisBloomFilterUtil {
private final RedissonClient redissonClient;
private final StringRedisTemplate stringRedisTemplate;
/**
* 为所有枚举类型及其分片初始化布隆过滤器
*/
public void init() {
for (BloomFilterType type : BloomFilterType.values()) {
String typeName = type.name();
int shards = type.getShardsNumber();
log.info("开始初始化布隆过滤器,类型={}, 分片数={}", typeName, shards);
for (int shard = 0; shard < shards; shard++) {
String filterName = getFilterName(type, shard);
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter(filterName);
try {
// 尝试初始化,如果已存在且参数一致则返回 false
boolean initialized = bloomFilter.tryInit(type.getPerShardInsertions(), type.getFalseProbability());
if (initialized) {
log.info("布隆过滤器初始化成功,key={}, 预计插入数={}, 误判率={}", filterName, type.getPerShardInsertions(), type.getFalseProbability());
} else {
log.error("布隆过滤器已存在且配置一致,跳过初始化,key={}", filterName);
}
} catch (Exception e) {
log.error("布隆过滤器初始化失败,key={}, 类型={}, 分片={}, 可能原因:Redis中已存在但参数不一致,请检查配置", filterName, typeName, shard, e);
}
}
}
}
/**
* 构建布隆过滤器的 Redis Key
*
* @param type 业务类型
* @param shardIndex 分片索引(0 ~ shardsNumber-1)
* @return Redis key
*/
private String getFilterName(BloomFilterType type, int shardIndex) {
return RedisKey.USER_BLOOM_FILTER + type.name().toLowerCase() + ":" + shardIndex;
}
/**
* 根据业务标识计算分片索引
*
* @param type 业务类型
* @param value 业务唯一标识(如 userId:operateId)
* @return 分片索引
*/
private int getShardIndex(BloomFilterType type, String value) {
int hash = HashUtil.murmur32(value.getBytes(StandardCharsets.UTF_8));
return Math.floorMod(hash, type.getShardsNumber());
}
/**
* 获取对应 value 所属的布隆过滤器实例
*
* @param type 业务类型
* @param value 业务唯一标识
* @return RBloomFilter 实例
*/
private RBloomFilter<String> getBloomFilter(BloomFilterType type, String value) {
int shardIndex = getShardIndex(type, value);
return redissonClient.getBloomFilter(getFilterName(type, shardIndex));
}
/**
* 构建存储在布隆过滤器中的唯一标识
*
* @param userId 用户ID
* @param operateId 操作对象ID
* @return 唯一标识字符串
*/
private String buildValue(String userId, String operateId) {
return userId + ":" + operateId;
}
/**
* 添加用户操作记录(点赞、收藏等)
*
* @param type 业务类型
* @param userId 用户ID
* @param operateId 操作对象ID(如帖子ID、新闻ID等)
* @return true 表示添加成功,false 表示添加失败(如 Redis 异常)
*/
public boolean addAction(BloomFilterType type, String userId, String operateId) {
String value = buildValue(userId, operateId);
try {
RBloomFilter<String> bloomFilter = getBloomFilter(type, value);
boolean add = bloomFilter.add(value);
if (add) {
stringRedisTemplate.opsForValue().set(getCacheKey(type, userId, operateId), String.valueOf(true), 10, TimeUnit.MINUTES);
}
return add;
} catch (Exception e) {
log.error("布隆过滤器添加元素失败, type={}, userId={}, operateId={}", type, userId, operateId, e);
return false;
}
}
/**
* 检查用户是否已执行过操作(点赞、收藏等)
*
* @param type 业务类型
* @param userId 用户ID
* @param operateId 操作对象ID
* @return true 表示可能存在,false 表示一定不存在
*/
public boolean existsAction(BloomFilterType type, String userId, String operateId) {
String value = buildValue(userId, operateId);
try {
RBloomFilter<String> bloomFilter = getBloomFilter(type, value);
return bloomFilter.contains(value);
} catch (Exception e) {
log.error("布隆过滤器查询元素失败,type={}, userId={}, operateId={}", type, userId, operateId, e);
// 发生异常时,保守返回 true 认为可能操作,再进行兜底查询确定
return true;
}
}
/**
* 拼接用户操作KEY
*
* @param type 业务类型
* @param userId 用户ID
* @param operateId 操作对象ID
* @return 用户操作KEY
*/
private String getCacheKey(BloomFilterType type, String userId, String operateId) {
return RedisKey.USER_OPERATE_CACHE + type.name().toLowerCase() + ":" + userId + "-" + operateId;
}
/**
* 取消用户操作时调用
*
* @param type 业务类型
* @param userId 用户ID
* @param operateId 操作对象ID
* @return true 操作成功 false 操作失败
*/
private boolean removeUserCacheKey(BloomFilterType type, String userId, String operateId) {
return stringRedisTemplate.unlink(getCacheKey(type, userId, operateId));
}
/**
* 检查用户是否已执行过某个操作(点赞、收藏等)
* 使用布隆过滤器快速过滤,再查缓存/数据库,避免频繁穿透
*
* @param type 业务类型
* @param userId 用户ID
* @param operateId 操作对象ID
* @param dbQuery 数据库查询回调,返回 true 表示已操作,false 表示未操作
* @return true 表示已操作,false 表示未操作
*/
public boolean isUserOperated(BloomFilterType type, String userId, String operateId, Supplier<Boolean> dbQuery) {
// 1. 布隆过滤器快速判断
boolean bloomResult = existsAction(type, userId, operateId);
if (!bloomResult) {
// 布隆过滤器确定不存在 → 一定未操作
return false;
}
// 2. 布隆过滤器认为可能存在,查询Redis缓存
String cacheKey = getCacheKey(type, userId, operateId);
Boolean cachedExists = stringRedisTemplate.hasKey(cacheKey);
if (Boolean.TRUE.equals(cachedExists)) {
// 缓存命中,表示已操作
return true;
}
// 3. 缓存未命中,最终查数据库(兜底操作)
boolean dbResult = dbQuery.get();
if (dbResult) {
// 数据库确认已操作,写入缓存(有效期 10 分钟,可根据业务调整)
stringRedisTemplate.opsForValue().set(cacheKey, String.valueOf(true), 10, TimeUnit.MINUTES);
}
return dbResult;
}
}
3、Redis Cursor 批量遍历迭代器工具
java
/**
* Redis Cursor 批量遍历迭代器工具
*
* @author Lucas
* date 2026/3/14 11:47
*/
@Slf4j
public final class RedisBatchIterable implements Iterable<List<Map.Entry<String, Long>>>, AutoCloseable {
private final Cursor<Map.Entry<String, Long>> cursor;
private final int batchSize;
private boolean closed = false;
private boolean iterated = false;
public RedisBatchIterable(Cursor<Map.Entry<String, Long>> cursor, int batchSize) {
if (batchSize <= 0) {
throw new IllegalArgumentException("batchSize must be > 0");
}
this.cursor = Objects.requireNonNull(cursor, "cursor");
this.batchSize = batchSize;
}
@NotNull
@Override
public Iterator<List<Map.Entry<String, Long>>> iterator() {
ensureOpen();
if (iterated) {
throw new IllegalStateException("This iterable can only be used once");
}
iterated = true;
return new AbstractBatchIterator<>(cursor, batchSize, Function.identity(), this::close);
}
private void ensureOpen() {
if (closed) {
throw new IllegalStateException("Already closed");
}
}
@Override
public void close() {
if (closed) {
return;
}
closed = true;
try {
cursor.close();
} catch (Exception e) {
log.warn("Failed to close Redis cursor", e);
}
}
static final class AbstractBatchIterator<T> implements Iterator<T> {
private final Cursor<Map.Entry<String, Long>> cursor;
private final int batchSize;
private final Function<List<Map.Entry<String, Long>>, T> converter;
private final Runnable onClose;
private final List<Map.Entry<String, Long>> buffer;
private boolean finished = false;
private boolean prefetched = false;
AbstractBatchIterator(
Cursor<Map.Entry<String, Long>> cursor,
int batchSize,
Function<List<Map.Entry<String, Long>>, T> converter,
Runnable onClose
) {
this.cursor = cursor;
this.batchSize = batchSize;
this.converter = converter;
this.onClose = onClose;
this.buffer = new ArrayList<>(batchSize);
}
private void fetch() {
if (finished) {
return;
}
buffer.clear();
int count = 0;
try {
while (cursor.hasNext() && count < batchSize) {
buffer.add(cursor.next());
count++;
}
if (buffer.isEmpty()) {
finished = true;
onClose.run();
}
} catch (Exception e) {
finished = true;
onClose.run();
throw new RuntimeException("Redis scan failed", e);
}
}
@Override
public boolean hasNext() {
if (!prefetched && !finished) {
fetch();
prefetched = true;
}
return !buffer.isEmpty();
}
@Override
public T next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
prefetched = false;
List<Map.Entry<String, Long>> snapshot = List.copyOf(buffer);
buffer.clear();
return converter.apply(snapshot);
}
}
}
4、Redis Hash计数工具类
java
/**
* Redis Hash计数工具类(维度小时分片,数据量大KEY可根据分片参数获取Hash值再次细化分片)
*
* @author Lucas
* date 2026/3/13 17:02
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisHashIncrementUtil {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHH");
private final StringRedisTemplate redisTemplate;
/// 获取当前往前推多少个小时
static String getCurrentSlot(Integer hourOffset) {
// 获取当前时间并减去 hourOffset 小时
LocalDateTime date = DateTimeUtil.getDateTime(Instant.now()).minusHours(hourOffset);
return date.format(FORMATTER);
}
/**
* +1
*/
public void incrFollowCount(String key, BigInteger tribuneId) {
redisTemplate.opsForHash().increment(key + getCurrentSlot(0), tribuneId.toString(), 1);
}
/**
* -1
*/
public void decrFollowCount(String key, BigInteger tribuneId) {
redisTemplate.opsForHash().increment(key + getCurrentSlot(0), tribuneId.toString(), -1);
}
/**
* 删除使用过的key
*/
public void deleteFollowCountKey(String key, Integer hourOffset) {
redisTemplate.unlink(key + getCurrentSlot(hourOffset));
}
/**
* 分批获取 上一个时间段 Hash 的值,每批 batchSize 条(建议batchSize数量不超过1000)
*/
public RedisBatchIterable fetchInBatches(String key, int batchSize, Integer hourOffset) {
String hashKey = key + getCurrentSlot(hourOffset);
HashOperations<String, String, Long> hashOps = redisTemplate.opsForHash();
Cursor<Map.Entry<String, Long>> cursor = hashOps.scan(hashKey, ScanOptions.scanOptions().count(batchSize).build());
return new RedisBatchIterable(cursor, batchSize);
}
/**
* 删除指定HashKey
*/
public void deleteInBatches(String key, List<String> hashKeys, Integer hourOffset) {
String hashKey = key + getCurrentSlot(hourOffset);
HashOperations<String, String, Long> hashOps = redisTemplate.opsForHash();
CollUtil.split(hashKeys, 200).forEach(batch -> {
try {
hashOps.delete(hashKey, batch);
} catch (Exception e) {
log.error("删除 Redis hash 批次失败: {}", batch, e);
}
});
}
/**
* 批量获取当前时间段对应的值(建议tribuneIds数量不超过500)
*/
public Map<BigInteger, Integer> getFollowCounts(String key, List<BigInteger> tribuneIds) {
HashOperations<String, String, String> hashOps = redisTemplate.opsForHash();
String hashKey = key + getCurrentSlot(0);
List<String> fields = tribuneIds.stream().map(BigInteger::toString).toList();
List<String> values = hashOps.multiGet(hashKey, fields);
return IntStream.range(0, tribuneIds.size()).boxed().collect(Collectors.toMap(tribuneIds::get, i -> {
String v = values.get(i);
return v == null ? 0 : Integer.parseInt(v);
}, (a, b) -> b, LinkedHashMap::new));
}
}
5、定时任务通过点赞数据
java
public Response<String> oneHourTimedAttentionTask(String key, Boolean isAttention) {
String typeText = isAttention ? "关注" : "帖子";
// 循环处理前 N 小时的数据(补偿 + 前一小时)
// 例如检查前 2 小时
int maxHoursToCheck = 2;
for (int hourOffset = maxHoursToCheck; hourOffset >= 1; hourOffset--) {
try (RedisBatchIterable batches = redisHashIncrementUtil.fetchInBatches(key, 400, hourOffset)) {
for (List<Map.Entry<String, Long>> entries : batches) {
try {
tribuneManager.batchUpdateFollowCount(entries, isAttention);
redisHashIncrementUtil.deleteInBatches(key, entries.stream().map(Map.Entry::getKey).toList(), hourOffset);
// 轻微间隔,防止瞬间冲击 Redis
Thread.sleep(ThreadLocalRandom.current().nextInt(5, 20));
} catch (Exception e) {
log.error("定时任务同步帖子{}数量异常", typeText, e);
}
}
} catch (Exception e) {
log.error("定时任务同步帖子{}数量,批量迭代器执行异常", typeText, e);
}
// 处理完成后删除 Redis key
redisHashIncrementUtil.deleteFollowCountKey(key, hourOffset);
log.info("定时检查同步帖子{}数量统计成功,执行完成时间:{},执行前几个小时的:{}", typeText, DateUtil.now(), hourOffset);
}
return responseFactory.success();
}