实践篇 · 设计与选型

存储选型与架构设计

学完数据结构后, 最常见的困惑是: "这些场景真的适合单存 Redis 吗? 内存不是很贵又容易丢吗?" 本篇将破除两个根深蒂固的直觉误区, 并通过黑名单实战案例来演示选型过程.

1. 误区一: "内存数据容易丢"

直觉来源: 内存断电即失, 进程挂了数据没了.

现实: Redis 早已不是"纯内存数据库", 而是"以内存为主存储 + 多级持久化保障"的系统.

持久化保障层级

保障层级 机制 丢失窗口
RDB 快照 定时将全量数据 dump 到磁盘 两次快照之间(分钟级)
AOF 日志 每条写命令追加到日志文件 取决于 fsync 策略
AOF everysec 每秒 fsync 一次(默认推荐) 最多丢 1 秒数据
AOF always 每条命令都 fsync 理论不丢(性能代价大)
主从复制 数据实时同步到从节点 主挂时从节点有完整副本
Sentinel / Cluster 自动故障转移 秒级切换, 几乎无感

MySQL 的数据也在内存里操作(Buffer Pool), 靠 redo log 保证持久化. Redis 的 AOF 本质上和 MySQL 的 redo log 是同一个思路--都是 WAL(Write-Ahead Log, 预写日志). 不会觉得"MySQL 数据在内存里所以容易丢", 对 Redis 也不该有这个偏见.

实际宕机场景分析

单机 Redis + AOF everysec:
进程崩溃 → 重启后从 AOF 恢复, 最多丢 1 秒写入

主从 + Sentinel(2从1哨兵组):
主节点宕机 → Sentinel 秒级自动切换到从节点
数据 → 从节点几乎有全量副本(异步复制延迟通常 < 100ms)

Redis Cluster(3主3从):
单节点宕机 → 对应从节点自动提升
要同时丢主从 → 极低概率(不同机架/可用区部署)

"丢了能接受"的判断标准

不是"会不会丢", 而是**"丢了之后恢复成本多高":**

数据类型 能否单存 Redis 理由
关注/粉丝关系 ✅ 可以 AOF+主从保障充分; 极端丢失可从用户行为日志重建
排行榜 ✅ 可以 可从原始数据(点赞表/分数表)重算
Session ✅ 可以 丢了让用户重新登录, 体验可接受
验证码/限流计数 ✅ 可以 本身就是临时数据
购物车 看体量 小平台可单存; 大厂双存避免客诉
订单/支付流水 ❌ 必须数据库 资金相关零容忍

2. 误区二: "大数据量会爆内存"

直觉来源: 内存比磁盘贵几十倍, 存不起海量数据.

现实: 需要算一笔账, 而不是凭感觉说"存不起".

算账示例: 1 亿用户关注关系

假设条件:
- 1 亿用户
- 平均每人关注 200 人
- 用户 ID 为 64 位整数(8 字节)

纯数据量:
1亿 × 200 × 8 字节 = 160 GB

加上 Set 结构开销(intset 编码约 1.2x):
≈ 192 GB

部署方案:
Redis Cluster 拆 100 个分片 → 每个节点 ≈ 2 GB
或 3 台 64GB 内存机器即可承载

成本 vs 收益

方案 月成本(预估) 获得的能力
3 台 64GB Redis 云主机 ≈ ¥9,000/月 所有关注查询 < 1ms, 共同关注实时计算, 无需缓存同步逻辑
MySQL + 缓存方案 机器 ¥4,000 + 开发维护人力 关注查询 50~200ms, 缓存一致性代码, 复杂度高

核心公式

如果 Redis 内存成本 < 用数据库实现同等性能所需的(机器 + 人力 + 复杂度)成本, 那就该用 Redis. 很多时候几千块月租换来的开发效率和查询性能是划算的.

装不下时的工程手段

策略 做法 适用条件
分片(Cluster) 数据按 key hash 分散到多个节点 单机装不下但总量可控
冷热分离 活跃用户放 Redis, 僵尸用户回落 DB 数据量大但热数据比例低
编码优化 使用 intset/ziplist 等紧凑编码 数据类型满足编码条件
降级存储 超大集合只存摘要/计数, 全量走 DB 大 V 粉丝列表等极端场景

3. 选型决策框架

用三个问题替代直觉判断:

问题 1: 数据丢失后果严重吗?
├── 不严重 / 可重建 → Redis 单存(+ AOF + 主从)
├── 较严重但需要高性能 → 双存(Redis + DB)
└── 零容忍(资金/合规) → 以 DB 为主, Redis 仅做缓存

问题 2: 数据量能装下吗?
├── 先算账: 数据量(GB) × 单价 = 月成本
├── 成本可接受 → 全量 Redis
├── 太贵 → 冷热分离(热数据 Redis + 冷数据 DB)
└── 单机装不下 ≠ 方案不可行(分片解决)

问题 3: 不用 Redis 的替代方案复杂度如何?
├── DB 方案需要多少额外缓存/索引/代码?
├── 缓存一致性维护成本?
└── 多数情况下 Redis 单存反而是最简方案

选型就像选交通工具: 不是"飞机贵所以一律坐火车", 而是"这趟行程的时间价值是否值得机票钱". Redis 的内存开销是"机票", 换来的是毫秒级响应和极简的代码架构.

4. 实例: 大 V 粉丝列表设计

千万粉丝的大 V 是"全量 Redis"策略的极限挑战. 实际工程中采用分层存储:

操作 存储位置 原因
"我是否关注了 TA" Redis Set(SISMEMBER) 高频读, O(1) 响应
"共同关注" Redis Set(SINTER) 实时性要求高
粉丝总数 Redis String(INCR 维护) 首页高频展示
粉丝列表第 50 页 数据库分页查询 低频访问, 不值得全放内存
最新 N 个粉丝 Redis ZSet(score=关注时间) 展示"最近关注", 热数据
# 关注操作: 同时维护正向 + 反向索引
# user:1001 关注 user:bigV

# 1. 正向: 我的关注列表(全量 Redis)
SADD following:1001 "bigV"

# 2. 反向-热数据: 大V最近粉丝(ZSet, 只保留最近1万)
ZADD followers:bigV:recent 1717200000 "1001"
ZREMRANGEBYRANK followers:bigV:recent 0 -10001

# 3. 反向-计数
INCR followers:bigV:count

# 4. 反向-全量: 写入 DB(用于分页浏览)
INSERT INTO follow_relation (follower_id, following_id, created_at) ...

设计原则

热路径走 Redis, 冷路径走 DB. 判断热冷的标准不是数据本身, 而是访问频率--"是否关注"每次进主页都要查(热), "粉丝列表第 50 页"可能一年没人翻(冷).

5. 实践: 高频读低频写的黑名单设计

需求描述: 系统需要维护一份黑名单(用户 ID / IP / 设备号等), 每次请求都要判断"当前请求者是否在黑名单中". 特征:

  • 读频率极高: 每个 API 请求都要查一次(QPS 可能 10 万+)
  • 写频率极低: 运营人员手动拉黑 / 风控系统定期更新(一天几十~几百次)
  • 数据量中等: 万~百万级条目
  • 实时性要求: 拉黑后需尽快生效(秒级可接受)

5.1 方案一: 单存 Redis

数据结构选择

选项 命令 内存 适用规模
Set(推荐) SADD / SISMEMBER 中等 百万级以下
Bitmap SETBIT / GETBIT 极小 ID 为连续数字时
Bloom Filter BF.ADD / BF.EXISTS 极小 允许误判时(千万级)

架构设计

flowchart LR
GW["网关层(每个请求)"] -->|"SISMEMBER"| R["Redis blacklist:ip"]
R -->|"0(不在) / 1(在)"| GW
R --- P["AOF持久化 + 主从复制"]
OP["运营后台 / 风控系统"] -->|"SADD / SREM"| R

核心代码

# === 初始化 ===
# 批量导入黑名单
SADD blacklist:ip "10.0.0.1" "192.168.1.100" "172.16.0.50"
SADD blacklist:uid "user_evil_1" "user_evil_2"

# === 查询(每个请求) ===
# 判断 IP 是否在黑名单中 → O(1)
SISMEMBER blacklist:ip "10.0.0.1" → (integer) 1 ← 在黑名单中, 拒绝
SISMEMBER blacklist:ip "10.0.0.2" → (integer) 0 ← 正常放行

# === 写入(低频运营操作) ===
# 拉黑
SADD blacklist:ip "203.0.113.5"

# 解禁
SREM blacklist:ip "10.0.0.1"

# 查看黑名单大小
SCARD blacklist:ip → (integer) 3

# 批量检查(Redis 6.2+)
SMISMEMBER blacklist:ip "10.0.0.1" "10.0.0.2" "203.0.113.5"
1) (integer) 1
2) (integer) 0
3) (integer) 1

持久化与高可用配置

# redis.conf 关键配置
appendonly yes # 开启 AOF
appendfsync everysec # 每秒刷盘(最多丢 1 秒写入)
auto-aof-rewrite-percentage 100 # AOF 翻倍时自动重写压缩

# 主从复制(至少 1 个从节点)
replicaof master_host 6379

# Sentinel 自动故障转移(生产必备)
sentinel monitor blacklist-master master_host 6379 2

风险与兜底

风险场景 后果 兜底方案
Redis 宕机 + AOF 损坏 黑名单全部丢失, 恶意用户放行 定期 RDB 备份到 OSS; 黑名单源数据留一份在运营后台可快速重导
主从切换瞬间 短暂不可用(1~3 秒) 网关层降级策略: Redis 不可用时暂时放行或暂时全部拦截(根据安全等级选择)
误操作 FLUSHDB 黑名单清空 开启 rename-command FLUSHDB "" 禁用危险命令

单存方案总结

适用条件: 黑名单条目可从外部来源重建(运营记录, 风控日志), 对极端丢失有容忍度(几秒内恢复即可).

优势: 架构极简, 响应极快(<0.1ms), 无一致性问题.

内存估算: 100 万个 IP(平均 15 字节) ≈ 50~80MB, 完全可接受.

5.2 方案二: 双存 Redis + DB

当黑名单属于核心安全资产(如金融风控, 反欺诈), 不能接受任何丢失风险时, 采用双存方案.

架构设计

flowchart TD
GW["网关层"] -->|"SISMEMBER (读)"| R["Redis blacklist"]
OP["运营后台 / 风控系统"] -->|"写入"| DB["DB blacklist"]
DB -->|"启动时/定时 全量同步"| R
OP -->|"写DB后通知Redis更新"| R

数据库表设计

CREATE TABLE blacklist (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
type TINYINT NOT NULL COMMENT '1=IP 2=UID 3=设备号',
value VARCHAR(128) NOT NULL COMMENT '黑名单值',
reason VARCHAR(256) DEFAULT '' COMMENT '拉黑原因',
operator VARCHAR(64) DEFAULT '' COMMENT '操作人',
status TINYINT DEFAULT 1 COMMENT '1=生效 0=已解禁',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_type_value (type, value),
KEY idx_status (status)
) ENGINE=InnoDB COMMENT='黑名单表';

写入流程(低频)

拉黑操作:
1. 写入 DB(INSERT ... ON DUPLICATE KEY UPDATE status=1)
2. DB 写入成功后, 同步更新 Redis(SADD)
3. 两步都成功 → 返回成功

解禁操作:
1. 更新 DB(UPDATE SET status=0)
2. DB 更新成功后, 同步更新 Redis(SREM)
3. 两步都成功 → 返回成功
def add_to_blacklist(type, value, reason, operator):
# Step 1: 先写 DB(权威数据源)
db.execute("""
INSERT INTO blacklist (type, value, reason, operator, status)
VALUES (?, ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE status=1, reason=?, operator=?
""", type, value, reason, operator, reason, operator)

# Step 2: 再写 Redis
redis.sadd(f"blacklist:{type}", value)

def remove_from_blacklist(type, value):
db.execute("UPDATE blacklist SET status=0 WHERE type=? AND value=?", type, value)
redis.srem(f"blacklist:{type}", value)

读取流程(高频)

# 直接查 Redis, 不回源 DB
# 因为 Redis 中的数据通过写入流程 + 全量同步保证完整性
SISMEMBER blacklist:1 "10.0.0.1" → 1 则拒绝

为什么读不走 DB?

黑名单查询在每个请求的关键路径上(10 万+ QPS), 如果走 DB 需要为 SELECT 建连接池, 加索引, 承受 1~5ms 延迟. Redis 的 SISMEMBER 是 O(1) 内存操作(<0.1ms), 差 10~50 倍.

DB 是权威存储, Redis 是查询加速层.

一致性保障

机制 作用 实现
写入时同步 正常情况下 DB 和 Redis 一致 写 DB 成功 → 立即 SADD/SREM Redis
全量同步(兜底) 修复漂移, 处理异常场景 定时任务每 N 分钟从 DB 全量加载到 Redis
启动时预热 Redis 重启后数据恢复 服务启动时从 DB 全量 LOAD 到 Redis
# 全量同步任务(定时执行, 如每 5 分钟)
def full_sync_blacklist():
for type in [1, 2, 3]: # IP, UID, 设备号
# 从 DB 拉取所有生效的黑名单
rows = db.query("SELECT value FROM blacklist WHERE type=? AND status=1", type)
values = [row.value for row in rows]

key = f"blacklist:{type}"

# 原子替换: 写入临时 key → RENAME 覆盖
tmp_key = f"{key}:tmp:{timestamp}"
if values:
redis.sadd(tmp_key, *values)
redis.rename(tmp_key, key) # 原子替换
else:
redis.delete(key)

为什么用 RENAME 而不是 DEL + SADD?

DEL 旧 key + SADD 新数据之间存在时间窗口--这段时间内黑名单为空, 恶意用户可能趁虚而入. RENAME 是原子操作, 切换瞬间无间隙.

异常场景处理

场景 A: 写 DB 成功, 写 Redis 失败
→ 下次全量同步时自动修复
→ 或立即重试 Redis 写入(最多重试 3 次)
→ 最坏情况: 恶意用户在同步间隔内逃过检查(分钟级)

场景 B: Redis 宕机
→ Sentinel 自动故障转移到从节点(数据完整)
→ 如果从节点也不可用:
方案 a) 降级到查 DB(临时, 有性能损失)
方案 b) 服务启动时触发全量同步恢复

场景 C: DB 和 Redis 数据不一致
→ 全量同步定时修复(以 DB 为权威)
→ 监控告警: 对比 SCARD 与 DB COUNT, 差异超阈值报警

双存方案总结

适用条件: 黑名单是安全核心资产, 不容许任何丢失; 需要审计记录(谁在什么时候拉黑了谁).

优势: 数据绝对不丢(DB 为权威), 读性能极高(Redis 加速), 支持审计和回溯.

代价: 架构复杂度增加, 需维护同步逻辑, 需处理一致性问题.

5.3 方案对比与选择

维度 单存 Redis 双存 Redis + DB
架构复杂度 ⭐ 极简 ⭐⭐⭐ 中等
读性能 < 0.1ms < 0.1ms(同样走 Redis)
写性能 < 0.1ms 1~5ms(多了 DB 写入)
数据安全性 ⭐⭐ AOF+主从保障 ⭐⭐⭐⭐ DB 持久化兜底
可审计性 ❌ 无操作记录 ✅ DB 有完整操作历史
恢复能力 依赖 AOF/RDB 备份 DB 随时可重建 Redis
开发成本 半天 2~3 天
适用场景 内部系统, 非核心业务, 黑名单可从外部重建 金融风控, 核心安全, 需要审计追溯

单存 = 把钱放保险箱(Redis + AOF) -- 安全性不低, 但万一被盗了就没了.
双存 = 保险箱 + 银行存款(Redis + DB) -- 随时可以从银行补回来, 还有流水单可查. 选哪个取决于保管的是零花钱还是全部身家.

6. 总结心法

破除直觉的三条心法

  • Redis 丢数据不是"会不会"的问题, 而是"丢多少能不能接受"的问题 -- AOF everysec + 主从之后, 风险已经足够低, 绝大多数非资金场景可以单存.
  • 内存贵不贵不该凭感觉, 该算账 -- 把 Redis 内存成本和"不用 Redis 时的替代方案总成本(机器+人力+复杂度)"做对比, 很多时候 Redis 反而是最经济的.
  • 不是"全部 Redis"或"全部 DB"的二选一 -- 分层存储(热路径 Redis + 冷路径 DB)才是工程现实. 按访问频率而非数据重要性分层.

思考延伸

  1. 当前负责的业务中, 有哪些数据适合从"DB + 缓存"简化为"Redis 单存"? 试着用三问框架评估一下.
  2. 双存方案中, 如果全量同步期间有新的写入操作, 可能出现什么一致性问题? 如何解决?(提示: 考虑 RENAME 的时序)
  3. 如果黑名单规模达到千万级(10M 条 IP), Set 仍然合适吗? 此时 Bloom Filter 方案有什么优势和劣势?