跳至主要內容

分布式缓存策略:Redis Cluster、一致性哈希与缓存穿透防护

郑天祺大约 11 分钟分布式Redis Cluster分布式缓存一致性哈希缓存穿透防护

前言

在互联网高并发系统中,缓存已成为必不可少的基础设施。从单机Redis到分布式缓存集群,从简单的Key-Value存储到复杂的缓存一致性保障,每一步升级都关乎系统能否扛住业务增长的流量冲击。

本文从缓存分布策略一致性哈希三大缓存问题(穿透/击穿/雪崩)的原理与防护方案出发,结合生产实践经验,帮助架构师设计高可用、高性能的分布式缓存方案。


一、缓存分布策略:从单机到集群

1.1 单机Redis的瓶颈

单机Redis虽然性能优异(单核QPS可达10W+),但面临三大问题:

问题影响解决方向
内存容量有限数据集>可用内存时,需要淘汰策略水平扩展(分片)
单点故障服务不可用,数据丢失主从复制 + 哨兵
QPS上限网络IO饱和、CPU成为瓶颈数据分片、负载均衡

经验教训:在淘宝、字节等大厂,当单机Redis命中率低于90%时,往往考虑迁移到分片集群。

1.2 三种分布策略对比

方案1:客户端分片(Client-side Sharding)

应用层 → Hash(key) → Slot计算 → 选择对应Redis实例

特点

  • 简单直接,无中间层
  • 每个客户端维护全量节点映射表

优点

  • 低延迟(无代理层)
  • 灵活扩展(支持自定义hash函数)

缺点

  • 节点变化时,客户端感知延迟高(需重新发现)
  • 多语言环境下维护成本大
  • 无法做全局管理

适用场景:固定集群大小、变更频率低的场景(如定时任务缓存)

方案2:代理层分片(Proxy-based Sharding)

应用层 → Redis Proxy(Twemproxy/Codis) → 后端Redis实例集群

代表产品

  • Twemproxy(Twitter开源):简轻量级,单代理
  • Codis(豆瓣开源):支持在线扩容,强大的管理后台

架构示例(Codis):

┌─────────────────┐
│   应用客户端    │
└────────┬────────┘
         │ (Redis Protocol)
┌────────▼────────────────┐
│  Codis Proxy集群        │
│  (负载均衡+故障转移)    │
└────────┬────────────────┘
         │ (RESP Protocol)
    ┌────┴─────┬───────┬───────┐
    │           │       │       │
┌───▼──┐  ┌───▼──┐ ┌──▼───┐ ┌─▼────┐
│Slot0 │  │Slot1 │ │Slot2 │ │Slot3 │
│Group │  │Group │ │Group │ │Group │
└──────┘  └──────┘ └──────┘ └──────┘
   │        │        │        │
 Redis    Redis    Redis    Redis
 Master   Master   Master   Master
   │        │        │        │
 Redis    Redis    Redis    Redis
 Slave    Slave    Slave    Slave

优点

  • 对应用透明(无需改代码)
  • 支持动态扩容(Codis)
  • 中心化管理

缺点

  • 增加网络往返(RTT增加1ms左右)
  • 代理层成为性能瓶颈
  • 代理故障影响全部业务

适用场景:中大型分布式系统、需要弹性扩容

方案3:服务端分片(Redis Cluster)

Redis 3.0原生支持的集群方案,业界标准

应用层 → Redis Cluster(16384 Slot) → 自动路由和故障转移

Cluster架构

  • 16384个Slot均匀分配到节点
  • 每个节点知道全量节点信息(Gossip协议同步)
  • 客户端可连任意节点,自动重定向

优点

  • 对应用基本透明(客户端智能路由)
  • 故障自动转移(无需哨兵)
  • 在线扩容(resharding无锁)
  • 官方原生支持

缺点

  • 16384 Slot粒度固定,某些场景不灵活
  • 跨Slot操作受限(Pipeline、Multi等)
  • 运维门槛较高

适用场景:大规模分布式系统、需要高可用、云原生部署

1.3 选型决策

┌─────────────────┐
│  缓存数据量?   │
└────────┬────────┘
    < 64GB │ > 64GB
         │    │
    ┌────▼────┴─────────┐
    │                    │
┌───▼──────┐      ┌──────▼──┐
│单机Redis │   分布式缓存│
│  + 哨兵  │    Cluster  │
└──────────┘      └─────────┘

我们的选择(字节跳动、淘宝类场景):

  • <128GB: Redis Cluster (3-5节点)
  • >128GB: 先Cluster分片,后考虑Codis管理层

二、一致性哈希:缓存分片的智慧

2.1 普通哈希的问题

场景:4个缓存节点,使用简单取模:hash(key) % 4

key分布:
0 -> Node 0
1 -> Node 1
2 -> Node 2
3 -> Node 3

扩容到5个节点:
0 -> Node 0
1 -> Node 1
2 -> Node 2
3 -> Node 3
4 -> Node 4   ← 新增

重新哈希后:
0 -> Node 0 ✓
1 -> Node 1 ✓
2 -> Node 2 ✓
3 -> Node 3 ✓
4 -> Node 0 (原本应在4上)
...

结果:80%的缓存失效,缓存雪崩!

2.2 一致性哈希原理

核心思想:将节点和数据都映射到一个环形空间(通常用2^32范围),尽量减少重新映射的数据量。

算法步骤

  1. 构建哈希环(0 - 2^32-1)

  2. 映射节点hash(node_name) % 2^32

              Hash值增大方向
                ↓
        ┌──────────────────┐
        │  Node C (3000)   │
        │                  │
  Node A(500)              Node B(2000)
        │                  │
        └──────────────────┘
         顺时针方向 →
  1. 映射数据hash(key) % 2^32,数据分配给顺时针第一个遇到的节点
Key分配示例:
- hash(user:1) = 600  → 顺时针→ Node C (最近节点)
- hash(user:2) = 1500 → 顺时针→ Node B
- hash(user:3) = 2500 → 顺时针→ Node C
  1. 扩容时:只影响受新节点"割取"的数据
添加Node D (1800):
        ┌──────────────────┐
        │  Node C (3000)   │
        │                  │
  Node A(500)   Node D(1800) Node B(2000)
        │           ↑        │
        └──────────────────┘
                │
只有 [Node A, Node D] 间的数据需迁移
(原本属于Node B的被D截取)

2.3 虚拟节点解决热点问题

问题:节点分布不均,某节点承载流量过高

原生一致性哈希:
        ┌──────────────────┐
        │  Node C          │  (跨度大)
        │                  │
  Node A                  Node B
        │  (跨度小)        │
        └──────────────────┘

结果:Node C 数据量和QPS是Node A的10倍!

解决方案虚拟节点,每个物理节点映射多个虚拟副本

class ConsistentHash:
    def __init__(self, nodes, virtual_nodes=150):
        self.ring = {}
        self.nodes = []
        
        for node in nodes:
            # 每个节点创建150个虚拟副本
            for i in range(virtual_nodes):
                virtual_key = f"{node}:{i}"
                hash_val = hash(virtual_key)
                self.ring[hash_val] = node
            self.nodes.append(node)
    
    def get_node(self, key):
        hash_val = hash(key)
        # 找顺时针第一个节点
        for ring_key in sorted(self.ring.keys()):
            if ring_key >= hash_val:
                return self.ring[ring_key]
        return self.ring[sorted(self.ring.keys())[0]]

虚拟节点数量

集群大小推荐虚拟节点数原因
3-5个150-200均衡度≥95%
5-20个100-150均衡度≥98%
>20个50-100内存开销考虑

三、三大缓存问题及防护方案

3.1 缓存穿透(Cache Penetration)

定义:请求数据既不在缓存,也不在数据库,每次都打穿缓存层直达数据库。

场景:黑客扫描不存在的用户ID、恶意爬虫查询虚假商品

请求 user_id=99999999
  ↓
Cache Miss (不存在)
  ↓
Query DB (0 rows)
  ↓
Response NULL

重复请求 → 数据库被打穿 → CPU飙升、数据库连接池耗尽

防护方案对比

方案1:布隆过滤器(Bloom Filter)

原理:用多个哈希函数映射数据到位数组,快速判断"可能存在"或"一定不存在"

实现

from pybloom_live import BloomFilter

class CacheLayer:
    def __init__(self):
        # 初始容量1000万,假正率1%
        self.bloom = BloomFilter(capacity=10_000_000, error_rate=0.01)
        # 预热布隆过滤器
        for user_id in self.get_all_user_ids():
            self.bloom.add(str(user_id))
    
    def get_user(self, user_id):
        # 一定不存在 → 直接返回NULL,无需查DB
        if str(user_id) not in self.bloom:
            return None
        
        cache_val = redis.get(f"user:{user_id}")
        if cache_val:
            return deserialize(cache_val)
        
        # 布隆过滤器说可能存在,但缓存没有 → 查DB
        db_val = db.query(f"SELECT * FROM users WHERE id = {user_id}")
        if db_val:
            redis.set(f"user:{user_id}", serialize(db_val), ex=3600)
        
        return db_val

优缺点

维度布隆过滤器
内存占用极低(10M用户仅需1.2MB)
假正率可控(1%-5%)
不支持删除普通Bloom不支持(需Counting Bloom)
维护成本需定期重建(新增用户时)

方案2:缓存NULL值

简单粗暴:不存在的key也缓存,但设置超短TTL

def get_user(user_id):
    val = redis.get(f"user:{user_id}")
    if val is not None:
        return deserialize(val)
    
    db_val = db.query(user_id)
    if db_val:
        # 存在 → 缓存3600s
        redis.setex(f"user:{user_id}", 3600, serialize(db_val))
    else:
        # 不存在 → 缓存空值,60s后失效
        redis.setex(f"user:{user_id}", 60, "NULL")
    
    return db_val

缺陷

  • 内存浪费(存储大量NULL)
  • 无法立即同步新增数据(需等TTL过期)

适用:冷数据、变化频率低

方案3:布隆 + 缓存组合

Request → Bloom Check
         ├─ 不存在 → return NULL (0.01%假正,还需DB查询)
         └─ 可能存在 → Redis 查询
                     ├─ Hit → return data
                     └─ Miss → DB查询 + 缓存

推荐指数:⭐⭐⭐⭐⭐

3.2 缓存击穿(Cache Breakdown)

定义热key过期,瞬间大量请求同时打向数据库

场景:明星商品、热搜词的缓存在12:00:00同时过期

时间12:00:00
iPhone缓存过期 (TTL=0)
    ↓
1000个并发请求到达
    ↓
全部进入DB查询
    ↓
数据库连接池爆满 → 后续请求排队等待

防护方案

方案1:互斥锁(Mutex Lock)

from threading import Lock

lock = Lock()

def get_hot_product(product_id):
    val = redis.get(f"product:{product_id}")
    if val:
        return deserialize(val)
    
    # 缓存Miss,竞争锁
    if lock.acquire(blocking=False):
        try:
            # 再次检查缓存(double-check)
            val = redis.get(f"product:{product_id}")
            if val:
                return deserialize(val)
            
            # 查DB并缓存
            db_val = db.get_product(product_id)
            redis.setex(f"product:{product_id}", 3600, serialize(db_val))
            return db_val
        finally:
            lock.release()
    else:
        # 没获得锁,等待其他线程加载完,再查缓存
        while not redis.exists(f"product:{product_id}"):
            time.sleep(0.01)
        return deserialize(redis.get(f"product:{product_id}"))

特点

  • 只允许一个线程查DB
  • 其他线程等待 → 缓存Miss率低
  • 但需要本地锁(分布式系统中不适用)

方案2:分布式锁(Redis Setnx)

def get_hot_product_distributed(product_id):
    val = redis.get(f"product:{product_id}")
    if val:
        return deserialize(val)
    
    lock_key = f"lock:product:{product_id}"
    # 原子化设置锁,仅当不存在时成功
    if redis.setnx(lock_key, "1"):
        redis.expire(lock_key, 10)  # 防止死锁
        try:
            db_val = db.get_product(product_id)
            redis.setex(f"product:{product_id}", 3600, serialize(db_val))
            return db_val
        finally:
            redis.delete(lock_key)
    else:
        # 没获得锁,自旋等待
        retry_count = 0
        while retry_count < 50:
            val = redis.get(f"product:{product_id}")
            if val:
                return deserialize(val)
            time.sleep(0.01)
            retry_count += 1
        # 超时仍无数据,降级查DB
        return db.get_product(product_id)

方案3:热key主动续期

提前感知热key,在过期前主动更新缓存

class HotKeyManager:
    def __init__(self, redis_client):
        self.redis = redis_client
        self.hot_keys = {}  # key -> (ttl, last_access_time)
    
    def track_access(self, key):
        # 访问次数/时间窗口判断热key
        if self.is_hot(key):
            self.hot_keys[key] = time.time()
    
    def refresh_hot_keys(self):
        """后台线程定期运行,提前刷新热key"""
        for key in self.hot_keys:
            ttl = self.redis.ttl(key)
            # TTL < 60s 时主动更新(给并发空隙)
            if ttl < 60:
                # 异步更新数据库,重新缓存
                value = self.db.get_by_key(key)
                self.redis.setex(key, 3600, serialize(value))

推荐方案:互斥锁 + 主动续期 ⭐⭐⭐⭐⭐

3.3 缓存雪崩(Cache Avalanche)

定义大量key同时过期,或缓存层整体故障,大流量打穿数据库

场景

  • 批量缓存在12:00:00设置,12:00:00+1h全部过期
  • Redis实例宕机,缓存层完全不可用
T1: 大量缓存过期
    ↓
T2: 并发请求全打向DB
    ↓
T3: DB CPU 100% → 响应变慢 → 排队
    ↓
T4: 应用超时 → 重试 → 雪上加霜

防护方案

方案1:缓存失效时间随机化

import random

def set_cache_with_jitter(key, value, base_ttl=3600):
    # TTL = 3000-4200s,避免同时过期
    ttl = base_ttl + random.randint(-10, 20) * 60
    redis.setex(key, ttl, serialize(value))

缺陷:治标不治本,只延缓不解决

方案2:缓存双层设计

def get_product_with_double_cache(product_id):
    # L1: 热缓存(Redis,TTL=1h)
    l1_key = f"product:hot:{product_id}"
    val = redis.get(l1_key)
    if val:
        return deserialize(val)
    
    # L2: 冷缓存(Redis,TTL=24h,作为降级方案)
    l2_key = f"product:cold:{product_id}"
    val = redis.get(l2_key)
    if val:
        # 同时更新L1
        redis.setex(l1_key, 3600, val)
        return deserialize(val)
    
    # 都Miss,查DB
    db_val = db.get_product(product_id)
    # 同时写入L1和L2
    redis.setex(l1_key, 3600, serialize(db_val))
    redis.setex(l2_key, 86400, serialize(db_val))
    return db_val

方案3:缓存预热 + 故障转移

class CachePreheater:
    def __init__(self, redis_client, db_client):
        self.redis = redis_client
        self.db = db_client
    
    def preheat_hot_data(self):
        """应用启动时预热热数据"""
        products = self.db.query("SELECT * FROM products WHERE hot=1 LIMIT 10000")
        for p in products:
            # 分散TTL,避免同时过期
            ttl = 3600 + random.randint(0, 1800)
            self.redis.setex(f"product:{p.id}", ttl, serialize(p))
    
    def fallback_strategy(self):
        """缓存层故障时的降级策略"""
        if not self.redis.ping():
            # Redis宕机,直连DB(开启查询缓存)
            return self.db.with_local_cache()

完整方案

缓存雪崩 → 
├─ 均衡TTL分布(随机+阶梯)
├─ 多层缓存兜底(热/冷层)
├─ 数据库连接池隔离 
├─ 流量限流(令牌桶)
└─ 熔断降级 (开启DB查询缓存)

四、Redis Cluster生产实战

4.1 集群架构设计

推荐配置(中等规模):

集群规模:6个节点 (3主3从)
单节点配置:
  - CPU: 4核
  - 内存: 16GB
  - 存储: SSD 100GB
  - 网络: 万兆网卡
  
单个Slot承载:
  - ~2730个Slot (16384/6)
  - ~16GB数据
  - QPS: 5000-8000

4.2 常见坑点

坑1:跨Slot Pipeline

# ❌ 错误做法
redis.pipeline().mget(['key:1', 'key:2', 'key:3']).execute()
# 如果三个key分别在Slot 100, 200, 300,会报错:
# CROSSSLOT Keys in request don't hash to the same slot

# ✓ 正确做法
pipeline = redis.pipeline()
for key in keys:
    pipeline.get(key)
pipeline.execute()  # 多次网络往返,但可行

坑2:DEL大key

# ❌ 错误做法
redis.delete('big_hash_key')  # 单线程阻塞数秒

# ✓ 正确做法
# 使用UNLINK(异步删除)
redis.execute_command('UNLINK', 'big_hash_key')

# 或分批删除
cursor = 0
while True:
    cursor, data = redis.hscan('big_hash', cursor=cursor, count=100)
    for field, _ in data:
        redis.hdel('big_hash_key', field)
    if cursor == 0:
        break

坑3:Cluster下的Watch/Multi

# ❌ 错误做法
redis.watch(['key:1', 'key:2'])  # 跨Slot,不支持
redis.multi()

# ✓ 正确做法
# 方案1:保证所有key在同一Slot
redis.watch(['{user:1}:profile', '{user:1}:settings'])

# 方案2:使用Lua脚本(原子性)
redis.eval("""
    if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('set', KEYS[1], ARGV[2])
    end
""", 1, 'key', 'old_val', 'new_val')

五、性能优化建议

5.1 连接池配置

from redis.connection import ConnectionPool
from redis import Redis

pool = ConnectionPool(
    host='cluster-endpoint',
    port=6379,
    max_connections=50,  # 单机Redis建议10-20,Cluster建议50-100
    socket_keepalive=True,
    socket_keepalive_options={
        1: 60,  # TCP_KEEPIDLE 60s
        2: 30,  # TCP_KEEPINTVL 30s
    }
)

redis_client = Redis(connection_pool=pool)

5.2 批量操作优化

# ❌ 低效:循环Set,100次网络往返
for key in keys:
    redis.set(key, values[key])

# ✓ 高效:Pipeline,1次网络往返
pipe = redis.pipeline(transaction=False)
for key in keys:
    pipe.set(key, values[key])
pipe.execute()

# ⭐⭐ 超优化:Lua脚本 + 批量处理
script = redis.register_script("""
    for i, key in ipairs(KEYS) do
        redis.call('set', key, ARGV[i])
    end
    return 'OK'
""")
script(keys=keys, args=values)

总结

问题根本原因最佳方案
穿透数据不存在布隆过滤器
击穿热key过期互斥锁 + 续期
雪崩大量key同时过期随机TTL + 多层缓存
分片单机容量限制Redis Cluster + 一致性哈希

黄金法则

  1. 区分冷热数据,差异化缓存策略
  2. 监控缓存命中率,目标≥95%
  3. 定期review热key,主动续期
  4. 设计降级预案,缓存故障不能瘫痪系统

参考阅读

上次编辑于:
贡献者: zhengtianqi