redis | 三种交互数据缓存设计方案 | 对比文档
redis | 三种交互数据缓存设计方案 | 对比文档
本方案基于 Redis 缓存实现交互数据的高性能读写,提供三种方案:Set + MQ(通用)、Set + 定时扫描(无 MQ 依赖)、Bitmap + MQ(极致内存)。评审重点:三种方案的适用边界、Bitmap 在分布式场景下的扩展性、雪花 ID 带来的内存风险、兜底降级策略的完整性。
一、问题陈述
当前系统面临交互数据热点问题:单篇内容 1 分钟内涌入 10 万点赞时,数据库连接池被打满、行锁冲突、主从延迟达 800ms 以上。现有纯数据库方案的点赞操作平均响应时间为 480ms,P99 达 2.1s,无法支撑日均千万级交互操作。
目标:将交互操作响应时间降至 10ms 以内,QPS 提升至万级,支持 1 亿用户规模。
二、数据结构选型
| 数据结构 | 存储方式 | 内存占用(1亿用户) | 典型操作 | 适用场景 |
|---|---|---|---|---|
| String | 简单键值对 | 高(每个 key 独立) | SET/GET/INCR | 单个计数、缓存 |
| Hash | 字段-值映射 | 中(共享 key) | HSET/HGET/HINCRBY | 对象属性、聚合计数 |
| Set | 无序唯一集合 | ~2GB | SADD/SREM/SISMEMBER | 关系存储、去重 |
| Bitmap | 位数组 | ~12MB | SETBIT/GETBIT/BITPOS | 状态标记(是/否) |
选型结论:
- Set:通用方案,存储用户级状态,无 ID 类型限制
- Bitmap:极致内存方案,存储用户级状态,需连续整数 ID
- Hash:所有方案共用,存储内容级计数(一个 key 聚合多维度)
三、方案设计与交互流程
本方案提供三种设计,按状态存储的数据结构区分,入库策略随之不同:
| 维度 | 方案一:Set + MQ | 方案二:Set + 定时扫描 | 方案三:Bitmap + MQ |
|---|---|---|---|
| 状态存储 | Set(无 ID 限制) | Set(无 ID 限制) | Bitmap(需连续整数 ID) |
| 计数存储 | Hash | Hash | Hash |
| 脏标记 | 无(MQ 替代) | Dirty Set | 无(MQ 替代) |
| 入库方式 | MQ 消息异步写库 | 定时任务扫描脏集合写库 | MQ 消息异步写库 |
| 内存占用(1亿用户) | ~2GB | ~2GB | ~12MB |
| 数据延迟 | 秒级 | 分钟级 | 秒级 |
| 适用场景 | 通用方案,支持任意 ID 类型 | 无 MQ 依赖,需批量合并 | 自增 ID,追求极致内存 |
Set vs Bitmap 适用场景:
| 用户量 | ID 类型 | ID 最大值 | Set 内存 | Bitmap 内存 | 推荐方案 |
|---|---|---|---|---|---|
| < 1000 万 | 自增 ID | < 1000 万 | ~200MB | ~1.2MB | Bitmap |
| 1000 万~1 亿 | 自增 ID | < 1 亿 | ~2GB | ~12MB | Bitmap |
| > 1 亿 | 自增 ID | < 42 亿 | > 2GB | ~500MB | 均可,Bitmap 仍有优势 |
| 任意 | 雪花 ID | ~2^40 | ~2GB | ~125GB | Set |
| 任意 | UUID | — | ~2GB | 不可用 | Set |
| 100 万 | 自增 ID(稀疏) | 10 亿 | ~20MB | ~120MB | Set |
判断标准:ID 最大值 / 实际用户数 > 100 时,Bitmap 内存优势消失,应选 Set。
3.1 共享 Key 概念
三种方案共享三个 Key 概念,不同方案按需组合:
| Key 概念 | 职责 | 对应问题 | 数据结构选择 |
|---|---|---|---|
| 状态 Key | 记录「谁做了」 | 用户是否已操作? | Set(方案一/二)或 Bitmap(方案三) |
| 计数 Key | 记录「做了多少」 | 当前有多少人操作? | Hash(所有方案) |
| 脏标记 Key | 记录「谁变了」 | 哪些数据需要同步到 DB? | Dirty Set(仅方案二) |
Key 命名规范:
状态 Key: {interactionType}:{storageType}:{targetType}:{targetId} 方案一/二: like:set:1:10086 (Set 存储) 方案三: like:bit:1:10086 (Bitmap 存储)
计数 Key: stat:{targetType}:{targetId} 示例: stat:1:10086 字段: like:1 = 128 (表示该笔记有 128 个点赞)
脏标记 Key: dirty:{interactionType}:{targetType} 示例: dirty:like:1 含义: 笔记类型下所有发生点赞变更的 targetId 集合组合规则:
- MQ 入库方案(一、三):消息本身携带变更信息,无需脏标记 Key
- 定时扫描入库方案(二):需要脏标记 Key 追踪变更,替代
KEYS *全库扫描
3.2 方案一:Set + MQ 异步入库
Key 设计
| Key 类型 | 命名格式 | 示例 | 职责 |
|---|---|---|---|
| Set | {type}:set:{targetType}:{targetId} | like:set:1:10086 | 存储已操作的用户 ID 集合 |
| Hash | stat:{targetType}:{targetId} | stat:1:10086 | 聚合计数 |
MQ 消息本身携带变更信息,无需脏标记 Key。
交互流程
┌─────────────────────────────────────────────────────────┐│ 用户交互请求 │└──────────────────────────┬──────────────────────────────┘ │ ▼ ┌─────────────────────┐ │ 1. 参数校验 │ └──────────┬──────────┘ ▼ ┌─────────────────────┐ │ 2. SISMEMBER │ │ like:set:1:10086 │ │ userId │ └──────────┬──────────┘ │ ┌─────┴─────┐ │ 已存在? │ └─────┬─────┘ ┌──────┴──────┐ ▼ ▼ 是 否 │ │ ▼ ▼ ┌──────────┐ ┌──────────────────────────┐ │ 返回 │ │ 3. SADD │ │ false │ │ like:set:1:10086 42 │ │ 已点赞 │ └────────────┬─────────────┘ └──────────┘ │ ▼ ┌──────────────────────────┐ │ 4. HINCRBY │ │ stat:1:10086 like:1 1 │ └────────────┬─────────────┘ │ ▼ ┌──────────────────────────┐ │ 5. MQ 发送消息 │ │ {userId, targetId, │ │ type, action} │ └────────────┬─────────────┘ │ ▼ ┌─────────────┐ │ 返回 true │ │ 点赞成功 │ └─────────────┘执行步骤:
- 参数校验:校验
type/targetId/userId,非法参数直接返回 false - SISMEMBER 判断是否已操作:
SISMEMBER like:set:1:10086 42- 返回 1:已点赞,直接返回 false
- 返回 0:未点赞,继续下一步
- SADD 添加用户 ID:
SADD like:set:1:10086 42 - HINCRBY 计数+1:
HINCRBY stat:1:10086 like:1 1 - MQ 发送消息:与 Redis 写入并行执行
取消点赞:
SREM like:set:1:10086 42HINCRBY stat:1:10086 like:1 -1# MQ 发送 action=unlike 消息入库方式:MQ 异步消费
┌─────────────────────────────────────────────────────────┐│ 用户交互请求 │└──────────────────────────┬──────────────────────────────┘ │ ┌────────────┴────────────┐ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ Redis 写入 │ │ MQ 发送 │ │ SADD │ │ Topic: │ │ HINCRBY │ │ interaction │ └───────┬───────┘ └───────┬───────┘ │ │ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ 返回用户 │ │ MQ Consumer │ │ 操作结果 │ │ 消费消息 │ └───────────────┘ └───────┬───────┘ │ ┌──────┴──────┐ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ 写入用户记录 │ │ 更新统计记录 │ │ INSERT/UPDATE │ │ user_id=0 │ └───────────────┘ └───────────────┘MQ 消息格式:
{ "userId": 42, "targetType": 1, "targetId": 10086, "interactionType": 1, "action": "like", "timestamp": 1715673600000}为什么不需要脏标记 Key:MQ 消息本身就是变更的载体,每条消息包含完整的操作信息(谁、对什么、做了什么),消费者直接根据消息内容写库,无需反查 Redis 来确定”哪些数据变了”。
消费者幂等保障:INSERT ... ON DUPLICATE KEY UPDATE(依赖唯一索引)。
3.3 方案二:Set + 定时扫描入库
Key 设计
| Key 类型 | 命名格式 | 示例 | 职责 |
|---|---|---|---|
| Set | {type}:set:{targetType}:{targetId} | like:set:1:10086 | 存储已操作的用户 ID 集合 |
| Hash | stat:{targetType}:{targetId} | stat:1:10086 | 聚合计数 |
| Dirty Set | dirty:{type}:{targetType} | dirty:like:1 | 标记哪些内容有待同步的变更 |
定时扫描方案需要知道”哪些数据发生了变更”,引入 Dirty Set 替代 KEYS * 全库扫描。
交互流程
┌─────────────────────────────────────────────────────────┐│ 用户交互请求 │└──────────────────────────┬──────────────────────────────┘ │ ▼ ┌─────────────────────┐ │ 1. 参数校验 │ └──────────┬──────────┘ ▼ ┌─────────────────────┐ │ 2. SISMEMBER │ │ like:set:1:10086 │ │ userId │ └──────────┬──────────┘ │ ┌─────┴─────┐ │ 已存在? │ └─────┬─────┘ ┌──────┴──────┐ ▼ ▼ 是 否 │ │ ▼ ▼ ┌──────────┐ ┌──────────────────────────┐ │ 返回 │ │ 3. SADD │ │ false │ │ like:set:1:10086 42 │ │ 已点赞 │ └────────────┬─────────────┘ └──────────┘ │ ▼ ┌──────────────────────────┐ │ 4. HINCRBY │ │ stat:1:10086 like:1 1 │ └────────────┬─────────────┘ │ ▼ ┌──────────────────────────┐ │ 5. SADD │ │ dirty:like:1 10086 │ └────────────┬─────────────┘ │ ▼ ┌─────────────┐ │ 返回 true │ │ 点赞成功 │ └─────────────┘执行步骤:
- 参数校验:校验
type/targetId/userId,非法参数直接返回 false - SISMEMBER 判断是否已操作:
SISMEMBER like:set:1:10086 42- 返回 1:已点赞,直接返回 false
- 返回 0:未点赞,继续下一步
- SADD 添加用户 ID:
SADD like:set:1:10086 42 - HINCRBY 计数+1:
HINCRBY stat:1:10086 like:1 1 - SADD 标记脏数据:
SADD dirty:like:1 10086
取消点赞:
SREM like:set:1:10086 42HINCRBY stat:1:10086 like:1 -1SADD dirty:like:1 10086入库方式:定时扫描
┌──────────────────────────────────────────────────────┐│ 定时任务触发(每 5 分钟) │└──────────────────────────┬───────────────────────────┘ │ ▼ ┌───────────────────────┐ │ Step 1: SMEMBERS │ │ dirty:like:1 │ │ → [10086, 20001, ...] │ └───────────┬───────────┘ │ ▼ ┌───────────────────────┐ │ Step 2: 遍历每个 │ │ targetId │ └───────────┬───────────┘ │ ┌──────────────┴──────────────┐ ▼ ▼ ┌───────────────────┐ ┌───────────────────┐ │ Step 3a │ │ Step 3b │ │ SMEMBERS │ │ HGETALL │ │ like:set:1:10086 │ │ stat:1:10086 │ │ 获取用户ID集合 │ │ 获取所有计数字段 │ │ 写入用户记录 │ │ 写入统计记录 │ │ INSERT/UPDATE │ │ user_id=0 │ └─────────┬─────────┘ └─────────┬─────────┘ │ │ ▼ ▼ ┌───────────────────┐ ┌───────────────────┐ │ DEL Set Key │ │ 保留 Hash Key │ │ (已同步,可删除) │ │ (继续累加新操作) │ └─────────┬─────────┘ └─────────┬─────────┘ │ │ └──────────────┬──────────────┘ ▼ ┌───────────────────┐ │ Step 4: SREM │ │ dirty:like:1 │ │ 10086 │ │ (移除已同步的ID) │ └───────────────────┘扫描步骤:
- 获取脏数据列表:
SMEMBERS dirty:like:1→ 得到[10086, 20001, ...] - 遍历每个 targetId:
- Set 同步:
SMEMBERS like:set:1:10086获取所有已操作用户 ID,写入/更新 DB 用户记录 - Hash 同步:
HGETALL stat:1:10086获取所有计数字段,写入 user_id=0 的统计记录
- Set 同步:
- 清理已同步数据:
- 删除已同步的 Set Key(
DEL like:set:1:10086) - 保留 Hash Key(继续累加新操作)
- 从 Dirty Set 移除已同步的 targetId(
SREM dirty:like:1 10086)
- 删除已同步的 Set Key(
Dirty Set 的核心价值:定时任务只需 SMEMBERS dirty:like:1 获取变更列表,而非 KEYS like:set:* 全库扫描。KEYS 命令时间复杂度 O(N) 且阻塞 Redis,生产环境禁止使用。
3.4 方案三:Bitmap + MQ 异步入库
方案一/二使用 Set 存储用户状态,内存占用与用户数成正比(1 亿用户约 2GB)。当用户 ID 为连续整数且追求极致内存时,可用 Bitmap 替代 Set,内存降至约 12MB。
Key 设计
| Key 类型 | 命名格式 | 示例 | 职责 |
|---|---|---|---|
| Bitmap | {type}:bit:{targetType}:{targetId} | like:bit:1:10086 | 用位偏移标记用户是否已操作 |
| Hash | stat:{targetType}:{targetId} | stat:1:10086 | 聚合计数 |
MQ 消息本身携带变更信息,无需脏标记 Key。
Bitmap 的前提条件:用户 ID 必须为连续整数(自增 ID),且最大 ID 不能过大。雪花 ID、UUID、稀疏 ID 场景不适用,详见第六章。
交互流程
┌─────────────────────────────────────────────────────────┐│ 用户交互请求 │└──────────────────────────┬──────────────────────────────┘ │ ▼ ┌─────────────────────┐ │ 1. 参数校验 │ └──────────┬──────────┘ ▼ ┌─────────────────────┐ │ 2. SETBIT │ │ like:bit:1:10086 │ │ userId → 1 │ └──────────┬──────────┘ │ ┌─────┴─────┐ │ 旧值 = ? │ └─────┬─────┘ ┌──────┴──────┐ ▼ ▼ 旧值=1 旧值=0 │ │ ▼ ▼ ┌──────────┐ ┌──────────────────────────┐ │ 返回 │ │ 3. HINCRBY │ │ false │ │ stat:1:10086 like:1 1 │ │ 已点赞 │ └────────────┬─────────────┘ └──────────┘ │ ▼ ┌──────────────────────────┐ │ 4. MQ 发送消息 │ │ {userId, targetId, │ │ type, action} │ └────────────┬─────────────┘ │ ▼ ┌─────────────┐ │ 返回 true │ │ 点赞成功 │ └─────────────┘执行步骤:
- 参数校验:校验
type/targetId/userId,非法参数直接返回 false - SETBIT 原子操作:
SETBIT like:bit:1:10086 42 1- 返回旧值=0:未点赞过,继续下一步
- 返回旧值=1:已点赞,直接返回 false
- HINCRBY 计数+1:
HINCRBY stat:1:10086 like:1 1 - MQ 发送消息:与 Redis 写入并行执行
核心优势:SETBIT 返回旧值的设计,让”判断是否已点赞”和”设置已点赞”合并为一次原子操作(方案一/二需要 SISMEMBER + SADD 两次操作),无需分布式锁,彻底避免并发重复计数。
取消点赞:
SETBIT like:bit:1:10086 42 0# 返回旧值=1: 已点赞,继续下一步# 返回旧值=0: 未点赞,直接返回 falseHINCRBY stat:1:10086 like:1 -1# MQ 发送 action=unlike 消息入库方式:MQ 异步消费
与方案一相同,MQ 消费者异步消费消息写入数据库。消息格式、幂等保障均一致。
3.5 四种交互场景的实现差异
| 交互类型 | 状态存储(方案一/二) | 状态存储(方案三) | 计数存储 | 特殊机制 |
|---|---|---|---|---|
| 点赞 | Set (like:set) | Bitmap (like:bit) | Hash (like:{code}) | 方案三:SETBIT 原子 toggle |
| 收藏 | Set (collect:set) | Bitmap (collect:bit) | Hash (collect:{code}) | 方案三:SETBIT 原子 toggle |
| 关注 | Set (follow:set:{userId}) | Bitmap (follow:bit:{userId}) | Hash (follow:{code}) | Key 维度为用户 |
| 浏览 | 无(SETNX 锁) | 无(SETNX 锁) | Hash (view:{code}) | 5 分钟防重复锁 |
3.6 三种方案对比
| 维度 | 方案一:Set + MQ | 方案二:Set + 定时扫描 | 方案三:Bitmap + MQ |
|---|---|---|---|
| Redis Key 数量 | 2(Set + Hash) | 3(Set + Hash + Dirty Set) | 2(Bitmap + Hash) |
| 去重判断 | SISMEMBER + SADD(两次操作) | SISMEMBER + SADD(两次操作) | SETBIT(一次原子操作) |
| 内存占用(1亿用户) | ~2GB | ~2GB | ~12MB |
| 用户 ID 限制 | 无限制 | 无限制 | 需连续整数 ID |
| 数据延迟 | 秒级 | 分钟级 | 秒级 |
| DB 写入量 | 每次操作一条记录 | 批量合并 | 每次操作一条记录 |
| 基础设施依赖 | 依赖 MQ 集群 | 仅 Redis + 定时任务 | 依赖 MQ 集群 |
| 实现复杂度 | 低 | 低 | 中(需 ID 映射或连续 ID) |
| 一致性保障 | MQ 重试 + 死信队列 | 扫描周期 + 数据校验 | MQ 重试 + 死信队列 |
| 适用规模 | 中小规模 | 中大规模(需批量合并) | 大规模(自增 ID + 极致内存) |
选型建议:
- 默认选方案一:通用、简单、无 ID 限制,适合大多数场景
- 无 MQ 基础设施选方案二:仅依赖 Redis,定时扫描批量合并写入
- 自增 ID + 亿级用户选方案三:内存从 2GB 降至 12MB,SETBIT 原子去重减少一次 Redis 调用
四、数据同步与缓存预热
4.1 数据库表设计
CREATE TABLE document_user_interaction ( id BIGINT PRIMARY KEY, user_id BIGINT COMMENT '用户ID(0表示统计记录)', target_type INT COMMENT '目标类型: 1=笔记, 2=视频, 3=评论, 4=用户', target_id BIGINT COMMENT '目标ID', interaction_type INT COMMENT '交互类型: 1=点赞, 2=收藏, 3=关注, 4=浏览', status INT COMMENT '状态: 0=取消, 1=有效, 统计记录存储计数', create_time DATETIME, update_time DATETIME, UNIQUE KEY uk_user_target (user_id, target_type, target_id, interaction_type));4.2 缓存预热
服务重启后从数据库预热缓存:
public void warmCacheFromDb(Integer type, Long targetId) { redisTemplate.delete(stateKey); List<DocUserInteraction> interactions = mapper.selectByTarget( targetType, targetId, INTERACTION_TYPE_LIKE); for (DocUserInteraction interaction : interactions) { if (useBitmap) { redisTemplate.opsForValue().setBit(stateKey, interaction.getUserId(), true); } else { redisTemplate.opsForSet().add(stateKey, interaction.getUserId().toString()); } } Long dbCount = mapper.countByTarget(targetType, targetId, INTERACTION_TYPE_LIKE); redisTemplate.opsForHash().put(statKey, countField, dbCount.toString());}预热时机:服务启动时批量预热热点内容;用户首次查询时懒加载预热。
五、兜底与降级方案
5.1 Redis 不可用时的降级策略
┌──────────────────────────────────────────────────────┐│ Redis 健康检查 ││ (每次操作前探测) │└──────────────────────────┬───────────────────────────┘ │ ┌──────┴──────┐ ▼ ▼ Redis 可用 Redis 不可用 │ │ ▼ ▼ ┌───────────┐ ┌───────────────────┐ │ 正常流程 │ │ 降级流程 │ │ 缓存写入 │ │ 直接写入数据库 │ └───────────┘ │ INSERT/UPDATE │ │ 开启限流 │ └───────────────────┘降级策略:
- 直接写库:Redis 不可用时,交互操作降级为直接写入数据库
- 限流保护:降级状态下对交互接口限流(如 1000 QPS),防止 DB 被打满
- 降级状态管理:通过配置中心或本地缓存标记降级状态,避免每次请求都探测 Redis
- 自动恢复:Redis 恢复后自动切换回缓存模式,并从数据库预热热点数据
降级代码示例:
public boolean like(Long userId, Integer targetType, Long targetId) { if (redisHealthChecker.isAvailable()) { return likeViaRedis(userId, targetType, targetId); } return likeViaDatabase(userId, targetType, targetId);}
private boolean likeViaDatabase(Long userId, Integer targetType, Long targetId) { rateLimiter.acquire(); DocUserInteraction existing = mapper.selectByUserAndTarget( userId, targetType, targetId, INTERACTION_TYPE_LIKE); if (existing != null && existing.getStatus() == 1) { return false; } mapper.insertOrUpdate(userId, targetType, targetId, INTERACTION_TYPE_LIKE, 1); return true;}5.2 数据不一致的修复机制
Redis 与数据库之间可能出现数据不一致,需要定期校验和修复。
不一致场景:
| 场景 | 原因 | 影响 |
|---|---|---|
| Redis 写入成功,DB 写入失败 | MQ 消费失败 / 定时扫描中断 | 计数偏大 |
| Redis 写入失败,DB 未写入 | Redis 超时但操作已执行 | 计数偏小 |
| 状态 Key 与 Hash 计数不一致 | 并发操作中间状态 | 状态与计数矛盾 |
修复方案:
- 定时对账任务:每小时抽样对比 Redis Hash 计数与 DB 统计记录,偏差超过阈值时触发全量校验
- 全量校验(方案三):
BITCOUNT like:bit:1:10086统计 Bitmap 中 1 的个数,与 Hash 中的计数对比,不一致时以 Bitmap 为准修复 Hash - 全量校验(方案一/二):
SCARD like:set:1:10086获取 Set 成员数,与 Hash 中的计数对比,不一致时以 Set 为准修复 Hash - DB 计数修复:
SELECT COUNT(*) FROM document_user_interaction WHERE ...与 Hash 对比,不一致时以 DB 为准(DB 是最终数据源)
5.3 MQ 消息丢失的兜底
MQ 方案(一、三)下,消息可能因网络故障、消费者宕机等原因丢失。
| 保障层级 | 措施 | 覆盖场景 |
|---|---|---|
| 生产者确认 | MQ 发送确认(Publisher Confirm) | 生产者发送失败 |
| 消息持久化 | MQ 开启消息持久化 | MQ Broker 宕机 |
| 消费者手动 ACK | 消费者处理成功后手动确认 | 消费者处理异常 |
| 死信队列 | 消费重试 3 次后进入死信队列 | 消费反复失败 |
| 定时对账 | 定时对账任务校验 Redis 与 DB 一致性 | 以上所有兜底 |
关键设计:MQ 方案下无需 Dirty Set,消息丢失的兜底依赖定时对账任务(5.2 节)。对账任务发现 Redis 与 DB 不一致时,以 DB 为准修复 Redis 缓存。
5.4 缓存击穿防护
热点内容缓存过期瞬间,大量请求同时穿透到数据库。
防护措施:
- 互斥锁:缓存重建时使用 Redis SETNX 加锁,只允许一个请求查库并重建缓存
- 逻辑过期:Hash 中存储逻辑过期时间,过期后异步更新,用户始终读到缓存(可能是旧数据)
- 永不过期 + 定时刷新:缓存不设 TTL,由定时任务负责更新计数
六、Bitmap 局限性与风险
6.1 SETBIT 首次写入的性能陷阱
风险:SETBIT 对不存在的 Key 执行时,时间复杂度为 O(offset),需从字节 0 到目标 offset 分配并零初始化整段内存。
影响:offset 为 21.4 亿(哈希取模期望值)时,首次写入分配 256MB 内存,耗时 25 秒,期间 Redis 单线程阻塞,该节点无法响应任何请求。后续操作因 Key 已存在而正常(O(1)),问题易被忽视。
| offset 大小 | 首次 SETBIT 分配内存 | 耗时量级 |
|---|---|---|
| 100 万 | ~125 KB | 毫秒级 |
| 1 亿 | ~12 MB | 百毫秒级 |
| 10 亿 | ~120 MB | 秒级 |
| 21.4 亿(期望值) | ~256 MB | 数秒 |
| 42.9 亿(最坏) | ~512 MB | 超时风险 |
应对:方案三(Bitmap + MQ)仅在用户 ID 为自增小整数(最大值 < 1 亿)时使用,此时首次分配仅 ~12MB,百毫秒级完成。雪花 ID 场景下 offset 过大,应选方案一/二(Set)。
6.2 雪花 ID 与 Bitmap 的根本矛盾
风险:雪花 ID 为 64 位(值域 ~2^63),Bitmap offset 上限为 2^32(约 42.9 亿),差 20 亿倍。哈希取模 hash64(userId) % (2^32 - 1) 是唯一的桥接方式,但存在两个致命问题。
影响:
- 取模后 offset 仍然很大:期望值约 21.4 亿,首次写入仍需分配 ~256MB
- 取模引入碰撞:两个不同 userId 映射到同一 offset,导致 A 收藏后查询 B 的状态返回 true(误判),B 取消收藏把 A 的状态也清掉
| Offset 上限 M | 单内容 1000 人操作碰撞率 | 单内容 1 万人操作碰撞率 | 首次分配内存 |
|---|---|---|---|
| 2^32 (42.9 亿) | ≈ 0.01% | ≈ 1.2% | 512 MB |
| 2^28 (2.68 亿) | ≈ 0.19% | ≈ 17% | 32 MB |
| 2^24 (1677 万) | ≈ 3% | ≈ 95% | 2 MB |
缩小 Offset 到不卡的范围,碰撞率对热门内容不可接受;保持大范围,首次写入卡顿不可接受。性能与正确性无法兼得。
应对:雪花 ID 体系下直接放弃 Bitmap,选方案一/二(Set 存原始 ID,零碰撞)。
6.3 分布式场景下的阻塞与倾斜
风险:Redis 单线程模型下,一个慢 SETBIT 会阻塞整个节点;爆款内容的所有操作打到同一个 Key(同一个节点),造成数据倾斜。 影响:
- 单线程阻塞:一个
SETBIT key 2147483648 1分配 256MB 期间,该节点上所有业务(登录、查询、计数)排队等待,客户端批量超时 - 数据倾斜:爆款文章首次被大量用户收藏,所有 SETBIT 打到同一节点,该节点承受内存分配 + CPU + 网络压力,其他节点空闲
- 大 Key 运维风险:单 Key 数百 MB,DEL 释放内存触发碎片整理卡顿,RDB/AOF 生成时间变长,主从全量同步传输耗时
| 问题 | Bitmap 方案 | Set 方案 |
|---|---|---|
| 单线程阻塞 | SETBIT 分配数百 MB 阻塞整个节点 | SADD O(1),毫秒级 |
| 数据倾斜 | 爆款内容所有操作打同一个节点 | 用户维度 Key 天然分散 |
| 大 Key 风险 | 单 Key 数百 MB | 单用户 Key 通常几 KB |
应对:使用 Hash Tag 机制(like:bit:1:{10086}、stat:1:{10086})确保相关 Key 落在同一 slot;定时扫描方案下 Dirty Set 也需纳入同一 slot(dirty:like:{1})。但 Hash Tag 无法解决单线程阻塞和数据倾斜,根本应对是:雪花 ID 场景选方案一/二(Set),自增 ID 场景选方案三时需评估单 Key 的 offset 上限。
6.4 同步任务的幂等性
风险:定时任务可能重复执行,MQ 消费可能重复消费。
影响:统计记录重复累加,用户状态记录重复插入。
应对:用户记录使用 INSERT ... ON DUPLICATE KEY UPDATE(依赖 4.1 节唯一索引);统计记录使用 user_id=0 标识 + 幂等覆盖更新。
6.5 数据丢失风险
风险:Redis 故障或重启导致缓存数据丢失。 影响:未同步的数据丢失,计数可能不准确。 应对:开启 Redis RDB + AOF 混合持久化;缩短同步周期至 5 分钟;服务重启后从数据库预热缓存;MQ 方案下消息本身即为数据备份。
七、总结
本方案基于 Redis 缓存实现交互数据的高性能读写,提供三种方案:
- 方案一(Set + MQ):通用方案,无 ID 限制,实现简单,适合大多数场景
- 方案二(Set + 定时扫描):无 MQ 依赖,Dirty Set 追踪变更,定时批量合并写入
- 方案三(Bitmap + MQ):极致内存(12MB vs 2GB),SETBIT 原子去重,需连续整数 ID
关键权衡:
- 选择最终一致性换取高性能,兜底机制保障数据最终正确
- Bitmap 的内存优势需与 ID 范围限制权衡,ID 稀疏度 > 100 时应改用 Set
- MQ 方案延迟低,定时扫描方案批量合并能力强,按业务规模和基础设施选择
- 分布式环境下需通过 Hash Tag 确保 Key 同 slot
八、参考资料
- Redis Bitmap 官方文档
- Redis Hash 官方文档
- Redis Set 官方文档
- 《Redis 设计与实现》黄健宏