2025-07-03

第一章:分布式事务基础理论

1.1 ACID 与 CAP 定理回顾

ACID 特性

  • 原子性(Atomicity):事务要么全部成功提交,要么全部失败回滚,中间不可见半成品。
  • 一致性(Consistency):事务执行前后,系统都必须处于合法状态(满足所有约束)。
  • 隔离性(Isolation):并发执行的事务之间不会相互干扰,隔离级别定义了“可见性”边界。
  • 持久性(Durability):事务一旦提交,其结果对系统是永久性的,即使发生故障也不丢失。

CAP 定理

在分布式系统中,不可能同时满足以下三点,只能选择两项:

  • Consistency(一致性):所有节点在同一时间看到相同的数据视图。
  • Availability(可用性):每次请求都能获得非错误响应。
  • Partition tolerance(分区容忍性):系统能容忍网络分区,保证继续提供服务。
对比:传统单体数据库追求 ACID;分布式系统根据业务侧重点,在 CAP 中做平衡。

1.2 分布式事务常见解决方案对比

模型原理优缺点
2PC(二阶段提交)协调者先询问所有参与者能否提交(Prepare),然后决定提交或回滚(Commit/Rollback)✅ 简单
❌ 易阻塞、单点协调者故障影响全局、性能开销大
3PC(三阶段提交)在 2PC 基础上增加“预提交”阶段(Prepare→PreCommit→Commit)✅ 减少阻塞风险
❌ 实现复杂,仍无法解决网络分区下的安全性
Saga(补偿事务)将大事务拆为若干本地事务,失败时依次执行补偿操作逆转✅ 无全局锁、无阻塞
❌ 补偿逻辑复杂、状态管理难、牵涉多方业务解耦
XSAGA基于 Saga 的扩展,结合消息队列与状态机管理分布式事务✅ 异步解耦、高可用
❌ 开发成本高,需要异步可靠消息与状态机组件
flowchart LR
  subgraph 2PC
    A[协调者: Prepare?] --> B[参与者1: OK/NO]
    A --> C[参与者2: OK/NO]
    B & C --> D[协调者: Commit/Rollback]
  end

1.3 Redis 在分布式事务体系中的定位

  1. 原子性命令

    • Redis 单条命令天然原子(如 INCR, HSET),无需额外加锁即可保证局部一致。
  2. MULTI/EXEC 事务

    • 将多条命令打包,在执行时保证中途不被其他命令插入,但不支持回滚;失败时会跳过出错命令继续。
  3. WATCH 乐观锁

    • 监控一个或多个 key,若在事务执行前有修改,整个事务会被中止
局限:Redis 自身不支持分布式事务协调,需配合应用侧逻辑或外部协调组件才能实现跨多个服务或数据源的一致性。

1.4 事务与锁:基础概念与关系

  • 事务(Transaction):逻辑上将一组操作视为一个整体,要么全部成功,要么全部回滚。
  • 锁(Lock):用于在并发场景下对某个资源或数据行加排它或共享控制,防止并发冲突。
特性事务
关注点处理多步操作的一致性控制并发对单个资源或对象的访问
实现方式协调者 + 协议(如 2PC、Saga)或数据库自带事务支持悲观锁(排它锁)、乐观锁(版本/ CAS)、分布式锁(Redis、Zookeeper)
回滚机制支持(数据库或应用需实现补偿)不支持回滚;锁只是并发控制,解锁后资源状态根据业务决定
使用场景跨服务、跨库的强一致性场景并发写场景、资源争用高的局部协调

第二章:Redis 原子操作与事务命令

2.1 MULTI/EXEC/DISCARD 四大命令详解

Redis 提供了原生的事务支持,通过以下命令组合完成:

  1. MULTI
    开始一个事务,将后续命令入队,不立即执行。
  2. EXEC
    执行事务队列中的所有命令,作为一个批次原子提交。
  3. DISCARD
    放弃事务队列中所有命令,退出事务模式。
  4. UNWATCH(或隐含于 EXEC/DISCARD 后)
    解除对所有 key 的 WATCH 监控。
Client> MULTI
OK
Client> SET user:1:name "Alice"
QUEUED
Client> INCR user:1:counter
QUEUED
Client> EXEC
1) OK
2) (integer) 1
  • 在 MULTI 与 EXEC 之间,所有写命令均返回 QUEUED 而不执行。
  • 若执行过程中某条命令出错(如语法错误),该命令会在 EXEC 时被跳过,其它命令依然执行。
  • EXEC 之后,事务队列自动清空,返回结果列表。

2.2 事务队列实现原理

内部流程简化示意:

flowchart LR
    subgraph Server
      A[Client MULTI] --> B[enter MULTI state]
      B --> C[queue commands]
      C --> D[Client EXEC]
      D --> E[execute queued commands one by one]
      E --> F[exit MULTI state]
    end
  • Redis 仅在内存中维护一个简单的命令数组,不做持久化。
  • 由于单线程模型,EXEC 阶段不会被其他客户端命令插入,保证了“原子”提交的效果。
  • 事务并不支持回滚:一旦 EXEC 开始,出错命令跳过也不影响其它操作。

2.3 WATCH 的乐观锁机制

WATCH 命令用来在事务前做乐观并发控制:

  1. 客户端 WATCH key1 key2 …,服务器会在内存中记录被监控的 key。
  2. 执行 MULTI 入队命令。
  3. 若执行 EXEC 前其他客户端对任一 watched key 执行了写操作,当前事务会失败,返回 nil
Client1> WATCH user:1:counter
OK
Client1> MULTI
OK
Client1> INCR user:1:counter
QUEUED
# 在此期间,Client2 执行 INCR user:1:counter
Client1> EXEC
(nil)             # 事务因 watched key 被修改而放弃
  • UNWATCH:可在多 key 监控后决定放弃事务前,手动取消监控。
  • WATCH+MULTI+EXEC 模式被视作“乐观锁”:假设冲突少,事务提交前无需加锁,冲突时再回退重试。

2.4 事务冲突场景与重试策略示例

在高并发场景下,WATCH 模式下冲突不可避免。常见重试模式:

import redis
r = redis.Redis()

def incr_user_counter(uid):
    key = f"user:{uid}:counter"
    while True:
        try:
            r.watch(key)
            count = int(r.get(key) or 0)
            pipe = r.pipeline()
            pipe.multi()
            pipe.set(key, count + 1)
            pipe.execute()
            break
        except redis.WatchError:
            # 发生冲突,重试
            continue
        finally:
            r.unwatch()
  • 流程

    1. WATCH 监控 key
    2. 读取当前值
    3. MULTI -> 修改 -> EXEC
    4. 若冲突(WatchError),则重试整个流程
  • 图解
sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: WATCH key
    S-->>C: OK
    C->>S: GET key
    S-->>C: value
    C->>S: MULTI
    S-->>C: OK
    C->>S: SET key newValue
    S-->>C: QUEUED
    C->>C: (some other client modifies key)
    C->>S: EXEC
    alt key changed
      S-->>C: nil (transaction aborted)
    else
      S-->>C: [OK]
    end
  • 重试注意:应设定最大重试次数或退避策略,避免活锁。

第三章:悲观锁与乐观锁在 Redis 中的实现

3.1 悲观锁概念与使用场景

悲观锁(Pessimistic Locking) 假定并发冲突随时会发生,因此,访问共享资源前先获取互斥锁,直到操作完成才释放锁,期间其他线程被阻塞或直接拒绝访问。

  • 适用场景

    • 写多读少、冲突概率高的关键资源(如库存、账户余额)。
    • 对一致性要求极高,无法容忍重试失败或脏读的业务。

3.2 Redis 分布式悲观锁(SETNX + TTL)

通过 SETNX(SET if Not eXists)命令实现基本分布式锁:

SET lock:key uuid NX PX 5000
  • NX:仅当 key 不存在时设置,保证互斥。
  • PX 5000:设置过期时间,避免死锁。

Java 示例(Jedis)

public boolean tryLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
    String result = jedis.set(lockKey, requestId, SetParams.setParams().nx().px(expireTime));
    return "OK".equals(result);
}

public boolean releaseLock(Jedis jedis, String lockKey, String requestId) {
    String luaScript =
      "if redis.call('get', KEYS[1]) == ARGV[1] then " +
      "  return redis.call('del', KEYS[1]) " +
      "else " +
      "  return 0 " +
      "end";
    Object result = jedis.eval(luaScript, Collections.singletonList(lockKey),
                               Collections.singletonList(requestId));
    return Long.valueOf(1).equals(result);
}
  • 优点:实现简单,基于 Redis 原子命令。
  • 缺点:不支持自动续期、SETNX 与 DEL 之间存在时机窗口。

3.3 乐观锁实现:基于版本号与事务重试

乐观锁(Optimistic Locking) 假定冲突少,通过版本号或时间戳检查,只有在操作前后资源未被修改才提交。

  • 实现方式:使用 Redis 的 WATCH + MULTI/EXEC
import redis
r = redis.Redis()

def update_balance(uid, delta):
    key = f"user:{uid}:balance"
    while True:
        try:
            r.watch(key)
            balance = float(r.get(key) or 0)
            new_balance = balance + delta
            pipe = r.pipeline()
            pipe.multi()
            pipe.set(key, new_balance)
            pipe.execute()
            break
        except redis.WatchError:
            # 冲突,重试
            continue
        finally:
            r.unwatch()
  • 优点:无锁等待成本,适合读多写少场景。
  • 缺点:在高并发写场景下可能频繁重试,性能下降。

3.4 代码示例:悲观锁与乐观锁对比实战

下面示例展示库存扣减场景的两种锁策略对比。

// 悲观锁方案:SETNX
public boolean decrementStockPessimistic(Jedis jedis, String productId) {
    String lockKey = "lock:stock:" + productId;
    String requestId = UUID.randomUUID().toString();
    if (!tryLock(jedis, lockKey, requestId, 3000)) {
        return false; // 获取锁失败
    }
    try {
        int stock = Integer.parseInt(jedis.get("stock:" + productId));
        if (stock <= 0) return false;
        jedis.decr("stock:" + productId);
        return true;
    } finally {
        releaseLock(jedis, lockKey, requestId);
    }
}

// 乐观锁方案:WATCH + MULTI
public boolean decrementStockOptimistic(Jedis jedis, String productId) {
    String key = "stock:" + productId;
    while (true) {
        jedis.watch(key);
        int stock = Integer.parseInt(jedis.get(key));
        if (stock <= 0) {
            jedis.unwatch();
            return false;
        }
        Transaction tx = jedis.multi();
        tx.decr(key);
        List<Object> res = tx.exec();
        if (res != null) {
            return true; // 成功
        }
        // 冲突,重试
    }
}
  • 对比

    • 悲观锁在高并发写时因为互斥性可能成为瓶颈;
    • 乐观锁则可能因冲突频繁重试而浪费 CPU 和网络资源。

第四章:RedLock 深度解析

4.1 RedLock 算法背景与设计目标

RedLock 是 Redis 创始人 Antirez 提出的分布式锁算法,旨在通过多个独立 Redis 节点协同工作,解决单节点故障时锁可能失效的问题。

  • 设计目标

    1. 安全性:获取锁后,只有持锁者才能解锁,防止误删他人锁。
    2. 可用性:即使部分 Redis 节点故障,只要大多数节点可用,仍可获取锁。
    3. 性能:锁获取、释放的延迟保持在可接受范围。

4.2 RedLock 详细流程图解

sequenceDiagram
  participant C as Client
  participant N1 as Redis1
  participant N2 as Redis2
  participant N3 as Redis3
  participant N4 as Redis4
  participant N5 as Redis5

  C->>N1: SET lock_key val NX PX ttl
  C->>N2: SET lock_key val NX PX ttl
  C->>N3: SET lock_key val NX PX ttl
  C->>N4: SET lock_key val NX PX ttl
  C->>N5: SET lock_key val NX PX ttl
  Note right of C: 如果超过半数节点成功,且<br/>总耗时 < ttl,则获取锁成功

步骤

  1. 客户端生成唯一随机值 val 作为请求标识。
  2. 按顺序向 N 个 Redis 实例发送 SET key val NX PX ttl,使用短超时保证请求不阻塞。
  3. 计算成功设置锁的节点数量 count,以及从第一台开始到最后一台花费的总时延 elapsed
  4. count >= N/2 + 1elapsed < ttl,则视为获取锁成功;否则视为失败,并向已成功节点发送 DEL key 释放锁。

4.3 实现代码剖析(Java 示例)

public class RedLock {
    private List<JedisPool> pools;
    private long ttl;
    private long acquireTimeout;

    public boolean lock(String key, String value) {
        int successCount = 0;
        long startTime = System.currentTimeMillis();
        for (JedisPool pool : pools) {
            try (Jedis jedis = pool.getResource()) {
                String resp = jedis.set(key, value, SetParams.setParams().nx().px(ttl));
                if ("OK".equals(resp)) successCount++;
            } catch (Exception e) { /* 忽略单节点故障 */ }
        }
        long elapsed = System.currentTimeMillis() - startTime;
        if (successCount >= pools.size() / 2 + 1 && elapsed < ttl) {
            return true;
        } else {
            // 释放已获取的锁
            unlock(key, value);
            return false;
        }
    }

    public void unlock(String key, String value) {
        String lua = 
          "if redis.call('get', KEYS[1]) == ARGV[1] then " +
          "  return redis.call('del', KEYS[1]) " +
          "else return 0 end";
        for (JedisPool pool : pools) {
            try (Jedis jedis = pool.getResource()) {
                jedis.eval(lua, Collections.singletonList(key),
                           Collections.singletonList(value));
            } catch (Exception e) { /* 忽略 */ }
        }
    }
}
  • 关键点

    • 使用相同 value 确保解锁安全。
    • 超时判断:若总耗时超过 ttl,即便设置足够节点,也视为失败,防止锁已过期。
    • 异常处理:忽略部分节点故障,但依赖多数节点可用。

4.4 RedLock 的安全性与争议

  • 安全性分析

    • N/2+1 节点写入成功的前提下,即使部分节点宕机,也能保留锁权。
    • 随机 value 确保只有真正持有者能解锁。
  • 争议点

    • 网络延迟波动 可能导致 elapsed < ttl 判定失效,从而出现锁重入风险。
    • 时钟漂移:RedLock 假设各个 Redis 节点时钟同步,否则 PX 过期可能不一致。
    • 社区质疑:部分专家认为单节点 SETNX + TTL 足以满足大多数分布式锁场景,RedLock 复杂度与收益不匹配。

第五章:基于 Lua 脚本的分布式锁增强

Lua 脚本在 Redis 中以“原子批处理”的方式执行,保证脚本内所有命令在一个上下文中顺序执行,不会被其他客户端命令打断。利用这一特性,可以实现更加安全、灵活的分布式锁。


5.1 Lua 原子性保证与使用场景

  • 原子执行:当你向 Redis 发送 EVAL 脚本时,服务器将整个脚本当作一个命令执行,期间不会切换到其他客户端。
  • 脚本场景

    • 自动续期(Watchdog)
    • 安全解锁(检查 value 后再 DEL)
    • 可重入锁(记录重入次数)
    • 锁队列(实现公平锁)

5.2 典型锁脚本实现(一):安全解锁

-- KEYS[1] = lockKey, ARGV[1] = ownerId
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
  • 流程

    1. GET lockKey,与持有者 ID (UUID) 做比对
    2. 匹配时才 DEL lockKey,否则返回 0
  • 效果:保证只有真正持锁者才能解锁,防止误删他人锁。

5.3 典型锁脚本实现(二):自动续期 Watchdog

当锁持有时间可能不足以完成业务逻辑时,需要“自动续期”机制,常见实现——后台定时执行脚本。

-- KEYS[1] = lockKey, ARGV[1] = ownerId, ARGV[2] = additionalTTL
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
    return 0
end
  • 在业务执行过程中,每隔 TTL/3 调用一次该脚本延长锁寿命,确保业务完成前锁不被过期。

5.4 可重入锁脚本示例

可重入锁允许同一个客户端多次加锁,每次加锁仅需增加内部计数,释放时再递减,直至 0 才真正释放。

-- KEYS[1]=lockKey, ARGV[1]=ownerId, ARGV[2]=ttl
local entry = redis.call("HGETALL", KEYS[1])
if next(entry) == nil then
    -- 首次加锁,创建 hash: { owner=ownerId, count=1 }
    redis.call("HMSET", KEYS[1], "owner", ARGV[1], "count", 1)
    redis.call("PEXPIRE", KEYS[1], ARGV[2])
    return 1
else
    local storedOwner = entry[2]
    if storedOwner == ARGV[1] then
        -- 重入:计数+1,并续期
        local cnt = tonumber(entry[4]) + 1
        redis.call("HSET", KEYS[1], "count", cnt)
        redis.call("PEXPIRE", KEYS[1], ARGV[2])
        return cnt
    else
        return 0
    end
end
  • 存储结构:使用 Hash 记录 ownercount
  • 释放脚本

    -- KEYS[1]=lockKey, ARGV[1]=ownerId
    local entry = redis.call("HGETALL", KEYS[1])
    if next(entry) == nil then
        return 0
    end
    if entry[2] == ARGV[1] then
        local cnt = tonumber(entry[4]) - 1
        if cnt > 0 then
            redis.call("HSET", KEYS[1], "count", cnt)
            return cnt
        else
            return redis.call("DEL", KEYS[1])
        end
    end
    return 0

5.5 性能与安全性评估

特性优点缺点
原子脚本执行无需往返多条命令,网络延迟低;执行期间不会被打断Lua 脚本会阻塞主线程
安全解锁避免 SETNX+DEL 的竞态脚本过长可能影响性能
可重入支持业务调用可安全重入、无锁重获失败状态存储更复杂,Hash 占用更多内存
自动续期保障长时业务场景的锁稳定性需要客户端定时心跳,复杂度提升

第六章:分布式事务模式在 Redis 上的实践

在微服务与分布式架构中,跨服务或跨数据库的一致性需求日益突出。传统的全局事务(如 2PC)在性能与可用性方面存在瓶颈。基于 Redis、消息队列以及应用协议的分布式事务模式成为主流选择。本章聚焦两大常见模式:Saga 与 TCC,并探讨 XSAGA 在 Redis 场景下的实现思路。


6.1 Saga 模式基础与 Redis 实现思路

Saga 模式将一个大事务拆分为一系列本地事务(step)与相应的补偿事务(compensation)。各服务按顺序执行本地事务,若中途某步失败,依次调用前面步骤的补偿事务,达到数据最终一致性。

步骤示意

  1. 执行 T1;若成功,推进至 T2,否则执行 C1。
  2. 执行 T2;若成功,推进至 T3,否则依次执行 C2、C1。
sequenceDiagram
    Client->>OrderService: CreateOrder()
    OrderService->>InventoryService: ReserveStock()
    alt ReserveStock OK
        OrderService->>PaymentService: ReservePayment()
        alt ReservePayment OK
            OrderService->>Client: Success
        else ReservePayment FAIL
            OrderService->>InventoryService: CompensateReleaseStock()
            OrderService->>Client: Failure
        end
    else ReserveStock FAIL
        OrderService->>Client: Failure
    end

Redis 实现要点

  • 状态存储:使用 Redis Hash 存储 Saga 状态:

    HSET saga:{sagaId} step T1 status PENDING
  • 可靠调度:结合消息队列(如 RabbitMQ)确保命令至少执行一次。
  • 补偿执行:若下游失败,由协调者发送补偿消息,消费者触发补偿逻辑。
  • 超时处理:利用 Redis TTL 与 keyspace notifications 触发超时回滚。

6.2 TCC(Try-Confirm-Cancel)模式与 Redis

TCC模式将事务分为三步:

  1. Try:预留资源或执行业务预处理。
  2. Confirm:确认事务,正式扣减或提交。
  3. Cancel:取消预留,回滚资源。

典型流程

sequenceDiagram
    Client->>ServiceA: tryA()
    ServiceA->>ServiceB: tryB()
    alt All try OK
        ServiceA->>ServiceB: confirmB()
        ServiceA->>Client: confirmA()
    else Any try FAIL
        ServiceA->>ServiceB: cancelB()
        ServiceA->>Client: cancelA()
    end

Redis 协调示例

  • 在 Try 阶段写入预留 key,并设置 TTL:

    SET reserved:order:{orderId} userId NX PX 60000
  • Confirm 成功后,DEL 该 key;Cancel 失败后,同样 DEL 并执行回滚逻辑。
  • 优点:明确的三段式接口,易于补偿管理。
  • 缺点:需实现 Try、Confirm、Cancel 三套接口,开发成本高。

6.3 XSAGA 模式示例:结合消息队列与 Redis

XSAGA 是 Saga 的扩展,使用状态机 + 可靠消息实现多事务编排,典型平台如 Apache ServiceComb Pack。

核心组件

  • 事务协调者:控制 Saga 执行流程,发布各 Step 消息。
  • 消息中间件:保证消息可靠投递与重试。
  • 参与者:消费消息,执行本地事务并更新状态。
  • Redis 存储:缓存 Saga 全局状态、Step 状态与补偿函数路由。

Redis 存储设计

HSET xsaga:{sagaId}:status globalState "INIT"
HSET xsaga:{sagaId}:steps step1 "PENDING"
HSET xsaga:{sagaId}:steps step2 "PENDING"
  • 消费者在成功后:

    HSET xsaga:{sagaId}:steps step1 "SUCCESS"
  • 失败时:

    HSET xsaga:{sagaId}:steps step1 "FAIL"
    RPUSH xsaga:{sagaId}:compensate compensateStep1
  • 协调者根据状态机读取 Redis 并发布下一个命令或补偿命令。

6.4 实战案例:电商下单跨服务事务

以“创建订单 → 扣减库存 → 扣减余额”场景展示 Saga 模式实战。

  1. 创建订单:OrderService 记录订单信息,并保存状态至 Redis:

    HSET saga:1001 step:createOrder "SUCCESS"
  2. 扣减库存:InventoryService 订阅 ReserveStock 消息,执行并更新 Redis:

    HSET saga:1001 step:reserveStock "SUCCESS"
  3. 扣减余额:PaymentService 订阅 ReservePayment 消息,执行并更新 Redis。
  4. 确认:协商者检查所有 step 状态为 SUCCESS 后,发布 Confirm 消息至各服务。
  5. 补偿:若任一 step FAIL,顺序执行补偿,如库存回滚、余额退回。


第七章:锁失效、超时与防死锁策略

在分布式锁场景中,锁过期、超时、死锁是常见挑战,本章深入分析并提供解决方案。

7.1 锁过期失效场景分析

  • 业务处理超时:持有者业务执行超过锁 TTL,锁自动过期,其他客户端可能抢占,导致并发操作错误。
  • 解决:自动续期或延长 TTL。

7.2 Watchdog 自动续期机制

基于 Redisson 的 Watchdog:若客户端在锁到期前仍在运行,自动为锁续期。

  • 默认超时时间:30 秒。
  • 调用 lock() 后,后台定时线程周期性发送 PEXPIRE 脚本延长 TTL。
RLock lock = redisson.getLock("resource");
lock.lock();  // 自动续期
try {
    // 业务代码
} finally {
    lock.unlock();
}

7.3 防止死锁的最佳实践

  • 合理设置 TTL:结合业务最坏执行时间估算。
  • 使用可重入锁:减少同线程重复加锁引发的死锁。
  • 请求超时机制:客户端设定最大等待时间,尝试失败后放弃或降级。

7.4 代码示例:可靠锁释放与续期

String luaRenew = 
  "if redis.call('get', KEYS[1]) == ARGV[1] then " +
  "  return redis.call('pexpire', KEYS[1], ARGV[2]) " +
  "else return 0 end";

// 定时续期线程
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
    jedis.eval(luaRenew, List.of(lockKey), List.of(requestId, "5000"));
}, 5, 5, TimeUnit.SECONDS);

第八章:高可用与锁的容灾设计

锁机制在 Redis Sentinel 或 Cluster 环境下,需考虑主从切换与分片容灾。

8.1 单实例锁的局限性

  • 节点宕机,锁丢失;
  • 尝试获取锁时可能连接失败。

8.2 Sentinel/Cluster 环境下的锁可靠性

  • Sentinel:自动主从切换,客户端需使用 Sentinel Pool;锁脚本需在所有节点上统一执行。
  • Cluster:锁 key 分布到某个 slot,需确保所有脚本与客户端指向正确节点。

8.3 主从切换与锁恢复

  • 切换窗口期,锁可能在新主上不存在;
  • 解决:使用 RedLock 多节点算法,或在多个实例上冗余存储锁。

8.4 容灾演练:故障切换场景下的锁安全

  • 模拟主节点挂掉,检查 RedLock 是否仍能获取大多数节点锁;
  • Sentinel 切换后,验证脚本与客户端自动连接。

第九章:锁与性能优化

9.1 锁的粒度与并发影响

  • 粗粒度锁:简单但并发性能差;
  • 细粒度锁:提高并发但管理复杂。

9.2 限流、降级与锁结合

  • 使用 Token Bucket 限流先前置;
  • 锁失败时可降级返回缓存或默认值。
if (!rateLimiter.tryAcquire()) return fallback();
if (lock.tryLock()) { ... }

9.3 大并发场景下的锁性能测试

  • 利用 JMeter 或 custom thread pool 对比 SETNX、RedLock、Redisson 性能;
  • 指标:成功率、平均延迟、吞吐量。

9.4 环境搭建与压力测试脚本

# JMeter 测试脚本示例,设置并发 1000
jmeter -n -t lock_test.jmx -Jthreads=1000

第十章:分布式事务监控与故障排查

10.1 监控锁获取、释放与超时指标

  • 收集 lock:acquire:successlock:acquire:faillock:release 计数;
  • Prometheus + Grafana 可视化。

10.2 事务执行链路跟踪

  • 使用 Sleuth 或 Zipkin,链路中记录 Redis 脚本调用。
  • 在链路报文中标注锁 key 与 requestId。

10.3 常见故障案例剖析

  • 锁未释放:可能因脚本错误或网络中断;
  • 续期失败:脚本未执行,TTL 到期。

10.4 报警策略与实践

  • lock:acquire:fail 超阈值报警;
  • 未释放锁告警(探测 key 长时间存在)。

第十一章:生产级锁框架与封装

11.1 Spring-Redis 分布式锁实践

@Component
public class RedisLockService {
    @Autowired private StringRedisTemplate redis;
    public boolean lock(String key, String id, long ttl) { ... }
    public boolean unlock(String key, String id) { ... }
}

11.2 Redisson、Lettuce 等客户端对比

框架特性
Redisson高级特性(可重入、延迟队列)
Lettuce轻量、高性能、响应式
Jedis简单、成熟

11.3 自研锁框架关键模块设计

  • LockManager 管理不同类型锁;
  • RetryPolicy 定制重试逻辑;
  • MetricsCollector 上报监控。

11.4 发布与灰度、回滚方案

  • 分组灰度:逐步打开分布式锁功能;
  • 回滚:配置中心开关、客户端降级到本地锁。

2025-07-03

第一章:Redis 内存管理概览

1.1 内存管理的重要性

在高并发系统中,内存是最宝贵的资源之一。Redis 作为内存数据库,其性能、可用性、以及数据一致性都高度依赖于底层内存管理策略。合理的内存分配与回收,不仅能保障系统平稳运行,还能避免内存碎片、内存泄漏等隐患,从而提升整体吞吐量与系统稳定性。

1.2 Redis 内存模型简介

Redis 使用单线程事件循环模型,所有命令请求都在同一线程内执行。

  • 对象存储:所有数据均以 robj(RedisObject)形式存在,包含类型、编码、值指针等元信息。
  • 内存分配器:默认使用 jemalloc,也支持 tcmalloc 或 libc malloc,通过 malloc_stats 调优。
  • 过期回收:通过惰性删除与定期删除两种策略协同工作,避免对延迟和性能造成大幅波动。

1.3 jemalloc、tcmalloc 与 libc malloc 对比

特性jemalloctcmalloclibc malloc
内存碎片率较低适中较高
线程局部缓存
性能优秀良好一般
可观测性支持 prof 和 stats支持部分调试不支持
// 查看 jemalloc 统计信息
INFO malloc-stats

1.4 监控与诊断内存使用的必备指标

  • used_memory:客户端可见分配内存总量
  • used_memory_rss:操作系统进程实际占用内存
  • mem_fragmentation_ratio:内存碎片率 = used_memory_rss / used_memory
  • used_memory_peak:历史峰值
  • allocator_allocated:分配器分配给 Redis 的总内存

第二章:内存分配与对象编码

2.1 Redis 对象(robj)的内存布局

RedisObject (robj) 是 Redis 中所有数据的基础结构:

typedef struct redisObject {
    unsigned type:4;        // 类型,如 string, list, set
    unsigned encoding:4;    // 编码方式,如 RAW, HT, ZIPLIST
    unsigned lru:LRU_BITS;  // LRU 时钟
    int refcount;           // 引用计数
    void *ptr;              // 指向具体值的指针
} robj;
  • type 标识数据类型
  • encoding 决定值的存储方式
  • refcount 支撑对象复用与内存释放

2.2 字符串对象的 SDS 结构详解

Simple Dynamic String (sds) 是 Redis 字符串的核心实现,提供 O(1) 的长度获取和缓冲区前置空间:

struct sdshdr {
    int len;        // 已使用长度
    int free;       // 可用剩余空间
    char buf[];     // 字符串内容
};
  • buf 末尾添加 NUL 以兼容 C 风格字符串
  • lenfree 保证追加拼接高效

2.3 列表、哈希、集合、ZSet 在内存中的编码策略

  • 列表(list):小量元素使用 ziplist(连续内存),大元素或超过阈值转为 linkedlist
  • 哈希(hash):小型哈希使用 ziplist,大型使用 dict
  • 集合(set):元素小、基数低时使用 intset,高基数使用 hash table
  • 有序集合(zset):由 skiplist + dict 组合实现
类型小对象编码大对象编码
listziplistlinkedlist
hashziplistdict
setintsetdict
zsetziplistskiplist

2.4 内存对齐与碎片

  • 内存块按 8 字节对齐
  • jemalloc 的 arena 分配策略减少碎片
  • 内存碎片会导致 used_memory_rss 大于 used_memory,需定期观察

第三章:内存占用监控与诊断工具

3.1 INFO memory 命令详解

Redis 提供 INFO memory 命令,可查看关键内存指标:

127.0.0.1:6379> INFO memory
# Memory
used_memory:1024000
used_memory_human:1000.00K
used_memory_rss:2048000
used_memory_rss_human:2.00M
used_memory_peak:3072000
used_memory_peak_human:3.00M
total_system_memory:16777216
total_system_memory_human:16.00M
used_memory_lua:37888
mem_fragmentation_ratio:2.00
mem_allocator:jemalloc-5.1.0
  • used_memory:分配给 Redis 实例的内存总量
  • used_memory_rss:操作系统层面实际占用内存,包括碎片
  • used_memory_peak:历史最高内存占用
  • mem_fragmentation_ratio:内存碎片率 = used_memory_rss / used_memory
  • mem_allocator:当前使用的内存分配器

3.2 redis-stat、redis-mem-keys 等开源工具

  • redis-stat:实时监控 Redis 命令 QPS、内存等
  • redis-mem-keys:扫描 Redis 实例,展示各 key 占用内存排行
  • rbtools:多实例管理与故障诊断
# 安装 redis-mem-keys
pip install redis-mem-keys
redis-mem-keys --host 127.0.0.1 --port 6379 --top 20

3.3 jemalloc 内置统计(prof)

对于 jemalloc,开启配置后可使用 malloc_conf 查看 arena 信息:

# 在启动 redis.conf 中添加
jemalloc-bg-thread:yes
malloc_conf:"background_thread:true,lg_chunk:20"

# 然后通过 INFO malloc-stats 查看
127.0.0.1:6379> INFO malloc-stats

3.4 实战:定位大 key 与内存峰值

使用 redis-cli --bigkeys 查找大 key:

redis-cli --bigkeys
# Sample output:
# Biggest string is 15.00K bytes
# Biggest list is 25 elements
# Biggest hash is 100 fields

结合 used_memory 峰值,对比内存使用曲线,定位临时异常或泄漏。


第四章:内存淘汰策略全解析

4.1 maxmemory 与 maxmemory-policy 配置

redis.conf 中设定:

maxmemory 2gb
maxmemory-policy allkeys-lru
  • maxmemory:Redis 实例的内存上限
  • maxmemory-policy:超过上限后的淘汰策略,常见选项详见下表

4.2 常用淘汰策略对比

策略含义适用场景
noeviction达到内存上限后,写操作返回错误业务本身可容忍写失败
allkeys-lru对所有 key 使用 LRU 淘汰一般缓存场景,热点隔离
volatile-lru只对设置了 TTL 的 key 使用 LRU 淘汰稳定数据需保留,无 TTL 不淘汰
allkeys-random删除任意 key对缓存命中无严格要求
volatile-random删除任意设置了 TTL 的 key仅淘汰部分临时数据
volatile-ttl删除 TTL 最近要过期的 key关键数据保活,先删快过期数据

4.3 noeviction 与 volatile-ttl 策略

  • noeviction:生产中谨慎使用,一旦超过内存,客户端写入失败,需做好容错
  • volatile-ttl:优先删除临近过期数据,保证长期热点数据存活

4.4 不同策略的适用场景总结

  • 热点缓存allkeys-lru
  • 短期数据volatile-ttl
  • 随机淘汰:对时序要求不高的实时数据,可以用 allkeys-random

第五章:LRU/LFU 算法实现深度剖析

5.1 经典 LRU 原理与近似值算法

  • 完全 LRU:维护双向链表,每次访问将节点移到表头,淘汰表尾节点。
  • 近似 LRU:使用 Redis 中的样本采样,默认 activeExpireCycle 每次检查一定数量的随机样本,减少复杂度。

5.2 Redis 6+ 的 LFU(TinyLFU)实现

  • LFU 原理:维护访问计数器,淘汰访问频次最低的 key。
  • Redis TinyLFU:使用 8 位访问频次(LOG_COUNTER),结合保护概率 P,命中后增量更新计数。

5.3 LRU 与 LFU 性能比对及调优

  • LRU 更适合短时热点数据,LFU 适合长期热点。
  • 可通过 maxmemory-samples(样本数)和 lfu-log-factor 调整性能。

5.4 代码示例:模拟 LRU/LFU 淘汰

# Python 模拟 LRU 缓存
class LRUCache:
    def __init__(self, capacity):
        self.cache = {}
        self.order = []
        self.capacity = capacity

    def get(self, key):
        if key in self.cache:
            self.order.remove(key)
            self.order.insert(0, key)
            return self.cache[key]
        return None

    def put(self, key, value):
        if key in self.cache:
            self.order.remove(key)
        elif len(self.cache) >= self.capacity:
            old = self.order.pop()
            del self.cache[old]
        self.cache[key] = value
        self.order.insert(0, key)

第六章:主动回收与惰性回收机制

6.1 惰性删除与定期删除原理

  • 惰性删除:访问时遇到过期 key 才删除。
  • 定期删除:每隔 hz 秒,扫描随机样本检测并删除过期 key。

6.2 active-expire 机制细节解析

Redis 的 active-expire 在每个周期最多检查 active-expire-cycle 个样本,防止阻塞。

6.3 惰性回收对大 key、过期 key 的处理

  • 大 key 删除可能阻塞,Redis 4.0+ 支持 lazy freeing(异步释放大对象)。
  • 可通过 lazyfree-lazy-expirelazyfree-lazy-server-del 配置开启。

6.4 实战:调优主动回收频率

# redis.conf
hz 10
active-expire-effort 10
lazyfree-lazy-expire yes
  • hz 调低至 10,减少定期扫描开销
  • active-expire-effort 提高,对应增加定期删除样本

第七章:内存碎片与 Defragmentation

7.1 内存碎片的成因

  • 内存对齐和小/大对象混合分配导致空洞
  • 长生命周期对象与短生命周期对象交叉

7.2 jemalloc 的 defragmentation 支持

  • mallctl 接口触发内存整理
  • Redis 通过 MEMORY PURGE 命令清理空闲页

7.3 Redis 4.0+ 内存碎片自动整理

  • 默认开启 activedefrag 功能
  • 可配置 active-defrag-threshold-lower-upper

7.4 案例:长期运行实例的碎片率诊断与修复

# 查看碎片率
redis-cli INFO memory | grep mem_fragmentation_ratio

# 触发手动整理
redis-cli MEMORY PURGE

第八章:大 Key 响应与内存保护

8.1 大 key 的类型及危害

  • String、List、Hash、Set、ZSet 大对象
  • 阻塞命令(如 LRANGE 0 -1)引发阻塞

8.2 客户端命令慢查与内存暴增

  • 使用 SLOWLOG 识别慢命令
  • 建议 SCAN 替代 KEYS

8.3 大对象拆分与批量处理实战

# Python 批量删除大 List
while True:
    items = redis.lpop('biglist', 100)
    if not items:
        break

8.4 代码示例:SCAN + pipeline 分批释放

# 分批删除 hash 大 key
cursor = 0
while True:
    cursor, keys = redis.hscan('bighash', cursor, count=1000)
    if not keys:
        break
    pipe = redis.pipeline()
    for key in keys:
        pipe.hdel('bighash', key)
    pipe.execute()

第九章:内存热点数据预警与防护

9.1 内存使用高峰检测

  • 基于 used_memory_peak 设置报警

9.2 热点 key、快速增长 key 监控

  • redis-cli --hotkeys
  • Prometheus 热 key 导出

9.3 过期键集中失效的预警

  • 统计 expired_keys 突增
  • 与命中率综合判断

9.4 实战:基于 keyspace notifications 的告警脚本

redis-cli psubscribe '__keyevent@0__:expired' | while read line; do echo "Expired: $line" | mail -s "Redis Expire Alert" ops@example.com; done

第十章:Redis Cluster & Sentinel 下的内存管理

10.1 集群节点内存均衡策略

  • 监控各主节点 used_memory,均衡 slot 分布

10.2 slot 迁移与内存峰值避免

  • cluster reshard 调整 slot 时限流

10.3 Sentinel 故障切换中的内存恢复

  • 重建从节点时需避免全量同步
  • 建议使用 RDB 快照增量同步

10.4 多机房容灾与冷备份

  • 定期 RDB/AOF 备份到冷存储
  • 跨机房恢复演练

第十一章:生产环境内存优化实战

11.1 配置最佳实践汇总

  • maxmemory-policy 选择 volatile-lru
  • lazyfree-lazy-expire 开启
  • activedefrag 根据碎片率开启

11.2 内存回收参数调优示例

# redis.conf 示例
maxmemory 4gb
maxmemory-policy volatile-lru
lazyfree-lazy-expire yes
activedefrag yes
active-defrag-threshold-lower 10
active-defrag-threshold-upper 100

11.3 从 1GB 到 1TB:规模化部署经验

  • 小集群:3 主 3 从,开启持久化
  • 大集群:分片 + Redis Cluster,监控与报警必备

11.4 企业级监控预警体系落地

  • Prometheus + Grafana + Alertmanager
  • 定制报警规则:高碎片率、低命中率、内存接近上限

第十二章:面试题与知识点速查

12.1 高频面试问答

  • Redis 内存分配器有哪些?
  • LRU 与 LFU 区别?
  • 惰性删除与主动删除原理?

12.2 经典场景设计题

设计一个系统,需缓存大量临时会话数据,要求低延迟与高并发,并且支持自动失效与快速释放内存。

12.3 关键命令与配置速查表

命令/配置说明
INFO memory查看内存使用情况
MEMORY PURGE清理空闲内存页
maxmemory-policy内存淘汰策略
lazyfree-lazy-expire异步删除过期 key
activedefrag内存碎片整理

12.4 延伸阅读与开源项目推荐

2025-07-03

第一章:缓存体系全景与Redis核心角色

1.1 为什么缓存是高并发系统不可或缺的组件?

在现代分布式系统中,缓存已经不再是“可选优化”,而是系统性能、吞吐量、响应延迟的核心支柱

  • 提升性能:热点数据直接命中缓存,访问延迟从毫秒级降低至微秒级。
  • 减轻数据库压力:避免频繁 IO,降低写入冲突。
  • 应对突发高并发:缓存是系统抗压的第一道防线。

1.2 Redis在缓存中的核心优势

特性说明
极致性能单线程模型,QPS 可达 10w+
数据结构丰富支持 String、List、Set、Hash、ZSet
天然持久化RDB/AOF 支持
支持高可用Sentinel、Cluster
支持分布式锁SETNX、RedLock

1.3 缓存问题的“病根”与分类

缓存虽好,但如果管理不当,常见以下三大类问题:

问题类型触发条件危害
缓存穿透请求的数据缓存与数据库都不存在直接穿透数据库,大量查询压力
缓存击穿热点 key 过期瞬间被并发请求击穿大量并发直接打到数据库
缓存雪崩大量 key 同时过期,或Redis集群不可用瞬间所有请求打爆后端

1.4 缓存问题三件套图解

          ┌──────────────┐
          │ Client       │
          └────┬─────────┘
               ▼
         ┌──────────────┐
         │  Redis 缓存层 │
         └────┬─────────┘
        miss ▼
      ┌──────────────┐
      │  MySQL/Postgre│
      └──────────────┘

穿透:客户端请求非法ID,缓存和DB都miss  
击穿:key刚失效,瞬间大量并发打到DB  
雪崩:缓存层整体崩溃或大批量key同时失效

第二章:缓存穿透详解

2.1 概念定义

缓存穿透指的是客户端请求数据库和缓存中都不存在的key,由于缓存没有命中,每次请求都打到数据库,导致数据库压力激增。

2.2 穿透场景复现

示例:客户端请求不存在的用户 ID(如 -1

public User getUser(Long id) {
    String cacheKey = "user:" + id;
    User user = redis.get(cacheKey);
    if (user != null) return user;
    
    user = db.queryUser(id);  // 如果 id 不存在,这里返回 null
    if (user != null) {
        redis.set(cacheKey, user, 3600);
    }
    return user;
}

如果大量恶意请求访问 user:-1,此代码将不断访问数据库!


2.3 产生原因分析

  • 用户请求非法 ID(如负数、随机 UUID)
  • 缺乏参数校验
  • 没有缓存空值
  • 黑产刷接口绕过缓存层

2.4 穿透图解

 Client ——> Redis ——miss——> DB ——return null
         ↑                         ↓
         ↑——————————(没有缓存空值)——————————↑

2.5 缓存穿透解决方案

✅ 方法一:缓存空对象

if (user == null) {
    redis.set(cacheKey, "", 300); // 缓存空值,短 TTL
}
缺点:容易污染缓存,适合低频查询接口。

✅ 方法二:布隆过滤器(推荐)

  • 初始化阶段将所有合法ID添加至布隆过滤器
  • 请求前先判断是否存在
// 初始化阶段
bloomFilter.put(10001L);

// 查询阶段
if (!bloomFilter.mightContain(id)) {
    return null;
}
Redis 中可结合 RedisBloom 模块使用

✅ 方法三:参数合法性校验

if (id <= 0) return null;

第三章:缓存雪崩详解

3.1 什么是缓存雪崩?

指大量缓存 Key 同时失效,导致所有请求直接访问数据库,或 Redis 实例宕机后导致后端承压甚至宕机。


3.2 场景演示

// 设置缓存时使用固定TTL
redis.set("product:1", product, 3600);
redis.set("product:2", product, 3600);
redis.set("product:3", product, 3600);
当 3600s 后这些 key 全部过期,大量请求将同时穿透缓存。

3.3 雪崩图解

   大量缓存key失效
          ▼
    Redis层命中率骤降
          ▼
     数据库 QPS 爆炸
          ▼
       系统崩溃

3.4 缓存雪崩防护策略

✅ 随机过期时间

int ttl = 3600 + new Random().nextInt(600); // 1~10分钟偏移
redis.set(key, value, ttl);

✅ 多级缓存策略(本地缓存 + Redis)

  • 一级缓存:Caffeine/Guava
  • 二级缓存:Redis
  • 第三级:数据库
// 查询顺序:local -> redis -> db

✅ 熔断/限流/降级

结合 Hystrix/Sentinel 对 Redis 异常进行降级兜底。


✅ 异步预热 + 主动刷新

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
    refreshHotKeys();
}, 0, 10, TimeUnit.MINUTES);

第四章:缓存击穿深度解析与实战应对


4.1 什么是缓存击穿?

缓存击穿(Cache Breakdown) 指的是:

某一个热点 Key 在某一时刻突然过期,大量请求并发访问这个 Key 时,发现缓存已过期,全部落到数据库上查询,导致系统瞬间压力飙升,甚至出现“雪崩式击穿”。

4.2 击穿 vs 雪崩 vs 穿透区别

问题类型对象成因危害
穿透不存在的 Key缓存和 DB 都查不到每次都访问数据库
雪崩大量 Key大批缓存同时过期 / Redis崩溃大量请求直达数据库
击穿热点单 Key缓存恰好过期,瞬时高并发访问大量请求集中落入数据库压力爆表

4.3 场景还原(代码示例)

假设一个热点商品详情页(例如活动页 banner ID 为 10001):

public Banner getBanner(long id) {
    String cacheKey = "banner:" + id;
    Banner banner = redis.get(cacheKey);

    if (banner != null) return banner;

    banner = db.queryBanner(id);
    if (banner != null) {
        redis.set(cacheKey, banner, 60); // 设置 60s 缓存
    }
    return banner;
}
如果这段时间正好是 高并发秒杀活动开始前 5 秒,正值用户大量涌入访问页面,缓存 TTL 恰巧过期 —— 所有请求直接穿透落入 DB,引发 缓存击穿

4.4 图解缓存击穿

  缓存中 key = “banner:10001” 正好过期
           ▼
  多个用户同时请求此 key
           ▼
  Redis 全部 miss,直接穿透
           ▼
  所有请求查询 DB,系统资源暴涨
Client1 ─┐
Client2 ─┴──► Redis (Miss) ─► DB
Client3 ─┘

4.5 缓存击穿常见场景

场景描述
秒杀商品详情页商品信息查询量极高,缓存失效后容易并发打 DB
热门推荐数据类似“今日热榜”、“最新视频”等,属于短时高热缓存数据
实时数据缓存缓存设为短 TTL,需要高频更新
用户登录态(短期有效)Session 失效时并发访问,易触发击穿

4.6 击穿防护策略

✅ 方案一:互斥锁(推荐)

对某个 key 的缓存构建操作加锁,防止并发构建重复查询数据库。
String lockKey = "lock:banner:" + id;
boolean locked = redis.setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);

if (locked) {
    try {
        Banner banner = db.queryBanner(id);
        redis.set("banner:" + id, banner, 60);
    } finally {
        redis.delete(lockKey);
    }
} else {
    Thread.sleep(50); // 等待其他线程构建缓存后重试
    return getBanner(id); // 递归重试
}
Redis SETNX 是加锁核心,避免多线程同时构建缓存。

✅ 方案二:逻辑过期 + 异步刷新(热点数据适用)

逻辑上设置过期时间,但物理上仍保留旧值。由后台线程定期刷新热点缓存。

{
  "data": {...},
  "expireTime": "2025-07-03T15:00:00Z"
}
  • 客户端每次读取 expireTime,若当前时间超出则触发异步更新线程刷新缓存。
if (now().isAfter(data.expireTime)) {
    // 异步刷新缓存数据,当前线程继续使用旧值
}

✅ 方案三:缓存永不过期 + 定时刷新

// 设置为永久 TTL
redis.set("banner:10001", data);

// 每隔 X 分钟由调度线程刷新缓存
@Scheduled(cron = "0 */5 * * * ?")
public void refreshHotBanner() {
    Banner banner = db.queryBanner(10001);
    redis.set("banner:10001", banner);
}

✅ 方案四:本地缓存兜底

  • 使用 Guava / Caffeine 实现本地 LRU 缓存机制
  • Redis 失效时快速兜底(适合小容量热点数据)
LoadingCache<String, Banner> localCache = CacheBuilder.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .maximumSize(1000)
    .build(key -> db.queryBanner(Long.parseLong(key)));

4.7 防护策略对比分析表

方案原理适用场景缺点
互斥锁SETNX 防止并发中低并发场景存在短暂等待
逻辑过期 + 异步刷新数据中标记过期时间高并发热点 key数据可能短暂过期
永不过期 + 定时刷新定时主动更新一致性要求低数据延迟大
本地缓存兜底JVM 内存快速命中热点数据小JVM 重启或更新需同步策略

4.8 实战案例:用户信息缓存击穿防护

public User getUserById(Long userId) {
    String key = "user:" + userId;
    String lockKey = "lock:" + key;
    
    String cached = redis.get(key);
    if (cached != null) return deserialize(cached);
    
    if (redis.setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS)) {
        try {
            User user = db.queryUser(userId);
            redis.set(key, serialize(user), 3600);
            return user;
        } finally {
            redis.delete(lockKey);
        }
    } else {
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {}
        return getUserById(userId); // 重试
    }
}

4.9 面试典型问题讲解

Q:如何解决 Redis 缓存击穿问题?

答: 常用方式是为热点 Key 加互斥锁防止缓存重建并发访问;或使用逻辑过期 + 异步刷新方案实现数据容忍性;高并发场景建议组合多级缓存策略防止单点故障。


第五章:多级缓存架构与数据一致性机制实战

这一章节将深入剖析缓存系统在真实业务中如何设计为多级缓存架构(L1+L2+DB),并重点解决实际开发中常见的缓存一致性、更新延迟、双写失效等问题


5.1 为什么需要多级缓存架构?

5.1.1 单级缓存的局限性

  • 如果仅使用 Redis:

    • 网络访问成本仍然高于本地访问
    • 遇到 Redis 宕机或波动,缓存整体不可用
    • 缓存刷新时会出现抖动或击穿

5.1.2 多级缓存的优势

缓存级别描述优点
一级缓存本地缓存(如 Caffeine)访问快,读写成本低
二级缓存Redis 分布式缓存容量大、集群支撑能力强
第三级后端数据库最终数据源,写一致性保障

5.1.3 多级缓存系统架构图

             ┌────────────────────────────┐
             │        Application         │
             └────────────┬───────────────┘
                          ▼
                ┌───────────────────┐
                │  L1: 本地缓存      │ ← Caffeine/Guava (TTL短)
                └────────┬──────────┘
                         ▼
                ┌───────────────────┐
                │  L2: Redis缓存层   │ ← 分布式缓存 (TTL长)
                └────────┬──────────┘
                         ▼
                ┌───────────────────┐
                │     DB持久层      │ ← MySQL / PostgreSQL
                └───────────────────┘

5.2 多级缓存代码实践(Java)

5.2.1 使用 Caffeine + Redis 的组合模式

LoadingCache<String, User> localCache = Caffeine.newBuilder()
    .expireAfterWrite(2, TimeUnit.MINUTES)
    .maximumSize(1000)
    .build(key -> {
        // 若本地未命中,则查询 Redis
        String json = redis.get(key);
        if (json != null) {
            return JSON.parseObject(json, User.class);
        }

        // 再次未命中,则查询数据库
        User user = db.queryUser(Long.parseLong(key.split(":")[1]));
        redis.set(key, JSON.toJSONString(user), 10 * 60); // 10分钟缓存
        return user;
    });

✅ 说明:

  • 本地缓存:2分钟,适合短期热点命中
  • Redis 缓存:10分钟,作为统一缓存层支撑大量请求
  • DB:作为最终数据源,仅在两层缓存都失效后访问

5.3 缓存一致性问题与挑战

5.3.1 常见问题场景

场景一:更新数据库后忘记更新缓存

user.setAge(30);
db.update(user);
// ❌ 忘记更新 Redis

场景二:先更新缓存,再更新数据库,结果失败

redis.set("user:123", user);
db.update(user); // 此处失败,缓存已脏

5.4 缓存一致性更新策略

✅ 5.4.1 推荐策略一:更新数据库 → 删除缓存

db.update(user); // ✅ 先更新数据库
redis.delete("user:" + user.getId()); // ✅ 后删除缓存
延迟一段时间后用户访问缓存 miss,重新从数据库加载

✅ 5.4.2 延迟双删机制(高并发安全型)

db.update(user);                      // 第一次删除缓存
redis.delete("user:" + user.getId());

Thread.sleep(500);                    // 短暂等待(让并发请求构建缓存)
redis.delete("user:" + user.getId()); // 第二次删除兜底
优点:防止并发请求在第一次删除后又提前构建新缓存,第二次删除保证脏数据清理。

✅ 5.4.3 读写分离设计:写请求不使用缓存

// 读:从缓存查找用户
public User getUser(id) {
    // 优先使用 Caffeine -> Redis -> DB
}

// 写:只更新数据库 + 删除缓存,不写入缓存
public void updateUser(User user) {
    db.update(user);
    redis.delete("user:" + user.getId());
    localCache.invalidate("user:" + user.getId());
}

5.5 高并发场景下的数据一致性问题详解

5.5.1 问题:读写并发 + 延迟写成功 → 缓存脏数据

  • 请求A:删除缓存 → 更新数据库(慢)
  • 请求B:并发访问,发现缓存为空,访问数据库旧数据 → 重建缓存(错误)
  • 请求A 继续 → 数据库更新完成,但缓存被错误重建

5.5.2 解决方案:逻辑过期 / 异步延迟删除 / 分布式锁保护


5.6 分布式缓存一致性实战:Redis Keyspace Notification + 消息队列

5.6.1 Redis Key 事件通知(keyspace)

开启配置:

notify-keyspace-events Egx

监听 key 过期:

PSUBSCRIBE __keyevent@0__:expired

可用于触发缓存刷新:

// key 过期事件订阅后,重新构建缓存

5.7 多级缓存一致性问题总结表

问题场景描述防御方案
并发重建脏缓存缓存刚被删除,缓存构建先于 DB 更新延迟双删
脏数据缓存失败先写缓存,后写 DB,DB 写失败先更新 DB,再删缓存
缓存更新被覆盖DB 改完后,旧请求更新了缓存分布式锁 / 写队列控制并发写入
跨服务缓存不一致服务 A 删缓存,B 未感知Redis Key 事件 + MQ 同步

5.8 SpringBoot + Caffeine + Redis 多级缓存实战架构

Spring 配置:

spring:
  cache:
    type: caffeine
    caffeine:
      spec: expireAfterWrite=120s,maximumSize=1000

RedisConfig 注册缓存:

@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .expireAfterWrite(2, TimeUnit.MINUTES)
            .maximumSize(1000));
        return manager;
    }
}

5.9 多级缓存适用场景建议

场景推荐策略
高并发热点数据Caffeine + Redis + 异步刷新
用户 Session/权限数据缓存Redis + TTL 逻辑刷新机制
长时间不变的数据(配置类)Redis 永不过期 + 定时刷新
实时变动数据(行情、库存)Redis + MQ 异步通知刷新

第六章:Redis 缓存监控、容灾与故障恢复策略实战

6.1 缓存监控指标体系

6.1.1 关键指标概览(Prometheus 采集项)

监控项含义
redis_connected_clients当前客户端连接数量
redis_memory_used_bytesRedis 占用内存大小
redis_keyspace_hits缓存命中次数
redis_keyspace_misses缓存未命中次数
redis_evicted_keys被淘汰的 key 数
redis_expired_keys过期删除的 key 数
redis_commands_processed_totalRedis 执行命令总数
redis_instance_uptime实例运行时长
redis_latency命令响应延迟

6.1.2 构建「命中率图表」

公式:


命中率 = hits / (hits + misses)
命中率持续下降 → 可能是雪崩或击穿前兆!

6.1.3 构建「内存预警系统」

内存耗尽往往意味着 Redis 会开始逐出 key 或拒绝写入,需结合以下配置和指标:

  • 配置项 maxmemory
  • 策略项 maxmemory-policy(推荐使用:volatile-lru / allkeys-lru)

6.2 使用 Redis Exporter + Prometheus + Grafana 实现可视化

6.2.1 安装 Redis Exporter

docker run -d -p 9121:9121 oliver006/redis_exporter

6.2.2 Prometheus 配置示例

scrape_configs:
  - job_name: 'redis'
    static_configs:
      - targets: ['localhost:9121']

6.2.3 Grafana 仪表盘示例(热门 Dashboard ID)

  • 官方推荐 Dashboard ID:763(Redis)
  • 支持 Key 命中率、QPS、连接数、内存曲线等

6.3 雪崩与击穿的早期告警机制

风险行为异常指标变化告警方式
雪崩开始命中率下降、miss率上升报警阈值设置 + 邮件/钉钉
击穿发生某 key 请求数异常增长热 key 检测
内存逼近限制memory\_used\_bytes 接近 maxmemory自动扩容 + 限流
节点掉线节点无响应Sentinel 哨兵自动切换

6.4 Sentinel 容灾与主从故障切换

6.4.1 Sentinel 简介

Redis Sentinel 是官方提供的高可用监控工具,支持:

  • 主节点宕机时自动切换
  • 向客户端通知新主节点地址
  • 哨兵节点之间投票选举 Leader

6.4.2 Sentinel 架构图

          ┌─────────────┐
          │  客户端     │
          └─────┬───────┘
                ▼
         ┌─────────────┐
         │ Sentinel 集群│
         └────┬────────┘
              ▼
       ┌────────────┐
       │ 主节点 Master│
       └────┬────────┘
            ▼
      ┌────────────┐
      │ 从节点 Slave│
      └────────────┘

6.4.3 配置 Sentinel 示例(sentinel.conf)

sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 10000
sentinel parallel-syncs mymaster 1

6.4.4 客户端连接示例(Jedis)

Set<String> sentinels = new HashSet<>();
sentinels.add("localhost:26379");
JedisSentinelPool pool = new JedisSentinelPool("mymaster", sentinels);

try (Jedis jedis = pool.getResource()) {
    jedis.set("key", "value");
}
Redis Sentinel 自动发现主从变更,无需重启客户端。

6.5 Redis Cluster 容灾架构(支持自动分片)

适用于大规模部署,自动分片、高可用容灾:

+---------+    +---------+    +---------+
| Master1 |<-> | Master2 |<-> | Master3 |
|  Slot 0-5000 | 5001-10000 | 10001-16383 |
|         |    |         |    |         |
|  Slave1 |    |  Slave2 |    |  Slave3 |
+---------+    +---------+    +---------+
  • 每个 Master 控制部分 Slot
  • 每个 Master 都有 Slave 自动备份
  • 故障节点由其他 Master 代理投票恢复

6.6 容灾方案对比

方案特点自动切换写入可用性成本
Redis Sentinel哨兵+主从+投票机制单主写入
Redis Cluster自动分片+多主架构可配置多写
手动主备脚本控制主从切换写需切换 DNS

6.7 故障模拟演练与自恢复测试

6.7.1 主动 kill 掉 Redis 主节点

docker exec -it redis_master bash
kill 1

观察:

  • Sentinel 是否能在 5s 内识别故障?
  • 客户端是否自动连接新主?
  • 缓存数据是否同步成功?

6.8 限流与降级机制补充缓存防线

6.8.1 热点 Key 限流

使用滑动窗口算法,限制单位时间内某 key 访问次数

if (redis.incr("req:user:1001") > 100) {
    return "Too many requests";
}
redis.expire("req:user:1001", 60);

6.8.2 服务降级保护缓存层

结合 Sentinel / Hystrix / Resilience4j,熔断缓存访问失败后自动降级到兜底响应或返回缓存快照。


第七章:高并发下的缓存穿透、雪崩、击穿综合实战项目


7.1 项目目标架构图

                    ┌──────────────┐
                    │    Client    │
                    └─────┬────────┘
                          ▼
                ┌────────────────────┐
                │   Controller层      │
                └────────┬───────────┘
                         ▼
      ┌───────────────────────────────────────┐
      │       CacheService(缓存综合服务)     │
      │ ┌────────┐    ┌──────────────┐        │
      │ │布隆过滤│    │ 本地缓存 Caffeine│        │
      │ └────────┘    └──────────────┘        │
      │ ┌────────────┐                        │
      │ │Redis 二级缓存│ ←→ 分布式锁          │
      │ └────────────┘                        │
      │ ┌────────────┐                        │
      │ │    DB 层     │ ←→ MQ缓存刷新通知     │
      │ └────────────┘                        │
      └───────────────────────────────────────┘

7.2 实战项目技术栈

模块技术
Spring 框架Spring Boot 3.x
缓存组件Caffeine、Redis
锁组件Redis 分布式锁
过滤组件RedisBloom
限流组件Guava RateLimiter
消息中间件RabbitMQ / Kafka
日志 & 监控SLF4J + Micrometer

7.3 本地缓存 Caffeine 配置

@Bean
public Cache<String, Object> localCache() {
    return Caffeine.newBuilder()
            .expireAfterWrite(2, TimeUnit.MINUTES)
            .maximumSize(10000)
            .build();
}

7.4 Redis 二级缓存访问逻辑(Cache Aside)

public User queryUserById(Long userId) {
    String key = "user:" + userId;

    // 1. 先查本地缓存
    User user = (User) localCache.getIfPresent(key);
    if (user != null) return user;

    // 2. 查 Redis 缓存
    String redisVal = redisTemplate.opsForValue().get(key);
    if (StringUtils.hasText(redisVal)) {
        user = JSON.parseObject(redisVal, User.class);
        localCache.put(key, user); // 回填本地缓存
        return user;
    }

    // 3. 缓存穿透防护:布隆过滤器判断是否存在
    if (!bloomFilter.contains(key)) {
        return null;
    }

    // 4. 加锁防击穿
    String lockKey = "lock:" + key;
    boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
    if (!locked) {
        // 等待其他线程构建缓存
        try { Thread.sleep(50); } catch (InterruptedException ignored) {}
        return queryUserById(userId); // 重试
    }

    try {
        // 5. 查询数据库
        user = userMapper.selectById(userId);
        if (user == null) {
            redisTemplate.opsForValue().set(key, "", 120, TimeUnit.SECONDS); // 空值防穿透
        } else {
            redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 10, TimeUnit.MINUTES);
            localCache.put(key, user);
        }
        return user;
    } finally {
        redisTemplate.delete(lockKey); // 解锁
    }
}

7.5 更新缓存:延迟双删机制实现

@Transactional
public void updateUser(User user) {
    String key = "user:" + user.getId();

    // 1. 先更新数据库
    userMapper.updateById(user);

    // 2. 删除缓存
    redisTemplate.delete(key);
    localCache.invalidate(key);

    // 3. 延迟二次删除
    Executors.newSingleThreadScheduledExecutor()
        .schedule(() -> {
            redisTemplate.delete(key);
            localCache.invalidate(key);
        }, 500, TimeUnit.MILLISECONDS);
}

7.6 热点 Key 检测与限流

RateLimiter rateLimiter = RateLimiter.create(100); // 每秒100个请求

public Object queryHotData(String key) {
    if (!rateLimiter.tryAcquire()) {
        log.warn("限流触发:{}", key);
        return fallbackResponse();
    }

    // 继续走缓存逻辑
    return queryUserById(Long.valueOf(key));
}

7.7 RedisBloom 布隆过滤器使用(防穿透)

创建 Bloom 过滤器

BF.RESERVE user_filter 0.01 1000000

添加数据

BF.ADD user_filter user:1001

定时刷新布隆过滤器

@Scheduled(fixedRate = 3600_000)
public void refreshBloom() {
    List<Long> userIds = userMapper.selectAllUserIds();
    for (Long id : userIds) {
        stringRedisTemplate.execute((RedisCallback<Object>) connection ->
            connection.execute("BF.ADD", "user_filter".getBytes(), ("user:" + id).getBytes()));
    }
}

7.8 消息队列异步刷新热点缓存(可选)

@RabbitListener(queues = "refresh-cache")
public void handleRefreshMsg(String key) {
    User user = userMapper.selectById(extractIdFromKey(key));
    redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 10, TimeUnit.MINUTES);
    localCache.put(key, user);
}

7.9 项目完整结构建议

src/
 ├── config/
 │   ├── RedisConfig.java
 │   └── CaffeineConfig.java
 ├── service/
 │   └── CacheService.java
 ├── controller/
 │   └── UserController.java
 ├── mq/
 │   └── CacheRefreshConsumer.java
 ├── bloom/
 │   └── BloomFilterUtil.java
 └── limiter/
     └── RateLimiterManager.java

7.10 综合效果测试与验证

问题类型验证方法是否防御成功
穿透连续请求不存在的用户 ID✅ 空值缓存 + 布隆过滤器拦截
击穿高并发请求某热点用户信息,临近 TTL✅ 分布式锁 + 本地缓存抗压
雪崩批量过期 key + Redis 崩溃模拟✅ 多级缓存 + 限流 + 降级响应

第八章:Redis 缓存问题面试题解析(含源码与场景设计)

8.1 高频面试问题汇总表

面试问题编号问题内容
Q1什么是缓存穿透?如何防止?
Q2什么是缓存雪崩?如何应对?
Q3什么是缓存击穿?如何防护?
Q4多级缓存系统中如何保持数据一致性?
Q5

| 如何使用布隆过滤器避免缓存穿透? |
| Q6 | 延迟双删策略具体怎么实现? |
| Q7 | 分布式锁如何避免击穿?与 Redisson 有何区别? |
| Q8 | 如何监控缓存健康状况?有哪些核心指标? |
| Q9 | Redis 的过期策略有哪些?如何选择? |
| Q10 | 如何防止缓存和数据库“双写不一致”? |


8.2 面试题详解与答案

Q1:什么是缓存穿透?如何防止?

  • 定义:查询不存在的数据,缓存未命中,数据库也无,造成每次请求都打 DB。
  • 成因:参数非法、大量恶意请求。
  • 防御方法

    • 空值缓存:null/"" 缓存一段时间。
    • 布隆过滤器:提前判断 key 是否存在。
    • 验证层参数合法性校验。

Q2:什么是缓存雪崩?如何防止?

  • 定义:大量缓存集中过期,Redis 压力骤增,大量请求打到 DB。
  • 成因:统一设置了相同 TTL,或者 Redis 整体故障。
  • 解决方案

    • 设置随机过期时间
    • 多级缓存(本地 + Redis)
    • 限流 / 熔断 / 降级机制
    • Redis Cluster + Sentinel 容灾架构

Q3:什么是缓存击穿?如何防护?

  • 定义:某个热点 key 突然失效,恰逢高并发访问时,大量请求同时击穿 DB。
  • 解决方案

    • 分布式锁互斥构建缓存
    • 逻辑过期 + 异步刷新
    • 设置永不过期 + 定时后台刷新

Q4:多级缓存如何保持一致性?

  • 更新数据库 → 删除 Redis 缓存 → 删除本地缓存
  • 延迟双删策略
  • MQ 异步刷新本地缓存
  • 使用版本号 + TTL 标记缓存失效

Q5:布隆过滤器实现原理?

  • 位图结构 + 多哈希函数
  • 查询 key 是否在集合中,存在返回“可能存在”不存在返回“绝对不存在”
  • 有误判率、无漏判率

Q6:延迟双删策略实现流程?

  1. 更新数据库
  2. 删除缓存(第一次)
  3. 等待 500ms
  4. 再次删除缓存(第二次兜底)

Q7:Redis 分布式锁机制?

  • setIfAbsent 设置锁
  • Redisson 提供自动续期和重入锁

Q8:缓存健康监控指标有哪些?

  • keyspace\_hits
  • keyspace\_misses
  • memory\_used\_bytes
  • evicted\_keys
  • connected\_clients
  • expired\_keys
  • slowlog

Q9:Redis 过期策略有哪些?

  • noeviction
  • allkeys-lru
  • volatile-lru
  • allkeys-random

Q10:如何保证缓存与数据库的一致性?

  • 推荐流程:更新数据库 → 删除缓存
  • 延迟双删 + 分布式锁
  • MQ 异步刷新策略
  • 版本号、时间戳避免旧数据覆盖新数据

第九章:Redis 缓存系统的性能优化建议与生产经验总结

9.1 Redis Key 设计规范

  • 使用英文冒号分隔,格式统一
  • 保持 key 长度合理 (<128 byte)
  • 避免特殊字符

9.2 TTL(缓存时间)设计原则

  • 不宜过短或过长,结合数据特点
  • 设置随机过期时间避免雪崩

9.3 缓存系统热点 Key 检测实践

  • 使用 redis-cli monitor(开发)
  • 使用 redis-cli --hotkeys
  • 利用 Prometheus + Grafana 监控

9.4 生产系统缓存常见问题排查流程

  • 查看命中率指标
  • 查询慢日志(slowlog)

9.5 Redis 生产配置优化建议

配置项推荐值
appendonlyyes
appendfsynceverysec
maxmemory-policyvolatile-lru / allkeys-lru
tcp-keepalive60
save900 1, 300 10, 60 10000
timeout300
latency-monitor-threshold500 ms

9.6 Redis 故障处理真实案例分析

案例一:缓存雪崩引发数据库连接池耗尽

  • 统一 TTL 导致缓存集中失效
  • DB 承受不了并发,连接池耗尽
  • 解决方案:分散 TTL + 预热 + 限流降级

案例二:缓存击穿导致接口卡顿

  • 热点 key 失效,QPS 突增打 DB
  • 使用分布式锁 + 逻辑过期 + 异步刷新优化

9.7 Redis 缓存调优 Checklist

  • 防穿透:布隆过滤器 + 空值缓存
  • 防击穿:分布式锁 + 逻辑过期
  • 防雪崩:随机 TTL + 限流 + 降级
  • 缓存一致性:更新 DB → 延迟双删缓存
  • 监控告警:命中率、QPS、慢查询、内存使用等
  • 容灾切换:Sentinel / Cluster
  • 多级缓存设计:本地 + Redis

2025-07-03

一、背景与概述

在 Redis 的五大基本数据类型中,ZSet(有序集合) 是极为重要的一种结构,广泛应用于排行榜、延时任务队列、缓存排序等场景。

ZSet 背后的核心数据结构就是 跳跃表(SkipList) 与哈希表的组合,它是一种兼具有序性、高性能的结构。本文将带你深入剖析其底层实现机制,重点理解 SkipList 的结构、Redis 中的实现、常见操作与复杂度。


二、ZSet 数据结构总览

2.1 ZSet 的组成

ZSet 是 Redis 中用于实现有序集合的数据结构,底层由两部分组成:

  • 字典(dict):用于快速根据成员查找其对应的 score(分值);
  • 跳跃表(skiplist):用于根据 score 排序,快速定位排名、范围查找等操作。

这两者共同维护 ZSet 的数据一致性,确保既能快速查找,又能保持有序性。

图解:

ZSet
 ├── dict: member -> score 映射(哈希表,O(1) 查找)
 └── skiplist: (score, member) 有序集合(跳跃表,O(logN) 范围查找)

三、跳跃表(SkipList)原理详解

3.1 SkipList 是什么?

跳跃表是一种基于多级索引的数据结构,它可以看作是一个多层链表,每一层是下一层的“索引”版本,从而加快查找速度。

SkipList 的特点:

  • 插入、删除、查找时间复杂度为 O(logN)
  • 实现简单,效率媲美平衡树
  • 天然支持范围查询,非常适合排序集合

3.2 图解结构

以一个存储整数的 SkipList 为例(高度为4):

Level 4:   ——>      10     ——>     50
Level 3:   ——>   5 ——> 10 ——> 30 ——> 50
Level 2:   ——>   5 ——> 10 ——> 20 ——> 30 ——> 50
Level 1:   ——> 1 ——> 5 ——> 10 ——> 20 ——> 30 ——> 40 ——> 50 ——> 60

每一层链表都可以跳跃地查找下一个节点,从而减少访问节点的数量。


四、Redis 中 SkipList 的实现结构

4.1 核心结构体(源码:server.h

typedef struct zskiplistNode {
    sds ele;                    // 成员
    double score;               // 分值
    struct zskiplistNode *backward;    // 后向指针
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前向指针
        unsigned int span;            // 跨度(用于排名计算)
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;
⚠️ level[] 是变长数组(C99语法),节点高度在创建时确定。

4.2 插入节点图解

假设当前插入 (score=25, ele="userA")

Step 1: 随机生成高度 H(比如是3)
Step 2: 找到每层对应的插入位置
Step 3: 调整 forward 和 span 指针
Step 4: 更新 header/tail 等信息

五、关键操作源码解读

5.1 插入节点:zslInsert()

zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL];
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    ...
    int level = zslRandomLevel(); // 生成随机层级
    ...
    zskiplistNode *x = zslCreateNode(level, score, ele);
    for (int i = 0; i < level; i++) {
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;
        ...
    }
    ...
    return x;
}

5.2 删除节点:zslDelete()

int zslDelete(zskiplist *zsl, double score, sds ele, zskiplistNode **node) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL];
    ...
    for (int i = 0; i < zsl->level; i++) {
        if (update[i]->level[i].forward == x) {
            update[i]->level[i].forward = x->level[i].forward;
        }
    }
    ...
    zslFreeNode(x);
}

5.3 查找节点:zslGetRank()zslFirstInRange()

Redis 为排名、范围查询提供了高效函数,如:

unsigned long zslGetRank(zskiplist *zsl, double score, sds ele);
zskiplistNode* zslFirstInRange(zskiplist *zsl, zrangespec *range);

六、时间复杂度分析

操作时间复杂度描述
插入O(logN)层数为 logN,按层插入
删除O(logN)同插入
查找O(logN)按层跳跃查找
范围查询O(logN + M)M 为返回结果数量
排名查询O(logN)利用 span 记录加速

七、实际应用场景举例

7.1 排行榜系统

ZADD game_rank 100 player1
ZADD game_rank 200 player2
ZADD game_rank 150 player3

ZRANGE game_rank 0 -1 WITHSCORES

7.2 延时队列(定时任务)

利用 score 存储时间戳,实现定时执行:

ZADD delay_queue 1722700000 job_id_1
ZRANGEBYSCORE delay_queue -inf 1722700000

八、优化与注意事项

  • 跳跃表节点最大层级为 32,默认概率为 0.25,保持高度平衡;
  • 由于同时维护 dict 与 skiplist,每次插入或删除都要双操作
  • ZSet 非线程安全,适合单线程操作或加锁处理
  • 不适合频繁更新 score 的场景,容易造成 skiplist 大量重构。

九、总结

Redis 的 ZSet 是通过字典 + 跳跃表组合实现的高性能有序集合结构。其中跳跃表作为核心组件,提供了高效的插入、删除、范围查找等操作,其逻辑结构清晰、实现简洁,适合高并发场景。

通过本文的源码分析与结构图解,相信你对 SkipList 的工作机制和 Redis 中 ZSet 的底层实现有了更清晰的认识。

2025-06-18

Redis深度剖析Gossip协议揭秘

——深入理解集群中节点如何交流与感知


一、引言:Redis为什么需要Gossip?

在 Redis 的 Cluster 模式下,节点之间需要感知彼此的健康状态与槽(slot)分布信息。为此,Redis 并没有采用集中式的“控制中心”,而是采用了去中心化的 Gossip 协议

Gossip 协议的核心思想是:

“我知道的事情,我会随机告诉别人,别人再告诉别人。”

这使得 Redis Cluster 拥有了高可用性、快速传播、容错性强的通信机制。


二、Gossip 协议基础原理

2.1 什么是 Gossip 协议?

Gossip(中文:八卦)协议,源自于人类传播八卦的行为模式。它在分布式系统中常用于节点健康探测和元数据同步。特征如下:

  • 去中心化传播信息
  • 定期交换状态
  • 缓慢但最终一致

2.2 Gossip 工作流程图解

         +---------+      gossip      +---------+
         | Node A  |----------------->| Node B  |
         +---------+ <----------------+---------+
                          gossip

每隔一定时间,Node A 会随机挑选一个节点(如 B),将自己已知的信息发送过去,并接收 B 的信息,更新自己。

2.3 Redis Cluster Gossip 特点

  • 每个 Redis 节点都定期发送 PING 请求;
  • 附带自己已知的其他节点信息
  • 接收方更新自己的集群拓扑;
  • 节点健康状态根据 pingpong 响应确定。

三、源码解析 Gossip 实现

Redis 的 Gossip 实现在 cluster.c 中的多个函数中体现,下面简化还原其关键部分:

3.1 发送 Gossip(简化)

void clusterSendPing(clusterLink *link) {
    clusterMsg msg;
    // 设置消息类型为 PING
    msg.type = CLUSTERMSG_TYPE_PING;

    // 将本地节点信息写入消息中
    clusterSetGossipSection(&msg);

    // 发送消息
    send(link->fd, &msg, sizeof(msg), 0);
}

3.2 构造 Gossip 信息

void clusterSetGossipSection(clusterMsg *msg) {
    int gossip_count = 0;
    for (int i = 0; i < cluster->node_count; i++) {
        clusterNode *n = cluster->nodes[i];
        if (n == myself) continue;

        // 添加其他节点信息
        msg->gossip[gossip_count].ip = n->ip;
        msg->gossip[gossip_count].port = n->port;
        msg->gossip[gossip_count].flags = n->flags;

        gossip_count++;
    }
    msg->gossip_count = gossip_count;
}

3.3 接收处理 Gossip

void clusterProcessGossipSection(clusterMsg *msg) {
    for (int i = 0; i < msg->gossip_count; i++) {
        clusterNodeGossip *g = &msg->gossip[i];

        // 查找或创建该节点
        clusterNode *n = getNodeByIPAndPort(g->ip, g->port);
        if (!n) n = createClusterNode(g->ip, g->port);

        // 更新其 flags 等状态
        n->flags = g->flags;
        n->last_ping_received = mstime();
    }
}

四、Redis Gossip 消息结构详解(图解)

4.1 clusterMsg 结构(简化图示)

+------------------+
| 消息头 (type/ping)|
+------------------+
| Gossip 节点列表   |
|  - IP            |
|  - Port          |
|  - Flags         |
+------------------+

每条 Gossip 消息都包含当前节点知道的其他节点的状态。


五、Redis Gossip 与故障检测

Redis 使用 Gossip 信息进行节点下线判断

  • 如果一个节点连续 PING 不通超过 cluster_node_timeout,它会被标记为 PFAIL(疑似下线);
  • 其他节点也 Gossip 到类似信息后,会最终达成一致,标记为 FAIL

故障检测图解

Node A       Node B        Node C
  |            |             |
  |----PING--->|             |
  |<---PONG----|             |
  |----PING----------->     |
  |         X(PING FAIL)    |
  |            |----Gossip info---> Node A
  |            |                      |
  |        Node C也怀疑B不可达       |
  |-------> 触发故障投票机制        |

六、Gossip 与 Slot 映射传播

Redis Cluster 还使用 Gossip 传播 Slot 分配信息。比如当某个节点的 Slot 迁移后,会通过 Gossip 更新给其他节点。

槽位传播流程:

  1. Node A 接收到 Slot 迁移信息;
  2. 将此信息通过 Gossip 发给 Node B;
  3. Node B 更新本地 slot 分配。

七、Redis Gossip 协议优缺点分析

优点缺点
无需中心协调,去中心化收敛速度较慢,最终一致性非实时
容错能力强,适应动态拓扑Gossip 消息量大时占用网络
易于扩展,可动态加入新节点容易误判故障,需多次确认

八、总结与实践建议

  • Gossip 是 Redis Cluster 构建高可用与强一致视图的基石;
  • 在调试集群状态时,可使用 CLUSTER NODES 命令观察节点 gossip 信息;
  • 在实际部署中注意配置 cluster-node-timeout,避免误判故障;
  • Gossip 无法实时同步所有状态,业务容错机制应作补充。

附录:命令辅助学习

# 查看当前节点认识的集群
redis-cli -c -p 7000 cluster nodes

# 强制刷新集群视图
redis-cli -c -p 7000 cluster meet <ip> <port>

# 槽位查看
redis-cli -c -p 7000 cluster slots

引言

在微服务架构中,Spring Cloud Gateway(以下简称 Gateway)常被用作系统的统一入口,负责路由、限流、监控等功能。与此同时,单点登录(SSO)认证是保障系统安全、提升用户体验的关键。结合Redis的高性能特性,利用 Gateway 的拦截器(Filter)实现统一鉴权与会话管理,能够打造一套高效、可伸缩的单点登录与认证系统。

本文将从架构设计核心原理代码示例图解四个方面,详细剖析 Gateway 拦截器 + Redis 方案,帮助你快速上手并轻松学习。


一、架构设计

┌──────────┐         ┌──────────┐        ┌────────────┐
│ 用户浏览器 │ ──→   │ Spring   │ ──→   │ 后端微服务1 │
│ (携带Token)│       │ Cloud    │       └────────────┘
└──────────┘        │ Gateway  │       ┌────────────┐
                    └───┬──────┘ ──→   │ 后端微服务2 │
                        │             └────────────┘
       ┌──────────────┐ │
       │   Redis      │◀┘
       │ (Session Store)│
       └──────────────┘
  • 用户浏览器:在登录后携带 JWT/Token 访问各微服务。
  • Gateway:接收请求后,通过拦截器校验 Token,并查询 Redis 获取会话或权限信息,决定放行或拒绝。
  • Redis:存储 Token 与用户会话数据,支持高并发读写,保障鉴权极低延迟。
  • 微服务:只需关注业务逻辑,无需重复实现鉴权逻辑。

二、核心原理

  1. Token 签发与存储

    • 用户登录成功后,认证服务生成 JWT 并同时在 Redis 中存储会话(或权限列表),Key 为 SESSION:{token},Value 为用户信息 JSON。
  2. Gateway 拦截器

    • 每次请求到达 Gateway 时,Filter 先从 HTTP Header(如 Authorization: Bearer <token>)中提取 Token;
    • 去 Redis 校验 Token 是否有效,并可选地加载用户权限;
    • 校验通过则将用户信息注入 Header 或上下文,转发给下游微服务;否则返回 401 Unauthorized
  3. Redis 会话管理

    • 设置过期时间(如 30 分钟),实现自动失效;
    • 支持单点登出:从 Redis 删除会话,立即使所有网关拦截器失效。

三、代码示例

1. Redis 配置

@Configuration
public class RedisConfig {
    @Bean
    public JedisConnectionFactory jedisConnectionFactory() {
        RedisStandaloneConfiguration cfg = new RedisStandaloneConfiguration("localhost", 6379);
        return new JedisConnectionFactory(cfg);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(JedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }
}

2. 认证服务:Token 签发与存储

@RestController
@RequestMapping("/auth")
public class AuthController {
    @Autowired private RedisTemplate<String,Object> redisTemplate;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginDTO dto) {
        // 验证用户名密码略…
        String token = JwtUtil.generateToken(dto.getUsername());
        // 存入 Redis,设置 30 分钟过期
        String key = "SESSION:" + token;
        UserInfo userInfo = new UserInfo(dto.getUsername(), List.of("ROLE_USER"));
        redisTemplate.opsForValue().set(key, userInfo, 30, TimeUnit.MINUTES);
        return ResponseEntity.ok(Map.of("token", token));
    }

    @PostMapping("/logout")
    public ResponseEntity<?> logout(@RequestHeader("Authorization") String auth) {
        String token = auth.replace("Bearer ", "");
        redisTemplate.delete("SESSION:" + token);
        return ResponseEntity.ok().build();
    }
}

3. Gateway 拦截器实现

@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
    @Autowired private RedisTemplate<String,Object> redisTemplate;

    @Override
    public int getOrder() {
        return -1;  // 优先级高于路由转发
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1. 提取 Token
        String auth = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (auth == null || !auth.startsWith("Bearer ")) {
            return unauthorized(exchange);
        }
        String token = auth.replace("Bearer ", "");

        // 2. Redis 校验
        String key = "SESSION:" + token;
        Object userInfo = redisTemplate.opsForValue().get(key);
        if (userInfo == null) {
            return unauthorized(exchange);
        }

        // 3. 延长会话有效期
        redisTemplate.expire(key, 30, TimeUnit.MINUTES);

        // 4. 将用户信息放入 Header,透传给下游
        exchange = exchange.mutate()
            .request(r -> r.header("X-User-Info", JsonUtils.toJson(userInfo)))
            .build();

        return chain.filter(exchange);
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        DataBuffer buffer = exchange.getResponse().bufferFactory()
            .wrap("{\"error\":\"Unauthorized\"}".getBytes());
        return exchange.getResponse().writeWith(Mono.just(buffer));
    }
}

四、图解流程

┌─────────────┐     1. 登录请求      ┌──────────────┐
│  用户浏览器   │ ──→ /auth/login ──→ │ 认证服务(Auth) │
└─────────────┘                     └──────────────┘
                                          │
                   2. 签发 JWT & 存 Redis(key=SESSION:token, value=UserInfo)
                                          ▼
┌─────────────┐     3. 携带 Token       ┌──────────┐
│  用户浏览器   │ ──→ 接入请求 ──→      │ Gateway  │
└─────────────┘                     └────┬─────┘
                                           │
                                4. 校验 Redis(key=SESSION:token)
                                           │
                              ┌────────────┴────────────┐
                              │                          │
                    有效 → 延长过期 & 注入用户信息         无效 → 返回 401
                              │                          
                              ▼                          
                    5. 转发到后端微服务                  

五、详细说明

  1. 全局 Filter vs 路由 Filter

    • 本示例使用 GlobalFilter,对所有路由生效;
    • 若需针对特定路由,可改用 GatewayFilterFactory 定制化 Filter。
  2. 会话延迟策略

    • 每次请求命中后主动延长 Redis Key 过期时间,实现“滑动过期”;
    • 可根据业务调整为固定过期或多级过期。
  3. 多实例部署与高可用

    • Gateway 与认证服务可水平扩展;
    • Redis 可部署哨兵或集群模式,保证高可用和容灾。
  4. 安全加固

    • 建议在 JWT 中添加签名与加密;
    • 对敏感 Header 与 Cookie 做安全校验;
    • 考虑使用 HTTPS,防止中间人攻击。

六、总结

通过上述方案,你可以快速构建基于 Spring Cloud Gateway + Redis 的单点登录与认证系统:

  • 高性能:Redis 提供毫秒级读写;
  • 高可用:组件可独立扩展与集群化部署;
  • 易维护:认证逻辑集中在 Gateway,一处修改全局生效。
2025-06-10

一、背景与挑战

在高并发场景下(如电商秒杀、社交动态流、API 网关),PHP 应用面临以下主要挑战:

  1. 阻塞等待带宽与资源浪费

    • 传统 PHP 是同步阻塞模式:发起一次远程接口调用或数据库查询,需要等待 I/O 完成后才能继续下一个操作。
    • 若同时有上千个请求进入,数百个慢接口轮询会导致大量进程或协程处于“睡眠等待”状态,CPU 资源无法被充分利用。
  2. 并发任务数量失控导致资源耗尽

    • 如果不对并发并行任务数量加以限制,瞬时并发过多会导致内存、文件描述符、数据库连接池耗尽,从而引发请求失败或服务崩溃。
    • 必须在吞吐与资源可承受之间找到平衡,并对“并发度”进行动态或静态约束。
  3. 传统锁与阻塞带来性能瓶颈

    • 在并发写共享资源(如缓存、日志、文件)时,若使用简单的互斥锁(flock()Mutex),会导致大量进程/协程等待锁释放,降低吞吐。
    • 异步非阻塞模型可以通过队列化或原子操作等方式减少锁竞争开销。

为应对上述挑战,本文将从 PHP 异步处理并发控制 两个维度展开,重点借助 Swoole 协程(也兼顾 ReactPHP/Amp 等方案)示例,展示如何在高并发场景下:

  • 非阻塞地执行网络/数据库 I/O
  • 有效控制并发数量,避免资源耗尽
  • 构建任务队列与限流策略
  • 处理并发写冲突与锁优化

二、PHP 异步基础:从阻塞到非阻塞

2.1 同步阻塞模式

在传统 PHP 脚本中,读取远程接口或数据库都会阻塞当前进程或线程,示例代码:

<?php
function fetchData(string $url): string {
    // 这是阻塞 I/O,同一时刻只能执行一条请求
    $response = file_get_contents($url);
    return $response ?: '';
}

// 串行发起多个请求
$urls = [
    'http://api.example.com/user/1',
    'http://api.example.com/user/2',
    'http://api.example.com/user/3',
];

$results = [];
$start = microtime(true);
foreach ($urls as $url) {
    $results[] = fetchData($url);
}
$end = microtime(true);
echo "同步完成,用时: " . round($end - $start, 3) . " 秒\n";
  • 若每个 fetchData() 需要 1 秒,3 个请求依次执行耗时约 3 秒。
  • 并发量一旦增大,阻塞等待会累加,导致吞吐急剧下降。

2.2 非阻塞/异步模型

异步 I/O 可以让单个进程在等待网络或磁盘操作时“挂起”该操作,并切换到下一任务,完成后再回来“续写”回调逻辑,实现“并发”效果。常见 PHP 异步方案包括:

  1. Swoole 协程:借助底层 epoll/kqueue,将 I/O 操作切换为协程挂起,不阻塞进程。
  2. ReactPHP / Amp:基于事件循环(Event Loop),使用回调或 yield 关键字实现异步非阻塞。
  3. Parallel / pthreads:多线程模型,将每个任务交给独立线程执行,本质上是并行而非真正“异步”。

下文将重点以 Swoole 协程 为主,兼顾 ReactPHP 思路,并展示如何借助这些模型让代码从“线性阻塞”变为“并发异步”。


三、方案一:Swoole 协程下的异步处理

3.1 Swoole 协程简介

  • 协程(Coroutine):一种“用户态”轻量线程,具有非常快速的上下文切换。
  • 当协程执行到阻塞 I/O(如 HTTP 请求、MySQL 查询、Redis 操作)时,会自动将该协程挂起,让出 CPU 给其他协程。I/O 完成后再恢复。
  • Swoole 通过底层 hook 系统函数,将传统阻塞函数转换为可挂起的异步调用。

只需在脚本中调用 Swoole\Coroutine\run() 创建协程容器,之后在任意位置使用 go(function(){…}) 即可开启协程。

3.2 示例:并发发起多 HTTP 请求

<?php
use Swoole\Coroutine\Http\Client;
use Swoole\Coroutine;

// 并发请求列表
$urls = [
    'http://httpbin.org/delay/1',
    'http://httpbin.org/delay/2',
    'http://httpbin.org/delay/3',
    'http://httpbin.org/get?param=4',
    'http://httpbin.org/uuid'
];

Co\run(function() use ($urls) {
    $responses = [];
    $wg = new Swoole\Coroutine\WaitGroup();

    foreach ($urls as $idx => $url) {
        $wg->add();
        go(function() use ($idx, $url, &$responses, $wg) {
            $parts = parse_url($url);
            $host = $parts['host'];
            $port = $parts['scheme'] === 'https' ? 443 : 80;
            $path = $parts['path'] . (isset($parts['query']) ? '?' . $parts['query'] : '');

            $cli = new Client($host, $port, $parts['scheme'] === 'https');
            $cli->set(['timeout' => 5]);
            $cli->get($path);
            $responses[$idx] = [
                'status' => $cli->statusCode,
                'body'   => substr($cli->body, 0, 100) . '…'
            ];
            $cli->close();
            echo "[协程 {$idx}] 请求 {$url} 完成,状态码={$responses[$idx]['status']}\n";
            $wg->done();
        });
    }

    $wg->wait();
    echo "[主协程] 所有请求已完成,共 " . count($responses) . " 条。\n";
    print_r($responses);
});

ASCII 流程图

┌─────────────────────────────────────────────────────────────────┐
│                   主协程 (Coroutine)                            │
│           (Co\run 内部作为主调度)                                │
└─────────────────────────────────────────────────────────────────┘
        │             │             │             │             │
        │             │             │             │             │
        ▼             ▼             ▼             ▼             ▼
    ┌───────┐      ┌───────┐      ┌───────┐      ┌───────┐      ┌───────┐
    │协程 0 │      │协程 1 │      │协程 2 │      │协程 3 │      │协程 4 │
    │发起 GET…│     │发起 GET…│     │发起 GET…│     │发起 GET…│     │发起 GET…│
    └───┬───┘      └───┬───┘      └───┬───┘      └───┬───┘      └───┬───┘
        │             │             │             │             │
        │             │             │             │             │
      I/O 阻塞        I/O 阻塞       I/O 阻塞       I/O 阻塞       I/O 阻塞
        │             │             │             │             │
   [挂起协程 0]  [挂起协程 1]  [挂起协程 2]  [挂起协程 3]  [挂起协程 4]
        ↓             ↓             ↓             ↓             ↓
  Swoole 底层 挂起 I/O 等待异步事件完成
        ↓             ↓             ↓             ↓             ↓
   I/O 完成         I/O 完成        I/O 完成        I/O 完成        I/O 完成
        │             │             │             │             │
  恢复协程 0       恢复协程 1    恢复协程 2    恢复协程 3    恢复协程 4
        │             │             │             │             │
     处理响应        处理响应       处理响应       处理响应       处理响应
        │             │             │             │             │
    $wg->done()     $wg->done()   $wg->done()   $wg->done()   $wg->done()
        └─────────────────────────────────────────┘
                           ↓
             主协程 调用 $wg->wait() 解除阻塞,继续执行
                           ↓
             输出所有响应并退出脚本

3.3 并发控制:限制协程数量

在高并发场景中,如果一次性开启上千个协程,可能出现以下风险:

  • 突发大量并发 I/O,造成网络带宽瞬间拥堵
  • PHP 进程内存分配不够,一次性分配大量协程栈空间导致 OOM

限制协程并发数示例

<?php
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;

$urls = []; // 假设有上千个 URL 列表
for ($i = 0; $i < 1000; $i++) {
    $urls[] = "http://httpbin.org/delay/" . rand(1, 3);
}

// 最大并发协程数
$maxConcurrency = 50;

// 使用 Channel 作为“令牌桶”或“协程池”
Co\run(function() use ($urls, $maxConcurrency) {
    $sem = new Channel($maxConcurrency);

    // 初始化令牌桶:放入 $maxConcurrency 个令牌
    for ($i = 0; $i < $maxConcurrency; $i++) {
        $sem->push(1);
    }

    $wg = new Swoole\Coroutine\WaitGroup();

    foreach ($urls as $idx => $url) {
        // 从令牌桶取出一个令牌;若为空则挂起等待
        $sem->pop();

        $wg->add();
        go(function() use ($idx, $url, $sem, $wg) {
            echo "[协程 {$idx}] 开始请求 {$url}\n";
            $parts = parse_url($url);
            $cli = new \Swoole\Coroutine\Http\Client($parts['host'], 80);
            $cli->get($parts['path']);
            $cli->close();
            echo "[协程 {$idx}] 完成请求\n";

            // 任务完成后归还令牌,让下一个协程能够启动
            $sem->push(1);

            $wg->done();
        });
    }

    $wg->wait();
    echo "[主协程] 所有请求已完成。\n";
});

原理与说明

  1. Channel 令牌桶

    • 创建一个容量为 $maxConcurrency 的 Channel,并预先 push() 同样数量的“令牌”(任意占位符)。
    • 每次要启动新协程前,先 pop() 一个令牌;如果 Channel 为空,则意味着当前已有 $maxConcurrency 个协程在运行,新的协程会被挂起等待令牌。
    • 协程执行完毕后 push() 回一个令牌,让后续被挂起的协程继续运行。
  2. 并发控制

    • 该方案等效于“协程池(Coroutine Pool)”,始终只维持最多 $maxConcurrency 个协程并发执行。
    • 避免瞬时并发过大导致 PHP 内存或系统资源耗尽。
  3. ASCII 图解:并发限制流程
┌─────────────────────────────────────────────────────────┐
│                     主协程 (Coroutine)                  │
└─────────────────────────────────────────────────────────┘
         │            │            │            │
    pop  │            │            │            │
─────────┼────────────┼────────────┼────────────┤
         ▼ (取令牌)    ▼ (取令牌)   ▼ (取令牌)   ▼  (取令牌)
     ┌───────────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐
     │ 协程 1    │  │ 协程 2    │  │ 协程 3    │  │ 协程 4    │
     │ 执行请求  │  │ 执行请求  │  │ 执行请求  │  │ 执行请求  │
     └────┬──────┘  └────┬──────┘  └────┬──────┘  └────┬──────┘
          │              │              │              │
        完成           完成           完成           完成
          │              │              │              │
        push           push           push           push
    (归还令牌)      (归还令牌)      (归还令牌)      (归还令牌)
          └──────────────┴──────────────┴──────────────┘
                       ↓
             下一个协程获取到令牌,继续启动

四、方案二:ReactPHP / Amp 异步事件循环

除了 Swoole,常见的 PHP 异步框架还有 ReactPHPAmp。它们并不依赖扩展,而是基于事件循环(Event Loop) + 回调/Promise模式实现异步:

  • ReactPHP:Node.js 式的事件循环,提供 react/httpreact/mysqlreact/redis 等组件。
  • Amp:基于 yield / await 的协程式语法糖,更接近同步写法,底层也是事件循环。

下面以 ReactPHP 为例,展示如何发起并发 HTTP 请求并控制并发量。

4.1 安装 ReactPHP

composer require react/event-loop react/http react/http-client react/promise react/promise-stream

4.2 并发请求示例(ReactPHP)

<?php
require 'vendor/autoload.php';

use React\EventLoop\Loop;
use React\Http\Browser;
use React\Promise\PromiseInterface;

// 要并发请求的 URL 列表
$urls = [
    'http://httpbin.org/delay/1',
    'http://httpbin.org/delay/2',
    'http://httpbin.org/get?foo=bar',
    'http://httpbin.org/status/200',
    'http://httpbin.org/uuid'
];

// 并发限制
$maxConcurrency = 3;
$inFlight = 0;
$queue = new SplQueue();

// 存放结果
$results = [];

// 创建 HTTP 客户端
$client = new Browser(Loop::get());

// 将 URL 推入队列
foreach ($urls as $idx => $url) {
    $queue->enqueue([$idx, $url]);
}

function processNext() {
    global $queue, $inFlight, $maxConcurrency, $client, $results;

    while ($inFlight < $maxConcurrency && !$queue->isEmpty()) {
        list($idx, $url) = $queue->dequeue();
        $inFlight++;
        /** @var PromiseInterface $promise */
        $promise = $client->get($url);
        $promise->then(
            function (\Psr\Http\Message\ResponseInterface $response) use ($idx, $url) {
                global $inFlight, $results;
                $results[$idx] = [
                    'url'    => $url,
                    'status' => $response->getStatusCode(),
                    'body'   => substr((string)$response->getBody(), 0, 100) . '…'
                ];
                echo "[主循环] 请求 {$url} 完成,状态码=". $response->getStatusCode() . "\n";
                $inFlight--;
                processNext(); // 继续处理下一批任务
            },
            function (Exception $e) use ($idx, $url) {
                global $inFlight, $results;
                $results[$idx] = [
                    'url'   => $url,
                    'error' => $e->getMessage()
                ];
                echo "[主循环] 请求 {$url} 失败: " . $e->getMessage() . "\n";
                $inFlight--;
                processNext();
            }
        );
    }

    // 当队列空且 inFlight=0 时可以结束循环
    if ($queue->isEmpty() && $inFlight === 0) {
        // 打印所有结果
        echo "[主循环] 所有请求完成,共 " . count($results) . " 条\n";
        print_r($results);
        Loop::stop();
    }
}

// 启动处理
processNext();
Loop::run();

ASCII 流程图

┌───────────────────────────────────────────────────────────┐
│                   ReactPHP 事件循环                        │
└───────────────────────────────────────────────────────────┘
      │            │            │            │            │
      │            │            │            │            │
      ▼            ▼            ▼            ▼            ▼
[HTTP get]    [HTTP get]    [HTTP get]    [队列等待]    [队列等待]
  (url1)        (url2)        (url3)        (url4)        (url5)
      │            │            │
      │ inFlight=3  │ (并发达到 max=3) 等待   等待
      ▼            ▼            ▼
 I/O await      I/O await      I/O await 
   (挂起)         (挂起)         (挂起)
      │            │            │
HTTP 响应1     HTTP 响应2     HTTP 响应3
      │            │            │
 inFlight--     inFlight--     inFlight--
      └┬──────┐     └┬──────┐     └┬──────┐
       │      │      │      │      │      │
       ▼      ▼      ▼      ▼      ▼      ▼
  processNext  processNext  processNext   ...
  检查队列 &   检查队列 &   检查队列 &
  并发数<3      并发数<3      并发数<3
       ↓      ↓      ↓ 
 发起 next HTTP 请求  … 

五、并发控制与资源管理

无论异步模型如何,在高并发场景下,必须对并发度进行有效管理,否则可能出现:

  • 内存耗尽:过多协程/进程同时运行,导致内存飙升。
  • 连接池耗尽:如 MySQL/Redis 连接池不足,导致请求被拒绝。
  • 下游接口限制:第三方 API 有 QPS 限制,过高并发会被封禁。

常见并发控制手段包括:

  1. 令牌桶/信号量:通过 Channel、Semaphore 等机制限制并发量。
  2. 任务队列/进程池/协程池:预先创建固定数量的“工作单元”,并从队列中取任务执行。
  3. 速率限制(Rate Limiting):使用 Leaky Bucket、Token Bucket 或滑动窗口算法限速。
  4. 超时与重试策略:对超时的异步任务及时取消或重试,避免僵死协程/进程。

下面以 Swoole 协程为例,介绍信号量限速两种并发控制方式。


5.1 信号量(Semaphore)并发控制

Swoole 协程提供了 Swoole\Coroutine\Semaphore 类,可用于限制并发访问某段代码。

示例:并发查询多个数据库并限制并发数

<?php
use Swoole\Coroutine;
use Swoole\Coroutine\MySQL;
use Swoole\Coroutine\Semaphore;

// 假设有若干用户 ID,需要并发查询用户详细信息
$userIds = range(1, 100);

// 最大并发协程数
$maxConcurrency = 10;

// 创建信号量
$sem = new Semaphore($maxConcurrency);

Co\run(function() use ($userIds, $sem) {
    $results = [];
    $wg = new Swoole\Coroutine\WaitGroup();

    foreach ($userIds as $id) {
        // 从信号量中获取一个票,若已达上限,挂起等待
        $sem->wait();

        $wg->add();
        go(function() use ($id, $sem, &$results, $wg) {
            // 连接数据库
            $db = new MySQL();
            $db->connect([
                'host'     => '127.0.0.1',
                'port'     => 3306,
                'user'     => 'root',
                'password' => '',
                'database' => 'test',
            ]);
            $res = $db->query("SELECT * FROM users WHERE id = {$id}");
            $results[$id] = $res;
            $db->close();
            echo "[协程] 查询用户 {$id} 完成\n";

            // 释放一个信号量票
            $sem->release();
            $wg->done();
        });
    }

    $wg->wait();
    echo "[主协程] 所有用户查询完成,共 " . count($results) . " 条数据\n";
    // 处理 $results
});

原理与说明

  1. new Semaphore($maxConcurrency):创建一个最大并发数为 $maxConcurrency 的信号量。
  2. $sem->wait():用于“申请”一个资源票(P 操作);若当前已有 $maxConcurrency 条协程已持有票,则其他协程会被挂起等待。
  3. $sem->release():释放一个资源票(V 操作),如果有协程在等待,会唤醒其中一个。
  4. 结合 WaitGroup,保证所有查询完成后再继续后续逻辑。

5.2 速率限制(限速)示例

在高并发场景,有时需要对同一个下游接口或资源进行限速,避免瞬时并发过多触发封禁。常用算法有 令牌桶(Token Bucket)漏桶(Leaky Bucket)滑动窗口。本文以“令牌桶”算法为例,在协程中简单实现 API QPS 限制。

示例:令牌桶限速

<?php
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;

// 目标 QPS(每秒最多 5 次请求)
$qps = 5;

// 创建一个容量为 $qps 令牌桶 Channel
$bucket = new Channel($qps);

// 持续向桶中投放令牌
go(function() use ($qps, $bucket) {
    while (true) {
        // 如果桶未满,则放入一个令牌
        if (!$bucket->isFull()) {
            $bucket->push(1);
        }
        // 每隔 1/$qps 秒产生一个令牌
        Coroutine::sleep(1 / $qps);
    }
});

// 需要并发发起的总任务数
$totalTasks = 20;

// 等待组
$wg = new Swoole\Coroutine\WaitGroup();

for ($i = 1; $i <= $totalTasks; $i++) {
    $wg->add();
    go(function() use ($i, $bucket, $wg) {
        // 从桶中取出一个令牌,若桶空则等待
        $bucket->pop();
        // 令牌取到后即可发起请求
        echo "[协程 {$i}] 获取令牌,开始请求 API 时间:" . date('H:i:s') . "\n";
        Coroutine::sleep(0.1); // 模拟 API 请求耗时 100ms
        echo "[协程 {$i}] 请求完成 时间:" . date('H:i:s') . "\n";
        $wg->done();
    });
}

$wg->wait();
echo "[主协程] 所有任务完成。\n";

ASCII 图解:令牌桶限速

  (桶满 5 个令牌后,多余的生产操作会 skip)
┌─────────────────────────────────────────────────────────────┐
│                     令牌桶(Channel)                        │
│               capacity = 5 (Max Token)                      │
│   ┌───┬───┬───┬───┬───┐                                      │
│   │ 1 │ 1 │ 1 │ 1 │ 1 │  <- 初始填满 5 个                      │
│   └───┴───┴───┴───┴───┘                                      │
└─────────────────────────────────────────────────────────────┘
      ↑                 ↑                 ↑
      │                 │                 │
[协程 1 pop]        [协程 2 pop]       [协程 3 pop]
      │                 │                 │
 发起请求            发起请求         发起请求
 (now bucket has 2 tokens)    (1 token)      (0 token)
      │                 │                 │
 多余 Pop 时协程会被挂起          │
      └───────────────┬─────────────┘
                      │
             令牌生产协程每 0.2 秒推 1 令牌
                      │
     ┌────────────────┼────────────────┐
     │                │                │
   T+0.2s          T+0.4s           T+0.6s
  bucket:1         bucket:2         bucket:3
     │                │                │
 [协程 4 pop]     [协程 5 pop]     [协程 6 pop]
  发起请求           发起请求           发起请求
  • 桶初始放满 5 个令牌,因此前 5 个协程几乎可瞬时拿到令牌并发起请求。
  • 之后只有当令牌按 1/$qps 秒速率补充时,新的协程才能从桶中拿到令牌并发起请求,从而平滑控制请求 QPS。

六、并发写冲突与锁优化

在高并发写共享资源(如缓存、日志、队列)时,必须避免过度的锁竞争,否则会变成串行模式,扼杀并发增益。

6.1 缓存原子更新示例

假设要对 Redis 或 APCu 中的计数器执行自增操作,传统方式可能是:

<?php
// 非原子操作示例:读-改-写
$count = apcu_fetch('page_view') ?: 0;
$count++;
apcu_store('page_view', $count);
  • 当并发高时,两个进程可能都 fetch=100,然后同时写入 101,导致计数丢失。

原子操作示例

<?php
// 使用 APCu 内置原子自增函数
$newCount = apcu_inc('page_view', 1, $success);
if (!$success) {
    // 如果键不存在,则先写入 1
    apcu_store('page_view', 1);
    $newCount = 1;
}
  • apcu_inc 是原子操作,内部会做加锁,确保并发自增结果准确无误。

6.2 文件锁与异步队列

如果需要对同一个文件或日志进行并发写入,可以将日志写入“异步队列”(如 Channel 或消息队列),然后在单独的写日志协程/进程中顺序消费,避免并发锁:

示例:协程队列写日志

<?php
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;

Co\run(function() {
    // 日志队列 Channel(容量1000)
    $logQueue = new Channel(1000);

    // 日志写入协程(单独一个),顺序消费
    go(function() use ($logQueue) {
        $fp = fopen(__DIR__ . '/app.log', 'a');
        while (true) {
            $entry = $logQueue->pop(); // 阻塞等待日志
            if ($entry === false) {
                // Channel 关闭
                break;
            }
            fwrite($fp, $entry . "\n");
        }
        fclose($fp);
    });

    // 模拟多个业务协程并发写日志
    for ($i = 1; $i <= 50; $i++) {
        go(function() use ($i, $logQueue) {
            $msg = "[协程 {$i}] 这是一条日志,时间:" . date('H:i:s');
            $logQueue->push($msg);
        });
    }

    // 等待一定时间后关闭日志队列
    Coroutine::sleep(1);
    $logQueue->close(); // 关闭 Channel,让日志写入协程退出
});
  • 原理:所有协程都将日志数据 push 到共享队列,单独的日志写协程依次 pop 并写入文件,避免多协程同时 fopen/fwrite 竞争。
  • 该模式也可用于“任务队列消费”、“图片处理队列”等高并发写场景。

七、总结与最佳实践

在高并发场景下,PHP 应用要想获得优异性能,需要结合业务场景与技术选型,合理利用异步与并发控制。本文从以下几个方面给出了详尽示例与说明:

  1. 阻塞 vs 非阻塞

    • 传统同步阻塞模型容易导致请求累加等待,吞吐下降。
    • 通过 Swoole 协程、ReactPHP、Amp 等框架可实现异步非阻塞,提升 I/O 并发度。
  2. Swoole 协程示例

    • 并发发 HTTP 请求:利用 go() + WaitGroup 实现简单并发调用。
    • 并发控制:借助 ChannelSemaphore 实现令牌桶或协程池,限制同时运行的协程数量,保护系统资源。
  3. ReactPHP 事件循环示例

    • 使用事件循环与 Promise 模式对大批量请求进行异步并发,并通过手动队列管理控制并发度。
  4. 并发写冲突与异步队列

    • 对共享资源(如文件、日志、缓存)并发写时,应尽量使用原子操作或将写操作集中到单独的协程/进程中顺序执行,避免锁竞争。
  5. 速率限制(Rate Limiting)

    • 通过令牌桶算法简单实现 QPS 控制,确保下游接口调用不会被超载或封禁。
  6. 常见 Pitfall 与注意事项

    • PCNTLParallelSwoole 各有使用场景与系统依赖,不同场合下需要灵活选型。
    • 异步代码中要避免使用阻塞 I/O,否则整个协程/事件循环会被挂起。
    • 必须对“并发度”进行限制,避免系统瞬间创建过多协程/进程导致资源耗尽。
    • 在协程环境下,原生函数会被 hook,确保使用 Swoole 协程安全的客户端(如 Swoole\Coroutine\MySQLSwoole\Coroutine\Http\Client 等)。

最佳实践总结

  1. 如果项目仅需并发简单任务(比如几百个独立操作),可优先选择 Swoole 协程,开发成本低、性能佳;
  2. 如果需要兼容更底层的 PHP 版本,或只需在 CLI 环境下快速多进程,可选择 PCNTL
  3. 若需要在纯 PHP 生态(无扩展)下实现异步,且对回调/Promise 接受度高,可使用 ReactPHPAmp
2025-06-09

Redis与MySQL数据库数据一致性保持策略

在高并发系统中,Redis 常被用作缓存层,MySQL 作为持久化存储。如何保证两者之间数据的一致性,是设计时必须解决的关键问题。本文将从以下几个方面展开讲解,并配以代码示例Mermaid 图解详细说明,帮助读者快速理解并上手实践。


1. 引言

  • 背景

    • Redis:高性能内存缓存,读写速度极快。
    • MySQL:可靠的关系型数据库,负责持久化存储。
  • 挑战

    • 当数据在 Redis(缓存)和 MySQL(数据库)之间存在更新操作时,如果操作顺序或策略不当,就可能导致“脏数据”或“缓存击穿”等问题。
    • 典型场景:应用先修改数据库,再同步/删除缓存;或先删除缓存,再修改数据库;中间一旦出现异常或并发,就会出现一致性问题。
  • 目标

    • 介绍主流的缓存一致性模式:Cache Aside、Write Through、Write Behind、延迟双删等。
    • 用代码示例体现核心思想,并通过 Mermaid 图解展示整体数据流。

2. 数据一致性挑战

2.1 缓存与数据库的常见不一致场景

  1. 先写缓存,后写数据库,写数据库失败

    • 现象:缓存已更新,但数据库写入出错,导致数据库中仍是旧值,一旦缓存失效,读取到旧值。
  2. 先写数据库,后删除缓存,删除失败

    • 现象:缓存仍存旧值,业务读取到脏数据。
  3. 并发更新导致的“脏写”

    • 两个线程同时更新某条数据,线程 A 先删除缓存、更新数据库;线程 B 读取数据库写入缓存,导致 A 的更新被 B 的旧值覆盖。

2.2 常见一致性指标

  • 强一致性:对所有客户端而言,读到的数据与最新写操作保持一致。
  • 最终一致性:允许短暂的不一致,但经过一定时间后,缓存与数据库最终会达到一致。
  • 弱一致性:对并发操作不作保证,不一致窗口可能较长。

在绝大多数业务场景里,我们追求最终一致性,并通过设计将不一致窗口尽可能缩短。


3. 基本缓存策略概述

Redis 与 MySQL 保持一致性,通常依赖以下几种模式:

  1. Cache Aside(旁路缓存,懒加载 + 延迟双删)
  2. Write Through(写缓存同时写数据库)
  3. Write Behind(写缓存后异步落库)
  4. Read Through(先读缓存,缓存未命中则读库并回写缓存)
  5. 分布式锁 + 事务补偿/事务消息
  6. 两阶段提交 / TCC 方案(对于强一致性要求极高的场景)

下面依次展开。


4. Cache Aside 模式

4.1 概述

  • 核心思想

    • 业务先操作数据库,再删除/更新缓存。
    • 读取时:先查 Redis 缓存,若命中则直接返回;若未命中,再从 MySQL 读取,并将结果回写到 Redis。
  • 优点

    • 简单易懂,适用广泛。
    • 读多写少场景下,能极大提升读性能。
  • 缺点

    • 写操作存在短暂的不一致窗口(数据库提交到缓存删除/更新之间)。
    • 需要结合“延迟双删”或“分布式锁”来进一步缩短不一致时间。

4.2 延迟双删防止并发写导致脏数据

当并发写操作发生时,单纯的“先删除缓存,再写数据库”并不能完全消除脏数据。常见的延迟双删策略如下:

  1. 线程 A / B 都准备更新 key=K:

    • 先删除缓存:DEL K
    • 更新数据库
    • 等待一定时间(例如 50ms)
    • 再次删除缓存:DEL K

通过两次删除,尽量避免另一线程在数据库更新完成后把旧值重新写入缓存。

4.3 工作流程图(Mermaid 图解)

flowchart LR
    subgraph 读请求
        A1[应用] -->|get(K)| B1[Redis: GET K]
        B1 -->|命中| C1[返回数据]
        B1 -->|未命中| D1[MySQL: SELECT * FROM table WHERE id=K]
        D1 --> E1[返回结果]
        E1 -->|SET K ...| B1
        E1 --> F1[返回数据]
    end

    subgraph 写请求(延迟双删)
        A2[应用] -->|DEL K| B2[Redis: DEL K]
        B2 -->|执行| C2[MySQL: UPDATE table SET ... WHERE id=K]
        C2 --> D2[等待 ∆t (如 50ms)]
        D2 --> E2[Redis: DEL K]
    end
  • 图示说明图示说明

    上图展示了读请求和写请求的主要流程,其中写请求使用了“延迟双删”策略:先删缓存、更新数据库、最后再删一次缓存。

4.4 代码示例(Java + Jedis + JDBC)

以下示例代码演示如何在 Java 中使用 Jedis 操作 Redis,并使用 JDBC 操作 MySQL,实现 Cache Aside + 延迟双删。

import redis.clients.jedis.Jedis;
import java.sql.*;
import java.time.Duration;

public class CacheAsideExample {
    private static final String REDIS_HOST = "localhost";
    private static final int REDIS_PORT = 6379;
    private static final String JDBC_URL = "jdbc:mysql://localhost:3306/testdb";
    private static final String JDBC_USER = "root";
    private static final String JDBC_PASS = "password";
    private Jedis jedis;
    private Connection conn;

    public CacheAsideExample() throws SQLException {
        jedis = new Jedis(REDIS_HOST, REDIS_PORT);
        conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASS);
    }

    /**
     * 读取操作:先查缓存,未命中则查库并回写缓存
     */
    public String getUserById(String userId) throws SQLException {
        String cacheKey = "user:" + userId;
        // 1. 先查询 Redis 缓存
        String userJson = jedis.get(cacheKey);
        if (userJson != null) {
            return userJson; // 缓存命中
        }
        // 2. 缓存未命中,查询 MySQL
        String sql = "SELECT id, name, age FROM users WHERE id = ?";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, userId);
            ResultSet rs = ps.executeQuery();
            if (rs.next()) {
                // 假设将用户信息转换为 JSON 字符串
                userJson = String.format("{\"id\":\"%s\",\"name\":\"%s\",\"age\":%d}",
                        rs.getString("id"), rs.getString("name"), rs.getInt("age"));
                // 3. 回写 Redis,设置合理过期时间
                jedis.setex(cacheKey, (int) Duration.ofMinutes(5).getSeconds(), userJson);
                return userJson;
            } else {
                return null;
            }
        }
    }

    /**
     * 写操作:延迟双删策略
     */
    public void updateUser(String userId, String newName, int newAge) throws SQLException, InterruptedException {
        String cacheKey = "user:" + userId;
        // 1. 删除缓存
        jedis.del(cacheKey);

        // 2. 更新数据库
        String sqlUpdate = "UPDATE users SET name = ?, age = ? WHERE id = ?";
        try (PreparedStatement ps = conn.prepareStatement(sqlUpdate)) {
            ps.setString(1, newName);
            ps.setInt(2, newAge);
            ps.setString(3, userId);
            ps.executeUpdate();
        }

        // 3. 延迟一段时间再次删除缓存,防止脏数据
        Thread.sleep(50); // 延迟 50ms
        jedis.del(cacheKey);
    }

    public void close() {
        jedis.close();
        try { conn.close(); } catch (SQLException ignored) {}
    }

    public static void main(String[] args) throws Exception {
        CacheAsideExample example = new CacheAsideExample();

        // 演示写操作
        example.updateUser("1001", "张三", 30);

        // 演示读操作
        String userData = example.getUserById("1001");
        System.out.println("User Data: " + userData);

        example.close();
    }
}

代码说明

  1. getUserById

    • 先尝试从 Redis 获取 user:1001
    • 如果命中直接返回,如果未命中则查询 MySQL,得到结果后写入 Redis 并设置过期时间(5 分钟)。
  2. updateUser

    • 第一次 jedis.del(cacheKey) 删除缓存,防止旧值被读取。
    • 执行 MySQL 更新。
    • 睡眠 50ms 后,再次 jedis.del(cacheKey) 二次删除,以避免并发写入脏数据。
注意:延迟时长 50ms 并非固定值,根据业务场景可调整,但要确保比典型数据库写入并发场景稍长,足以避免同一时刻另一个线程将“旧值”写入缓存。

5. Write Through 模式

5.1 概述

  • 核心思想

    • 应用对数据的 写操作先写入 Redis 缓存,然后再写入 MySQL。
    • 同时也可将写操作封装在一个统一接口中,保证读写一致性。
  • 优点

    • 读写均在缓存层完成,读速度非常快。
    • 保证了缓存与数据库数据几乎同时更新,若写数据库失败(回滚),需要同步将缓存回滚或删除。
  • 缺点

    • 写操作的吞吐量受 Redis & MySQL 并发写性能影响,通常写延迟较高。
    • 写失败时,需要考虑保证缓存与数据库回滚一致,否则会出现脏数据。

5.2 工作流程图(Mermaid 图解)

flowchart LR
    A[应用] -->|SET K->V| B[Redis: SET K V]
    B -->|OK| C[MySQL: INSERT/UPDATE table SET ...]
    C -->|失败?| D{失败?}
    D -- 是 --> E[Redis: DEL K 或 回滚操作]
    D -- 否 --> F[写操作结束,返回成功]

5.3 代码示例(Java + Jedis + JDBC)

public class WriteThroughExample {
    private Jedis jedis;
    private Connection conn;

    public WriteThroughExample() throws SQLException {
        jedis = new Jedis("localhost", 6379);
        conn = DriverManager.getConnection(
                "jdbc:mysql://localhost:3306/testdb", "root", "password");
    }

    /**
     * 写操作:先写 Redis,再写 MySQL。
     */
    public void saveUser(String userId, String name, int age) {
        String cacheKey = "user:" + userId;
        String userJson = String.format("{\"id\":\"%s\",\"name\":\"%s\",\"age\":%d}", userId, name, age);

        // 1. 写 Redis 缓存
        jedis.setex(cacheKey, 300, userJson); // 5 分钟过期

        // 2. 写 MySQL
        String sql = "REPLACE INTO users(id, name, age) VALUES(?, ?, ?)";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, userId);
            ps.setString(2, name);
            ps.setInt(3, age);
            ps.executeUpdate();
        } catch (SQLException e) {
            // 3. 如果写数据库失败,则删除缓存,避免脏数据
            jedis.del(cacheKey);
            throw new RuntimeException("保存用户失败,已删除缓存", e);
        }
    }

    public void close() {
        jedis.close();
        try { conn.close(); } catch (SQLException ignored) {}
    }

    public static void main(String[] args) throws Exception {
        WriteThroughExample example = new WriteThroughExample();
        example.saveUser("1002", "李四", 28);
        example.close();
    }
}

代码说明

  1. 先写 Redis:确保缓存层保存了最新数据,后续读操作会从缓存命中。
  2. 再写 MySQL:若插入/更新 MySQL 成功,流程结束;若失败则删除缓存,避免数据不一致。

注意事项

  • 事务原子性:若存在复杂逻辑,需要确保 Redis 和 MySQL 的写操作要么同时成功,要么同时失败。
  • 在高并发场景下,Write Through 会降低写性能,因为必须等待两端都写完才能返回。

6. Write Behind 模式

6.1 概述

  • 核心思想

    • 应用只写入 Redis 缓存,不立即写数据库。
    • Cache Layer 维护一个异步队列/队列缓存,将写请求累积并在后台定期或触发条件时批量刷入 MySQL。
  • 优点

    • 写操作速度非常快,仅操作 Redis。
    • 利用批量写库,提升数据库写入吞吐量。
  • 缺点

    • 如果异步刷库任务出现故障或服务宕机,将导致数据丢失。
    • 数据最终一致性延迟较高,不适合对实时性要求高的场景。

6.2 工作流程图(Mermaid 图解)

flowchart LR
    A[应用] -->|SET K->V| B[Redis: SET K V 并将 K 加入待刷库队列]
    B --> C[返回成功]
    subgraph 刷库线程
        D[检查待刷库队列] -->|批量取出若干条| E[MySQL: BATCH UPDATE]
        E -->|刷入成功?| F{成功?}
        F -- 是 --> G[从队列移除相应 Key]
        F -- 否 --> H[日志/重试机制]
    end

6.3 代码示例(Java + Jedis)

以下示例演示一种简化版的 Write Behind:

  • 使用 Redis 列表(List)维护待刷库的 Key 列表。
  • 后台线程每隔固定时间(如 1s)批量从队列读取,一次性执行 MySQL 更新。
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;

import java.sql.*;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;

public class WriteBehindExample {
    private static final String REDIS_HOST = "localhost";
    private static final int REDIS_PORT = 6379;
    private Jedis jedis;
    private Connection conn;
    private static final String QUEUE_KEY = "cache_to_db_queue";

    public WriteBehindExample() throws SQLException {
        jedis = new Jedis(REDIS_HOST, REDIS_PORT);
        conn = DriverManager.getConnection(
                "jdbc:mysql://localhost:3306/testdb", "root", "password");
        // 启动后台刷库定时任务
        startFlushTimer();
    }

    /**
     * 写操作:写 Redis 缓存,并将 Key 放入队列
     */
    public void saveUserAsync(String userId, String name, int age) {
        String cacheKey = "user:" + userId;
        String userJson = String.format("{\"id\":\"%s\",\"name\":\"%s\",\"age\":%d}", userId, name, age);

        // 1. 写 Redis,并将待刷库的 Key 放入 List 列表
        Pipeline p = jedis.pipelined();
        p.setex(cacheKey, 300, userJson); // 5 分钟过期
        p.lpush(QUEUE_KEY, cacheKey);
        p.sync();
    }

    /**
     * 后台定时任务:批量刷库
     */
    private void startFlushTimer() {
        Timer timer = new Timer(true);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                flushCacheToDb();
            }
        }, 1000, 1000); // 延迟 1s 启动,每 1s 执行一次
    }

    /**
     * 从 Redis 列表中批量取出待刷库 Key,查询对应缓存值并写入 MySQL
     */
    private void flushCacheToDb() {
        try {
            // 一次性取出最多 100 条待刷库 key
            List<String> keys = jedis.lrange(QUEUE_KEY, 0, 99);
            if (keys == null || keys.isEmpty()) {
                return;
            }

            // 开启事务
            conn.setAutoCommit(false);
            String sql = "REPLACE INTO users(id, name, age) VALUES(?, ?, ?)";
            try (PreparedStatement ps = conn.prepareStatement(sql)) {
                for (String cacheKey : keys) {
                    String userJson = jedis.get(cacheKey);
                    if (userJson == null) {
                        // 缓存可能已过期或被删除,跳过
                        jedis.lrem(QUEUE_KEY, 0, cacheKey);
                        continue;
                    }
                    // 简单解析 JSON(生产环境请使用更健壮的 JSON 序列化库)
                    // 假设格式为 {"id":"1003","name":"王五","age":25}
                    String[] parts = userJson.replaceAll("[{}\"]", "")
                            .split(",");
                    String id = parts[0].split(":")[1];
                    String name = parts[1].split(":")[1];
                    int age = Integer.parseInt(parts[2].split(":")[1]);

                    ps.setString(1, id);
                    ps.setString(2, name);
                    ps.setInt(3, age);
                    ps.addBatch();
                }
                ps.executeBatch();
                conn.commit();
                // 批量删除已刷库 key
                jedis.ltrim(QUEUE_KEY, keys.size(), -1);
            } catch (SQLException e) {
                conn.rollback();
                // 日志记录,生产环境可加入重试机制
                System.err.println("刷库失败,稍后重试:" + e.getMessage());
            } finally {
                conn.setAutoCommit(true);
            }
        } catch (Exception ex) {
            // 捕获 Redis 或其他异常,保证定时任务不中断
            System.err.println("flushCacheToDb 异常:" + ex.getMessage());
        }
    }

    public void close() {
        jedis.close();
        try { conn.close(); } catch (SQLException ignored) {}
    }

    public static void main(String[] args) throws Exception {
        WriteBehindExample example = new WriteBehindExample();
        // 演示写操作
        example.saveUserAsync("1003", "王五", 25);
        // 程序可继续处理其他逻辑,后台线程负责刷库
    }
}

代码说明

  1. saveUserAsync

    • 仅写入 Redis,并把 user:1003 压入 cache_to_db_queue 列表,表示待落库。
  2. flushCacheToDb

    • 定时任务每秒执行一次,从列表中批量获取待落库的 Key,比如最多 100 条。
    • 对每个 Key,从 Redis 中读取缓存值(JSON 字符串),将解析后的字段写入 MySQL。
    • 成功后调用 ltrim 将已处理的队列数据清除。
    • 若写库失败则回滚,并记录日志,下一次任务会重新读取队列继续写入。

风险提示

  • 如果应用或后台线程进程意外挂掉,Redis 列表中的数据可能长时间无法落库,导致缓存与数据库不一致。
  • 建议在生产环境结合消息队列(如 Kafka、RabbitMQ)或 Redis Stream,以保证刷库任务的高可靠性。

7. 分布式锁与事务补偿

7.1 分布式锁

当并发写同一条数据时,可通过 Redis 分布式锁(如 Redisson、Jedis 的 SETNX)为写操作上锁,保证同一时刻只有一台应用实例执行更新,从而避免脏写。例如:

// 简化示例,建议使用 Redisson 等成熟的分布式锁库
public void updateUserWithLock(String userId, String newName, int newAge) throws InterruptedException {
    String lockKey = "lock:user:" + userId;
    String requestId = UUID.randomUUID().toString();
    // 尝试获取锁
    boolean locked = jedis.set(lockKey, requestId, "NX", "PX", 5000) != null;
    if (!locked) {
        throw new RuntimeException("获取锁失败,请稍后重试");
    }
    try {
        // 1. 删除缓存
        jedis.del("user:" + userId);
        // 2. 更新数据库
        // ...
        // 3. 延迟双删或直接更新缓存
        Thread.sleep(50);
        jedis.del("user:" + userId);
    } finally {
        // 释放锁,必须确保 requestId 一致才删除
        String val = jedis.get(lockKey);
        if (requestId.equals(val)) {
            jedis.del(lockKey);
        }
    }
}

7.2 事务补偿 / 消息队列

对于写入数据库失败后,缓存已被更新/删除但数据库未提交的场景,还可以结合本地事务消息二阶段提交进行补偿。典型思路:

  1. 写本地事务消息表

    • 将待执行的缓存操作与数据库操作放在同一个本地事务里。
    • 如果数据库提交成功,则消息表写入成功;若提交失败,则本地事务回滚,缓存也不更新。
  2. 异步投递/确认

    • 后台异步线程扫描消息表,将消息投递到消息队列(如 Kafka)。
    • 消费端收到消息后执行缓存更新与数据库最终落库或补偿逻辑。

该方案较为复杂,适用于对强一致性要求极高的场景。


8. 其他一致性模式简介

8.1 Read Through

  • 描述:应用直接对缓存层发起读请求,若缓存未命中,缓存层自身会从数据库加载并回写缓存。
  • 特点:用起来更像“透明缓存”,业务不需要显式编写“先查缓存、未命中查库、回写缓存”的逻辑。但需要使用支持 Read Through 功能的缓存客户端或中间件(如某些商业缓存解决方案)。

8.2 两阶段提交(2PC)/ TCC

  • 2PC(两阶段提交)

    • 要求分布式事务协调者(Coordinator)协调缓存更新与数据库更新两个阶段。
    • 阶段 1(Prepare):通知各参与者预备提交;如果所有参与者都准备就绪,则进入阶段 2。
    • 阶段 2(Commit/Rollback):通知各参与者正式提交或回滚。
  • TCC(Try-Confirm-Cancel)

    • Try:各参与者尝试预占资源(如锁定缓存、预写日志等)。
    • Confirm:各参与者确认实际提交。
    • Cancel:各参与者进行回滚。
  • 优缺点

    • 优点:能保证严格的强一致性
    • 缺点:性能开销大,编程复杂度高,且存在锁等待、阻塞等问题,不适用于极高吞吐场景。

9. 总结与最佳实践

  1. 优先采用 Cache Aside(延迟双删 + 分布式锁)模式

    • 简单、易实现,对于大部分读多写少场景能满足一致性要求。
    • 延迟双删能够在高并发下显著减少脏数据出现概率。
    • 分布式锁可以进一步控制并发更新并发写时对缓存的多次操作顺序。
  2. 针对写多场景,可考虑 Write Through 或 Write Behind

    • Write Through:适合对读取延迟要求极高、写性能要求相对一般的场景。
    • Write Behind:适合对写性能要求极高,但可容忍一定最终一致性延迟的场景。注意后台刷库任务的高可靠性及消息持久化。
  3. 严谨场景下可使用分布式事务或 TCC

    • 对一致性要求绝对严格且能够接受额外延迟与复杂度的业务,比如金融系统的流水账务。
    • 尽量减少全链路分布式事务的使用范围,只将关键操作纳入。
  4. 合理设计缓存过期时间与热点数据策略

    • 常见做法是:热点数据设置较长的过期时间,非热点数据使用合理的过期策略以节省内存。
    • 对于热点“雪崩”场景,可结合随机化过期时间、互斥锁重建缓存或提前预热等方式。
  5. 监控与报警

    • 建立缓存命中率监控、数据库写入失败监控、后台刷库积压监控等。
    • 及时发现缓存与数据库不一致的风险,并进行人工或自动补偿。

10. 全文小结

  • Redis 与 MySQL 保持数据一致性,核心在于设计合理的缓存读写策略,将不一致窗口尽量缩短,并根据业务需求权衡性能与一致性。
  • 本文重点介绍了常见的 Cache Aside(延迟双删)、Write Through、Write Behind 模式,并配以 Mermaid 图解,帮助你快速理解整体流程。
  • 代码示例(Java + Jedis + JDBC) 则直观演示各模式下的具体实现细节。
  • 最后,还简要介绍了分布式锁、事务补偿、两阶段提交等进阶方案,供对一致性要求更高的场景参考。

目录

  1. 分布式 Session 的背景与挑战
  2. 常见的分布式 Session 解决方案
    2.1. 基于“会话粘滞”(Sticky Session)的负载均衡
    2.2. 中央化会话存储:Redis、数据库等
    2.3. 客户端 Token:JWT(JSON Web Token)方案
    2.4. 对比与选型建议
  3. 一致性哈希基础与原理
    3.1. 何为一致性哈希?为什么要用它?
    3.2. 一致性哈希环(Hash Ring)的结构
    3.3. 虚拟节点(Virtual Node)与热点均衡
  4. 一致性哈希的详细实现
    4.1. 环形逻辑与节点映射示意
    4.2. 插入与查找流程图解(ASCII 版)
    4.3. 节点增删带来的最小重映射特性
  5. 代码示例:用 Java 实现简单一致性哈希
    5.1. 核心数据结构:TreeMap 维护 Hash 环
    5.2. 虚拟节点生成与映射逻辑
    5.3. 添加/删除物理节点的逻辑实现
    5.4. 根据 Key 查找对应节点
  6. 分布式 Session 与一致性哈希结合
    6.1. Redis 集群与 Memcached 集群中的一致性哈希
    6.2. 使用一致性哈希分布 Session 到多个缓存节点的示例
    6.3. 节点扩容/缩容时 Session 数据重分布的平滑性
  7. 图解:一致性哈希在分布式 Session 中的应用
  8. 性能、可靠性与实际落地注意事项
  9. 总结

1. 分布式 Session 的背景与挑战

在单体应用中,HTTP Session 通常存储在应用服务器(如 Tomcat)的内存里,只要请求都落在同一台机器,Session 能正常保持。然而在现代微服务或集群化部署场景下,引入多台应用实例、负载均衡(如 Nginx、LVS、F5)后,请求可能被路由到任意一台实例,导致“Session 丢失”或“用户登录态丢失”。

常见问题包括:

  • 会话粘滞要求高:需要保证同一用户的连续请求都落到同一台机器才能访问到对应的 Session,这种“粘滞”配置在大规模集群中维护复杂。
  • 扩展难度大:如果在某台服务器上存储了大量 Session,那么该服务器资源紧张时难以水平扩展。
  • 单点故障风险:一个应用实例宕机,保存在它内存中的所有 Session 都会丢失,导致用户需重新登录。
  • 性能与可靠性平衡:Session 写入频繁、内存占用高,要么放入数据库(读写延迟)、要么放入缓存(易受网络抖动影响)。

因此,如何在多实例环境下,既能保证 Session 的可用性、一致性,又能方便扩容与高可用,成为许多项目的核心需求。


2. 常见的分布式 Session 解决方案

面对上述挑战,业界产生了多种方案,大致可以分为以下几类。

2.1. 基于“会话粘滞”(Sticky Session)的负载均衡

原理:在负载均衡层(如 Nginx、LVS、F5)配置“会话粘滞”(也称“Session Affinity”),根据 Cookie、源 IP、请求路径等规则,将同一用户的请求固定路由到同一个后端应用实例。

  • 优点

    • 实现简单,不需要改造应用代码;
    • 只要应用实例下线,需要将流量迁移到其他节点即可。
  • 缺点

    • 粘滞规则有限,若该主机宕机,所有 Session 都丢失;
    • 在扩容/缩容时无法做到平滑迁移,容易引发部分用户断开;
    • 难以对 Session 进行统一管理与共享,无法跨实例读取;

配置示例(Nginx 基于 Cookie 粘滞)

upstream backend_servers {
    ip_hash;  # 基于客户端 IP 粘滞
    server 10.0.0.101:8080;
    server 10.0.0.102:8080;
    server 10.0.0.103:8080;
}

server {
    listen 80;
    location / {
        proxy_pass http://backend_servers;
    }
}

或使用 sticky 模块基于专用 Cookie:

upstream backend {
    sticky cookie srv_id expires=1h path=/;  
    server 10.0.0.101:8080;
    server 10.0.0.102:8080;
    server 10.0.0.103:8080;
}

server {
    listen 80;
    location / {
        proxy_pass http://backend;
    }
}

2.2. 中央化会话存储:Redis、数据库等

原理:将所有 Session 信息从本地内存抽取出来,集中存储在一个外部存储(Session Store)里。常见做法包括:

  • Redis:使用高性能内存缓存,将 Session 序列化后存入 Redis。应用读取时,携带某个 Session ID(Cookie),后端通过该 ID 从 Redis 拉取会话数据。
  • 关系数据库:将 Session 存到 MySQL、PostgreSQL 等数据库中;不如 Redis 性能高,但持久化与备份更简单。
  • Memcached:类似 Redis,用于短生命周期、高并发访问的 Session 存储。

优点

  • 所有实例共享同一个 Session 存储,扩容时无需粘滞;
  • 可以针对 Redis 集群做高可用部署,避免单点故障;
  • 支持 Session 过期自动清理;

缺点

  • 外部存储成为瓶颈,高并发时需要更大规模的缓存集群;
  • Session 序列化/反序列化开销、网络延迟;
  • 写入频率极高时(如每次请求都更新 Session),带来较大网络与 CPU 压力。

Java + Spring Boot 集成 Redis 存储 Session 示例

  1. 引入依赖pom.xml):

    <!-- Spring Session Data Redis -->
    <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session-data-redis</artifactId>
        <version>2.5.0</version>
    </dependency>
    <!-- Redis 连接客户端 Lettuce -->
    <dependency>
        <groupId>io.lettuce</groupId>
        <artifactId>lettuce-core</artifactId>
        <version>6.1.5.RELEASE</version>
    </dependency>
  2. 配置 Redis 连接与 Session 存储application.yml):

    spring:
      redis:
        host: localhost
        port: 6379
      session:
        store-type: redis
        redis:
          namespace: myapp:sessions  # Redis Key 前缀
        timeout: 1800s   # Session 过期 30 分钟
  3. 启用 Spring Session(主程序类):

    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
    
    @SpringBootApplication
    @EnableRedisHttpSession
    public class MyApplication {
        public static void main(String[] args) {
            SpringApplication.run(MyApplication.class, args);
        }
    }
  4. Controller 读写 Session 示例

    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.servlet.http.HttpSession;
    
    @RestController
    public class SessionController {
    
        @GetMapping("/setSession")
        public String setSession(HttpSession session) {
            session.setAttribute("username", "alice");
            return "Session 存入 username=alice";
        }
    
        @GetMapping("/getSession")
        public String getSession(HttpSession session) {
            Object username = session.getAttribute("username");
            return "Session 读取 username=" + (username != null ? username : "null");
        }
    }
  • 当用户访问 /setSession 时,会在 Redis 中写入 Key 类似:

    myapp:sessions:0e3f48a6-...-c8b42dc7f0

    Value 部分是序列化后的 Session 数据。

  • 下次访问任意实例的 /getSession,只要携带相同的 Cookie(SESSION=0e3f48a6-...),即可在 Redis 成功读取到之前写入的 username

2.3. 客户端 Token:JWT(JSON Web Token)方案

原理:将用户登录态信息打包到客户端的 JWT Token 中,无需在服务器存储 Session。典型流程:

  1. 用户登录后,服务端根据用户身份生成 JWT Token(包含用户 ID、过期时间、签名等信息),并将其返回给客户端(通常存在 Cookie 或 Authorization 头中)。
  2. 客户端每次请求都带上 JWT Token,服务端验证 Token 的签名与有效期,若合法则直接从 Token 中解析用户身份,不需访问 Session 存储。

优点

  • 完全无状态,减少后端存储 Session 的开销;
  • 方便跨域、跨域名访问,适合微服务、前后端分离场景;
  • Token 自带有效期,不易被伪造;

缺点

  • Token 大小通常较大(包含签名与 Payload),会增加每次 HTTP 请求头部大小;
  • 无法服务端主动“销毁”某个 Token(除非维护黑名单),不易应对强制登出或登录审计;
  • Token 本身包含信息,一旦泄露风险更大。

Spring Boot + JWT 示例(非常简化版,仅供思路):

  1. 引入依赖pom.xml):

    <!-- JWT 库 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>
  2. 生成与验证 Token 的工具类

    import io.jsonwebtoken.Claims;
    import io.jsonwebtoken.Jwts;
    import io.jsonwebtoken.SignatureAlgorithm;
    
    import java.util.Date;
    
    public class JwtUtil {
        private static final String SECRET_KEY = "MySecretKey12345";  // 应该放在配置中
    
        // 生成 Token
        public static String generateToken(String userId) {
            long expirationMillis = 3600000; // 1 小时
            return Jwts.builder()
                    .setSubject(userId)
                    .setIssuedAt(new Date())
                    .setExpiration(new Date(System.currentTimeMillis() + expirationMillis))
                    .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                    .compact();
        }
    
        // 验证 Token 并解析用户 ID
        public static String validateToken(String token) {
            Claims claims = Jwts.parser()
                    .setSigningKey(SECRET_KEY)
                    .parseClaimsJws(token)
                    .getBody();
            return claims.getSubject();  // 返回用户 ID
        }
    }
  3. 登录接口示例

    @RestController
    public class AuthController {
    
        @PostMapping("/login")
        public String login(@RequestParam String username, @RequestParam String password) {
            // 简化,假设登录成功后
            String userId = "user123";
            String token = JwtUtil.generateToken(userId);
            return token;  // 客户端可存储到 Cookie 或 localStorage
        }
    }
  4. 拦截器或过滤器校验 Token

    @Component
    public class JwtFilter extends OncePerRequestFilter {
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
            String token = request.getHeader("Authorization");
            if (token != null && token.startsWith("Bearer ")) {
                token = token.substring(7);
                try {
                    String userId = JwtUtil.validateToken(token);
                    // 将 userId 写入 SecurityContext 或 request attribute
                    request.setAttribute("userId", userId);
                } catch (Exception e) {
                    response.setStatus(HttpStatus.UNAUTHORIZED.value());
                    response.getWriter().write("Invalid JWT Token");
                    return;
                }
            }
            filterChain.doFilter(request, response);
        }
    }

2.4. 对比与选型建议

方案优点缺点适用场景
会话粘滞(Sticky)实现简单,无需改代码单点故障;扩缩容不平滑小规模、对可用性要求不高的集群
中央化存储(Redis/DB)易扩展;支持集群高可用;Session 可跨实例共享网络与序列化开销;存储层压力大绝大多数中大型 Web 应用
JWT Token(无状态)无需后端存储;跨域、跨语言Token 无法强制过期;Token 大小影响性能微服务 API 网关;前后端分离场景
  • 如果是传统 Java Web 应用,且引入了 Redis 集群,则基于 Redis 存储 Session 是最常见的做法。
  • 如果是前后端分离、移动端或 API 场景,推荐使用JWT Token,保持无状态。
  • 如果是简单 demo 或测试环境,也可直接配置会话粘滞,但生产环境不建议。

3. 一致性哈希基础与原理

在“中央化存储”方案中,往往会搭建一个缓存集群(如多台 Redis 或 Memcached)。如何将请求均衡地分布到各个缓存节点?传统做法是“取模”hash(key) % N,但它存在剧烈的“缓存雪崩”问题:当缓存节点增加或减少时,绝大部分 Keys 会被映射到新的节点,导致大量缓存失效、击穿后端数据库。

一致性哈希(Consistent Hashing) 正是在这种场景下应运而生,保证在节点变动(增删)时,只会导致最小数量的 Keys 重新映射到新节点,极大降低缓存失效冲击。

3.1. 何为一致性哈希?为什么要用它?

  • 传统取模(Modulo)缺点:假设有 3 台缓存节点,节点编号 0、1、2,Node = hash(key) % 3。若扩容到 4 台(编号 0、1、2、3),原来的大部分 Key 的 hash(key) % 3 结果无法直接映射到新的 hash(key) % 4,必须全部重新分布。
  • 一致性哈希思想

    1. 将所有节点和 Keys 都映射到同一个“环”上(0 到 2³²−1 的哈希空间),通过哈希函数计算各自在环上的位置;
    2. Key 的节点归属:顺时针找到第一个大于等于 Key 哈希值的节点(如果超过最大值,则回到环起点);
    3. 节点增删时,仅影响相邻的 Key —— 新节点插入后,只会“抢走”后继节点的部分 Key,删除节点时只会让它所负责的部分 Key 迁移到下一个节点;
  • 最小重映射特性:对于 N 个节点,添加一个节点导致约 1/(N+1) 的 Keys 重新映射;删除节点同理。相比取模几乎 100% 重映射,一致性哈希能极大提升数据平稳性。

3.2. 一致性哈希环(Hash Ring)的结构

  • 将哈希空间视为一个环(0 到 2³²−1 循环),节点与 Key 都通过相同哈希函数 H(x)(如 MD5、SHA-1、CRC32 等)映射到这个环上。
  • 使用可排序的数据结构(如有序数组、TreeMap)维护节点在环上的位置。
  • 当需要查找 Key 的节点时,通过 H(key) 计算 Key 在环上的位置,在 TreeMap 中查找第一个大于等于该位置的节点,若不存在则取 TreeMap.firstKey()(环的起点)。
    0                                               2^32 - 1
    +------------------------------------------------+
    |0 →●              ●           ●           ●    |
    |       NodeA     NodeB      NodeC      NodeD   |
    +------------------------------------------------+
    (顺时针:0 → ... → 2^32−1 → 0)
  • 假设 Key “mySession123” 哈希到 H(mySession123) = 1.2e9,在环上找到最近顺时针的节点(如 NodeB),则该 Key 存储在 NodeB 上。

3.3. 虚拟节点(Virtual Node)与热点均衡

  • 问题:真实节点数量较少时,哈希函数在环上分布不均匀,少数节点可能“背负”大量 Key,出现负载不均。
  • 解决方案:虚拟节点

    • 为每个真实节点生成 M 个虚拟节点,表示为 NodeA#1NodeA#2 等,在哈希环上散布 M 个位置;
    • 真实节点真正负责的 Key 是落在这些虚拟节点区间内的所有 Key;
    • 这样就能让节点在环上均匀分布,减少单点拥堵。
【哈希环示意 with 虚拟节点】(数字为哈希值模拟)

环上散布如下位置:
  NodeA#1 → 100  
  NodeC#1 → 300  
  NodeB#1 → 600  
  NodeA#2 → 900  
  NodeD#1 → 1200  
  NodeC#2 → 1500  
   ...  (总共 M·N 个虚拟节点)

Key1 → H=1100 → 第一个 ≥1100 的虚拟节点是 NodeD#1 → 分配给 NodeD  
Key2 → H=350  → 第一个 ≥350 的虚拟节点是 NodeB#1 → 分配给 NodeB  

虚拟节点个数选择

  • 如果 N(真实节点)较小,可设置每台 M=100~200 个虚拟节点;
  • 如果 N 很大,可适当减少 M;
  • 关键目标是让环上 N × M 个散点能够尽可能均匀。

4. 一致性哈希的详细实现

下面详细剖析如何用代码实现一致性哈希环,包括插入节点、删除节点与查找 Key 的流程。

4.1. 环形逻辑与节点映射示意

结构

  • 核心数据结构为一个有序的 Map,键是虚拟节点的哈希值(整数),值是该虚拟节点对应的真实节点标识(如 "10.0.0.101:6379")。
  • 伪代码初始化时,遍历所有真实节点 for each server in servers,为其创建 M 个虚拟节点 server#i,计算 hash(server#i),并将 (hash, server) 放入 TreeMap
TreeMap<Integer, String> hashRing = new TreeMap<>();

for each server in servers:
    for i in 0 -> M-1:
        vnodeKey = server + "#" + i
        hashValue = hash(vnodeKey)  // 整数哈希
        hashRing.put(hashValue, server)

4.2. 插入与查找流程图解(ASCII 版)

插入虚拟节点流程

[初始化服务器列表]      ServerList = [S1, S2, S3]
       │
       ▼
【为每个 Server 生成 M 个虚拟节点】(伪循环)
       │
       ▼
hashRing.put(hash("S1#0"), "S1")
hashRing.put(hash("S1#1"), "S1")
 ...        ...
hashRing.put(hash("S2#0"), "S2")
 ...        ...
hashRing.put(hash("S3#M-1"), "S3")
       │
       ▼
┌─────────────────────────────────────────────┐
│  有序 Map (hashRing):                     │
│    Key: 虚拟节点 Hash值, Value: 所属真实节点 │
│                                           │
│   100  → "S1"  (代表 "S1#0")               │
│   320  → "S2"  (代表 "S2#0")               │
│   450  → "S1"  (代表 "S1#1")               │
│   780  → "S3"  (代表 "S3#0")               │
│   ...     ...                              │
└─────────────────────────────────────────────┘

查找 Key 对应节点流程

假设要存储 Key = "session123"

Key = "session123"
1. 计算 hashValue = hash("session123") = 500  // 例如

2. 在 TreeMap 中查找第一个 ≥ 500 的 Key
   hashRing.ceilingKey(500) → 返回 780  // 对应 "S3"
   如果 ceilingKey 为 null,则取 hashRing.firstKey(),做环回绕行为。

3. 最终分配 targetServer = hashRing.get(780) = "S3"

用 ASCII 图示:

环(示例数值,仅演示顺序):
       100    320    450    500(Key #1)    780
 S1#0→●      ●      ●                    ●→S3#0
       └───>─┘      └─────>─────>─────────┘
 环上顺时针方向表示数值增大(%2^32循环)
  • Key 哈希值落在 500,顺时针找到 780 对应节点 "S3";
  • 如果 Key 哈希值 = 900 > 最大虚拟节点 780,则回到第一个虚拟节点 100,对应节点 "S1"。

4.3. 节点增删带来的最小重映射特性

  • 添加节点

    • 假设新增服务器 S4。只需为 S4 生成 M 个虚拟节点插入到 hashRing

      for (int i = 0; i < M; i++) {
          int hashValue = hash("S4#" + i);
          hashRing.put(hashValue, "S4");
      }
    • 这样,只有原来落在这些新虚拟节点与其前一个虚拟节点之间的 Key 会被重新映射到 S4;其余 Key 不受影响。
  • 删除节点

    • 假设删除服务器 S2。只需将 hashRing 中所有对应 "S2#i" 哈希值的条目移除。
    • 随后,之前原本属于 S2 区间内的 Key 会顺时针迁移到该区间下一个可用虚拟节点所对应的真实节点(可能是 S3S1S4 等)。

因此,一致性哈希在节点增删时可以保证大约只有 1/N 的 Key 会重新映射,而不是全部 Key 重映射。


5. 代码示例:用 Java 实现简单一致性哈希

下面通过一个完整的 Java 类示例,演示如何构建一致性哈希环,支持虚拟节点节点增删Key 查找等操作。

5.1. 核心数据结构:TreeMap 维护 Hash 环

Java 的 TreeMap 实现了红黑树,能够按照 Key (这里是 Hash 值)的顺序进行快速查找、插入、删除。我们将 TreeMap<Integer, String> 用来存储 “虚拟节点 Hash → 真实节点地址” 的映射。

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;

public class ConsistentHashing {
    // 虚拟节点数量(可调整)
    private final int VIRTUAL_NODES;

    // 环上的 Hash → 真实节点映射
    private final TreeMap<Long, String> hashRing = new TreeMap<>();

    // 保存真实节点列表
    private final Set<String> realNodes = new HashSet<>();

    // MD5 实例用于 Hash 计算
    private final MessageDigest md5;

    public ConsistentHashing(List<String> nodes, int virtualNodes) {
        this.VIRTUAL_NODES = virtualNodes;
        try {
            this.md5 = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("无法获取 MD5 实例", e);
        }
        // 初始化时将传入的真实节点列表加入环中
        for (String node : nodes) {
            addNode(node);
        }
    }

    /**
     * 将一个真实节点及其对应的虚拟节点加入 Hash 环
     */
    public void addNode(String realNode) {
        if (realNodes.contains(realNode)) {
            return;
        }
        realNodes.add(realNode);
        for (int i = 0; i < VIRTUAL_NODES; i++) {
            String virtualNodeKey = realNode + "#" + i;
            long hash = hash(virtualNodeKey);
            hashRing.put(hash, realNode);
            System.out.printf("添加虚拟节点:%-20s 对应 Hash=%d\n", virtualNodeKey, hash);
        }
    }

    /**
     * 从 Hash 环中移除一个真实节点及其所有虚拟节点
     */
    public void removeNode(String realNode) {
        if (!realNodes.contains(realNode)) {
            return;
        }
        realNodes.remove(realNode);
        for (int i = 0; i < VIRTUAL_NODES; i++) {
            String virtualNodeKey = realNode + "#" + i;
            long hash = hash(virtualNodeKey);
            hashRing.remove(hash);
            System.out.printf("移除虚拟节点:%-20s 对应 Hash=%d\n", virtualNodeKey, hash);
        }
    }

    /**
     * 根据 Key 查找其对应的真实节点
     */
    public String getNode(String key) {
        if (hashRing.isEmpty()) {
            return null;
        }
        long hash = hash(key);
        // 找到第一个 ≥ hash 的虚拟节点 Key
        Map.Entry<Long, String> entry = hashRing.ceilingEntry(hash);
        if (entry == null) {
            // 若超过最大 Key,则取环的第一个 Key(环回绕)
            entry = hashRing.firstEntry();
        }
        return entry.getValue();
    }

    /**
     * 计算字符串的 Hash 值(使用 MD5 并取 64 位高位作为 Long)
     */
    private long hash(String key) {
        byte[] digest = md5.digest(key.getBytes(StandardCharsets.UTF_8));
        // 使用前 8 个字节构造 Long 值
        long h = 0;
        for (int i = 0; i < 8; i++) {
            h = (h << 8) | (digest[i] & 0xFF);
        }
        return h & 0x7FFFFFFFFFFFFFFFL; // 保持正数
    }

    // 调试:打印当前 Hash 环的所有虚拟节点
    public void printHashRing() {
        System.out.println("当前 Hash 环 (HashValue → RealNode):");
        for (Map.Entry<Long, String> entry : hashRing.entrySet()) {
            System.out.printf("%d → %s\n", entry.getKey(), entry.getValue());
        }
    }

    // main 测试
    public static void main(String[] args) {
        List<String> nodes = Arrays.asList("10.0.0.101:6379", "10.0.0.102:6379", "10.0.0.103:6379");
        int virtualNodes = 3;  // 每个物理节点 3 个虚拟节点(演示用,生产可调至 100~200)

        ConsistentHashing ch = new ConsistentHashing(nodes, virtualNodes);
        ch.printHashRing();

        // 测试 Key 分布
        String[] keys = {"session123", "user456", "order789", "product321", "session555"};
        System.out.println("\n----- 测试 Key 对应节点 -----");
        for (String key : keys) {
            System.out.printf("Key \"%s\" 对应节点:%s\n", key, ch.getNode(key));
        }

        // 测试添加节点后 Key 重映射
        System.out.println("\n----- 添加新节点 10.0.0.104:6379 -----");
        ch.addNode("10.0.0.104:6379");
        ch.printHashRing();
        System.out.println("\n添加节点后重新测试 Key 对应节点:");
        for (String key : keys) {
            System.out.printf("Key \"%s\" 对应节点:%s\n", key, ch.getNode(key));
        }

        // 测试移除节点后 Key 重映射
        System.out.println("\n----- 移除节点 10.0.0.102:6379 -----");
        ch.removeNode("10.0.0.102:6379");
        ch.printHashRing();
        System.out.println("\n移除节点后重新测试 Key 对应节点:");
        for (String key : keys) {
            System.out.printf("Key \"%s\" 对应节点:%s\n", key, ch.getNode(key));
        }
    }
}

代码说明

  1. 构造方法 ConsistentHashing(List<String> nodes, int virtualNodes)

    • 接收真实节点列表与虚拟节点数,遍历调用 addNode(...)
  2. addNode(String realNode)

    • 将真实节点加入 realNodes 集合;
    • 遍历 i=0...VIRTUAL_NODES-1,为每个虚拟节点 realNode#i 计算哈希值,插入到 hashRing
  3. removeNode(String realNode)

    • realNodes 删除;
    • 同样遍历所有虚拟节点删除 hashRing 中对应的哈希条目。
  4. getNode(String key)

    • 根据 hash(key)hashRing 中查找第一个大于等于该值的条目,若为空则取 firstEntry()
    • 返回对应的真实节点地址。
  5. hash(String key)

    • 使用 MD5 计算 128 位摘要,取前 64 位(8 个字节)构造一个 Long,截断正数作为哈希值;
    • 也可使用 CRC32、FNV1\_32\_HASH 等其他哈希算法,但 MD5 分布更均匀。
  6. 示例输出

    • 初始化环时,会打印出所有插入的虚拟节点及其哈希值;
    • 对每个测试 Key 打印初始的映射节点;
    • 插入/移除节点后,打印环的状态,并重新测试 Key 的映射,观察大部分 Key 不变,仅少数 Key 发生变化。

6. 分布式 Session 与一致性哈希结合

在分布式 Session 方案中,如果采用多个 Redis 实例(或 Memcached 节点)来存储会话,如何将 Session ID(或其他 Key)稳定地分配到各个 Redis 实例?一致性哈希就是最佳选择。

6.1. Redis 集群与 Memcached 集群中的一致性哈希

  • Redis Cluster

    • Redis Cluster 本身内部实现了“Slot”与“数据迁移”机制,将 Key 拆分到 16,384 个槽位(slot),然后将槽位与节点对应。当集群扩容时,通过槽位迁移将 Key 重新分布;
    • 应用级别无需手动做一致性哈希,Redis Cluster 驱动客户端(如 Jedis Cluster、lettuce cluster)会自动将 Key 分配到对应槽位与节点。
  • 单机多实例 + 客户端路由

    • 如果没有使用 Redis Cluster,而是多台 Redis 单实例部署,则需要在客户端(如 Spring Session Redis、lettuce、Jedis)配置“基于一致性哈希的分片策略”,将不同 Key 定向到不同 Redis 实例。
  • Memcached 集群

    • 绝大多数 Memcached 客户端(如 spymemcached、XMemcached)都内置一致性哈希分片算法,开发者只需提供多台 Memcached 服务器地址列表,客户端自动为 Key 查找对应节点。

6.2. 使用一致性哈希分布 Session 到多个缓存节点的示例

假设我们有三台 Redis:10.0.0.101:637910.0.0.102:637910.0.0.103:6379,希望将 Session 存储均匀地分布到它们之上。可以分两种思路:

思路 A:在应用层自己实现一致性哈希

  • 像上面 Java 示例中那样构造一个一致性哈希环 ConsistentHashing,然后在存储或读取 Session 时:

    1. HttpServletRequest.getSession().getId() 获得 Session ID;
    2. 调用 String node = ch.getNode(sessionId); 得到 Redis 节点地址;
    3. 用 Redis 客户端(Jedis/lettuce)连接到 node 执行 SET session:<sessionId>GET session:<sessionId>
// 存 Session 示例(伪代码)
String sessionId = request.getSession().getId();
String targetNode = ch.getNode(sessionId);
Jedis jedis = new Jedis(hostFrom(targetNode), portFrom(targetNode));
jedis.set("session:" + sessionId, serializedSessionData);
  • 优点:完全可控,适合自研 Session 管理框架;
  • 缺点:要自己管理 Jedis 或 Redis 连接池,并处理节点故障;

思路 B:使用 Spring Session + Lettuce Cluster 内置分片

  • Spring Session Data Redis 本身支持配置多个 Redis 节点与分片策略。以 Lettuce 为例,只需在配置中指定 Redis Standalone 或 Cluster:
spring:
  redis:
    cluster:
      nodes:
        - 10.0.0.101:6379
        - 10.0.0.102:6379
        - 10.0.0.103:6379
    lettuce:
      cluster:
        refresh:
          adaptive: true
  • Lettuce Cluster 客户端会将连接路由到正确的节点,无需我们实现一致性哈希逻辑。
  • Spring Session Redis 在底层使用 RedisConnectionFactory,只要 Lettuce Cluster Client 正确配置,Session 的读写就会自动分布。

注:如果没有使用 Redis Cluster,而是 3 台单机版 Redis,也可配置 Redis Sentinel,Spring Boot Lettuce Client 会在内部做分片和故障转移,但需要在代码中指定 RedisStandaloneConfiguration + RedisSentinelConfiguration

6.3. 节点扩容/缩容时 Session 数据重分布的平滑性

  • 如果采用自己实现的一致性哈希,只需向环中 addNode("10.0.0.104:6379"),即可将新节点平滑加入,只有一部分用户的 Session 会从旧节点迁移到新节点;
  • 如果采用Spring Session + Lettuce Cluster,则扩容时向 Redis Cluster 增加节点,进行槽位迁移后,客户端自动感知槽位变更,也仅会迁移相应槽位的 Key;
  • 相比之下,一致性哈希能确保添加/删除节点时,仅有极少量 Session 需要重读、重写,避免“缓存雪崩”。

7. 图解:一致性哈希在分布式 Session 中的应用

下面用 ASCII 图直观展示“一致性哈希 + 多 Redis 节点”存储 Session 的过程。

           ┌───────────────────────┐
           │     ConsistentHash    │
           │  (维护虚拟节点 Hash 环) │
           └─────────┬─────────────┘
                     │
                     │  getNode(sessionId)
                     ▼
            ┌─────────────────────┐
            │     Hash 环示意图     │
            │                     │
            │    100 → "R1"       │
            │    300 → "R2"       │
            │    550 → "R1"       │
            │    800 → "R3"       │
            │    920 → "R2"       │
            │   ...               │
            └─────────────────────┘
                     │
      sessionIdHash = 620
                     │
        顺时针找到 ≥620 的 Hash → 800 对应 R3
                     │
                     ▼
            ┌─────────────────────┐
            │   目标 Redis 节点:   │
            │     "10.0.0.103:6379"│
            └─────────────────────┘
  • 读/写 Session 时:在获取到 Session ID 后,先调用 getNode(sessionId),定位到对应 Redis 实例(本例中是 R3);
  • 写入 Session:使用 Jedis/lettuce 连接到 R3,执行 SET session:<sessionId> ...
  • 读取 Session:同理,调用 getNode 定位到 R3,然后 GET session:<sessionId>
  • 增加 Redis 节点:新增 R4,如果其虚拟节点 Hash 值插入到 700 处,环上仅 620\~700 之间的 Key 会被重新映射到 R4,其他 Key 不受影响;

8. 性能、可靠性与实际落地注意事项

在实际项目中,将分布式 Session 与一致性哈希结合时,除了核心代码实现外,还需关注以下几点:

  1. Hash 算法选择与冲突

    • 上例中使用 MD5 取前 8 个字节构造 64 位整数;也可使用 CRC32 或其他速度更快的哈希算法,权衡分布均匀性与计算开销;
    • 注意哈希冲突概率极低,但若发生相同 Hash 值覆盖,应用中需在 hashRing.put(...) 前校验并做 rehash 或跳过。
  2. 虚拟节点数量调优

    • 真实节点少时应增大虚拟节点数,如 M = 100~200;真实节点多时可适当减少;
    • 每个虚拟节点对应额外的 Map 条目,TreeMap 操作是 O(log(N*M)) 的时间,若虚拟节点过多可能带来少许性能开销。
  3. 网络与连接池管理

    • 如果自己在应用层维持多个 Jedis/Lettuce 连接池(针对每个 Redis 节点),要注意连接池数量与连接复用;
    • 推荐使用 Lettuce Cluster Client 或 Redisson,这些客户端都内置了一致性哈希与节点故障迁移逻辑。
  4. 节点故障处理

    • 当某个节点宕机时,需要从 hashRing 中移除该节点,所有映射到它的 Key 自动迁移到下一个节点;
    • 但同步故障迁移时,需要额外的 Session 冗余或复制,否则该节点上 Session 数据将不可用(丢失);
    • 可在应用层维持双副本:将 Session 写入两个节点(replicaCount = 2),一主一备;若主节点挂,备节点仍可提供服务。
  5. 数据一致性与过期策略

    • Session 对象包含状态信息,通常需要设置 TTL(过期时间),一致性哈希+Redis 的场景下,要在写 SET 时附带 EXPIRE
    • 不同节点的系统时钟需校准,避免因时钟漂移导致 Session 过早或过期延迟判断。
  6. 监控与告警

    • 对每个 Redis 节点做健康监控:QPS、内存使用、慢查询、连接数等;
    • 对一致性哈希环做监控:节点列表变更、Key 分布不均、某节点压力过大时需触发告警;
  7. 数据迁移与热备

    • 如果要做“无缝扩容”或“在线重分布”,可以借助专门工具(如 redis-trib.rbredis-shake)或自行实现迁移脚本:

      1. 添加新节点到 Hash 环;
      2. 扫描旧节点上所有 Keys,判断新节点是否接管,符合条件的将对应 Key 迁移到新节点;
      3. 删除旧节点(缩容时)。
    • 这种在线迁移会产生额外网络与 CPU 开销,不宜频繁操作。

9. 总结

本文从以下层面全面解析了分布式 Session 问题与一致性哈希技术:

  1. 分布式 Session 背景:介绍了多实例应用中 Session 丢失、会话粘滞带来的挑战;
  2. 常见方案对比:详细讲解会话粘滞、中央化存储(Redis/数据库)、以及 JWT Token 的优缺点与适用场景;
  3. 一致性哈希基础:阐述一致性哈希如何在节点增删时实现最小 Key 重映射,有效避免缓存雪崩;
  4. 一致性哈希实现细节:通过 ASCII 图解与 Java 代码示例,演示如何构建一致性哈希环、虚拟节点生成、插入/删除节点、Key 映射流程;
  5. 分布式 Session 与一致性哈希结合:说明在多 Redis 或 Memcached 环境中,通过一致性哈希将 Session 均匀地分布到各节点,并在扩容/缩容时平滑迁移;
  6. 实际落地注意事项:总结了 Hash 算法选择、虚拟节点调优、故障处理与数据迁移的关键点。

要在生产环境中实现高可用、可扩展的分布式 Session,推荐使用成熟的客户端库(如 Spring Session Redis + Lettuce Cluster、Redisson、或托管的 Redis Cluster),这样可以将一致性哈希与故障转移、哨兵(Sentinel)、在线迁移等复杂逻辑交给社区成熟方案,减少自行实现的运维成本。同时,务必结合业务访问量与运维可控性,合理调节虚拟节点数量与节点副本策略,才能在性能与可靠性之间达到最佳平衡。

通过掌握本文的原理与示例,你应能:

  • 清楚地理解为何要使用一致性哈希而非简单取模;
  • 具备手动搭建简单一致性哈希环以应对异构缓存节点的能力;
  • 在 Spring Boot 应用中快速集成 Redis Session 存储与一致性哈希分片;
  • 对缓存节点故障与在线扩容时的 Session 数据迁移有清晰的思路与实现方案。
2025-06-02

RDB 快照和 AOF 日志在性能上有何差异

在 Redis 中,为了保证内存数据的持久化,有两种主要方案:RDB(Redis Database)快照AOF(Append-Only File)日志。二者的工作原理不同,对系统性能的影响也各有特点。本文将从原理、性能对比、代码示例和流程图等角度,详细剖析 RDB 与 AOF 在性能上的差异,帮助你结合场景做出合理选择。


目录

  1. 原理简述
    1.1. RDB 快照原理
    1.2. AOF 日志原理
  2. 性能影响对比
    2.1. 写入吞吐与延迟
    2.2. 恢复时间
    2.3. 磁盘占用与 I/O 开销
  3. 代码示例:简单基准测试
    3.1. 环境准备与配置
    3.2. RDB 下的基准测试示例
    3.3. AOF 下的基准测试示例
    3.4. 结果解读
  4. 流程图解:RDB 与 AOF 持久化流程
    4.1. RDB BGSAVE 流程图
    4.2. AOF 写入与重写流程图
  5. 详细说明与优化建议
    5.1. RDB 场景下的性能优化
    5.2. AOF 场景下的性能优化
    5.3. 何时选择混合策略
  6. 总结

1. 原理简述

在深入性能对比之前,先回顾 RDB 和 AOF 各自的基本原理。

1.1. RDB 快照原理

  • 触发方式

    • 根据 redis.conf 中的 save 配置(如 save 900 1save 300 10save 60 10000)自动触发,或手动执行 BGSAVE 命令强制执行快照。
  • 执行流程

    1. 主进程调用 fork(),复制当前进程地址空间给子进程(写时复制 Copy-on-Write)。
    2. 子进程遍历内存中的所有键值对,将其以紧凑的二进制格式序列化,并写入 dump.rdb 文件,完成后退出。
    3. 主进程继续响应客户端读写请求,只承担 COW 带来的内存开销。

1.2. AOF 日志原理

  • 触发方式

    • 每次写命令(SETINCRLPUSH 等)执行前,Redis 先将该命令以 RESP 格式写入 appendonly.aof,再根据 appendfsync 策略决定何时刷盘。
  • 刷盘策略

    1. appendfsync always:接到每条写命令后立即 fsync,安全性最高但延迟最大。
    2. appendfsync everysec(推荐):每秒一次 fsync,能兼顾性能和安全,最多丢失 1 秒数据。
    3. appendfsync no:由操作系统决定何时写盘,最快速度但最不安全。
  • AOF 重写(Rewrite)

    • 随着时间推移,AOF 文件会不断增大。Redis 提供 BGREWRITEAOF,通过 fork() 子进程读取当前内存,生成简化后的命令集写入新文件,再将主进程在期间写入的命令追加到新文件后,最后替换旧文件。

2. 性能影响对比

下面从写入吞吐与延迟、恢复时间、磁盘占用与 I/O 开销三个维度,对比 RDB 与 AOF 在性能上的差异。

2.1. 写入吞吐与延迟

特性RDB 快照AOF 日志
平时写入延迟写入仅操作内存,不会阻塞(fork() 带来轻微 COW 开销)需要将命令首先写入 AOF 缓冲并根据 appendfsync 策略刷盘,延迟更高
写入吞吐较高(仅内存操作),不会因持久化而阻塞客户端较低(有 I/O 同步开销),尤其 appendfsync always 时影响显著
非阻塞持久化过程BGSAVE 子进程写盘,不阻塞主进程写命令时追加文件并刷盘,可能阻塞主进程(视 appendfsync 策略)
高并发写场景表现更好,只有在触发 BGSAVE 时会有短暂 COW 性能波动中等,appendfsync everysec 下每秒刷一次盘,短时延迟波动
  • RDB 写入延迟极低,因为平时写操作只修改内存,触发快照时会 fork(),主进程仅多一份内存 Cop y-on-Write 开销。
  • AOF 写入延迟 与所选策略强相关:

    • always:写操作必须等待磁盘 fsync 完成,延迟最高;
    • everysec:写入时只追加到操作系统页缓存,稍后异步刷盘,延迟较小;
    • no:写入由操作系统随时写盘,延迟最低但最不安全。

2.2. 恢复时间

特性RDB 快照AOF 日志
恢复方式直接读取 dump.rdb,反序列化内存,一次性恢复顺序执行 appendonly.aof 中所有写命令
恢复速度非常快,可在毫秒或几百毫秒级加载百万级数据较慢,需逐条执行命令,耗时较长(与 AOF 文件大小成线性关系)
冷启动恢复适合生产环境快速启动若 AOF 文件过大,启动延迟明显
  • RDB 恢复速度快:加载二进制快照文件,即可一次性将内存完全恢复。
  • AOF 恢复速度慢:需要从头开始解析文件,执行每一条写命令。对于几 GB 的 AOF 文件,可能需要数秒甚至更久。

2.3. 磁盘占用与 I/O 开销

特性RDB 文件AOF 文件
文件体积较小(紧凑二进制格式),通常是相同数据量下最小较大(包含所有写命令),大约是 RDB 的 2–3 倍
磁盘 I/O 高峰BGSAVE 期间子进程写盘,I/O 瞬时峰值高高并发写时不断追加,有持续 I/O;重写时会产生大量 I/O
写盘模式子进程一次性顺序写入 RDB 文件持续追加写(Append),并定期 fsync
重写过程 I/O无(RDB 没有内置重写)BGREWRITEAOF 期间需要写新 AOF 文件并复制差异,I/O 开销大
  • RDB 仅在触发快照时产生高 I/O,且时间较短。
  • AOF 持续不断地追加写,如果写命令频繁,会产生持续 I/O;BGREWRITEAOF 时会有一次新的全量写盘,期间 I/O 峰值也会升高。

3. 代码示例:简单基准测试

下面通过一个简单的脚本,演示如何使用 redis-benchmark 分析 RDB 与 AOF 情况下的写入吞吐,并记录响应延迟。

3.1. 环境准备与配置

假设在本机安装 Redis,并在两个不同的配置文件下运行两个实例:

  1. RDB-only 实例 (redis-rdb.conf):

    port 6379
    dir /tmp/redis-rdb
    dbfilename dump.rdb
    
    # 只开启 RDB,禁用 AOF
    appendonly no
    
    # 默认 RDB 策略
    save 900 1
    save 300 10
    save 60 10000
  2. AOF-only 实例 (redis-aof.conf):

    port 6380
    dir /tmp/redis-aof
    dbfilename dump.rdb
    
    # 只开启 AOF
    appendonly yes
    appendfilename "appendonly.aof"
    # 每秒 fsync
    appendfsync everysec
    
    # 禁用 RDB 快照
    save ""

启动两个 Redis 实例:

mkdir -p /tmp/redis-rdb /tmp/redis-aof
redis-server redis-rdb.conf &
redis-server redis-aof.conf &

3.2. RDB 下的基准测试示例

使用 redis-benchmark 对 RDB-only 实例(6379端口)进行写入测试:

redis-benchmark -h 127.0.0.1 -p 6379 -n 100000 -c 50 -t set -P 16
  • -n 100000:总共发送 100,000 条请求;
  • -c 50:50 个并发连接;
  • -t set:只测试 SET 命令;
  • -P 16:使用 pipeline,批量发送 16 条命令后再等待回复。

示例结果(字段说明因环境不同略有变化,此处仅作参考):

====== SET ======
  100000 requests completed in 1.23 seconds
  50 parallel clients
  pipeline size: 16

  ... (省略输出) ...

  99.90% <= 1 milliseconds
  99.99% <= 2 milliseconds
  100.00% <= 3 milliseconds

  81300.00 requests per second
  • 写入吞吐约为 80k req/s,响应延迟大多数在 1ms 以内。

3.3. AOF 下的基准测试示例

对 AOF-only 实例(6380端口)做相同测试:

redis-benchmark -h 127.0.0.1 -p 6380 -n 100000 -c 50 -t set -P 16

示例结果(仅供参考):

====== SET ======
  100000 requests completed in 1.94 seconds
  50 parallel clients
  pipeline size: 16

  ... (省略输出) ...

  99.90% <= 2 milliseconds
  99.99% <= 4 milliseconds
  100.00% <= 6 milliseconds

  51500.00 requests per second
  • 写入吞吐约为 50k req/s,相较 RDB 情况下明显下降。延迟 99% 在 2ms 左右。

3.4. 结果解读

  • 在相同硬件与客户端参数下,RDB-only 实例写入吞吐高于 AOF-only 实例,原因在于 AOF 需要将命令写入文件并执行 fsync everysec
  • AOF 中的刷盘操作会在高并发时频繁触发 I/O,导致延迟有所上升。
  • 如果使用 appendfsync always,写入吞吐还会更低。

4. 流程图解:RDB 与 AOF 持久化流程

下面通过 ASCII 图示,对比 RDB(BGSAVE)与 AOF 写入/重写过程。

4.1. RDB BGSAVE 流程图

       ┌─────────────────────────────────────────┐
       │              客户端请求                │
       └───────────────────┬─────────────────────┘
                           │     (平时读写操作只在内存)
                           ▼
       ┌─────────────────────────────────────────┐
       │          Redis 主进程(App Server)       │
       │  ┌───────────────────────────────────┐  │
       │  │         内存中的 Key-Value        │  │
       │  │                                   │  │
       │  └───────────────────────────────────┘  │
       │                │                        │
       │                │ 满足 save 条件 或 BGSAVE │
       │                ▼                        │
       │      ┌────────────────────────┐         │
       │      │        fork()          │         │
       │      └──────────┬─────────────┘         │
       │                 │                       │
┌──────▼──────┐   ┌──────▼───────┐   ┌───────────▼────────┐
│ 子进程(BGSAVE) │   │ 主进程 继续   │   │ Copy-on-Write 机制 │
│  生成 dump.rdb  │   │ 处理客户端请求│   │ 时间点复制内存页  │
└──────┬──────┘   └──────────────┘   └────────────────────┘
       │
       ▼
(dump.rdb 写盘完成 → 子进程退出)
  • 子进程负责遍历内存写 RDB,主进程不阻塞,但因 COW 会额外分配内存页。

4.2. AOF 写入与重写流程图

       ┌─────────────────────────────────────────┐
       │              客户端请求                │
       │        (写命令,如 SET key value)      │
       └───────────────────┬─────────────────────┘
                           │
                           ▼
       ┌─────────────────────────────────────────┐
       │          Redis 主进程(App Server)       │
       │   (1) 执行写命令前,先 append 到 AOF    │
       │       aof_buffer 即操作系统页缓存       │
       │   (2) 根据 appendfsync 策略决定何时 fsync │
       │   (3) 执行写命令修改内存                │
       └───────────────┬─────────────────────────┘
                       │
    ┌──────────────────▼───────────────────┐
    │       AOF 持续追加到 appendonly.aof  │
    │ (appendfsync everysec:后续每秒 fsync)│
    └──────────────────┬───────────────────┘
                       │
               ┌───────▼───────────────────┐
               │  AOF 重写触发( BGREWRITEAOF ) │
               │                           │
               │  (1) fork() 生成子进程      │
               │  (2) 子进程遍历内存生成      │
               │      模拟命令写入 new.aof    │
               │  (3) 主进程继续写 aof_buffer │
               │  (4) 子进程写完后向主进程   │
               │      请求差量命令并追加到 new.aof│
               │  (5) 替换旧 aof 文件       │
               └───────────────────────────┘
  • AOF 写入是主进程同步追加并刷盘,重写时也使用 fork(),但是子进程仅负责遍历生成新命令,主进程继续写操作并将差量追加。

5. 详细说明与优化建议

5.1. RDB 场景下的性能优化

  1. 降低快照触发频率

    • 如果写入量大,可减少 save 触发条件,比如只保留 save 900 1,避免频繁 BGSAVE
  2. 监控内存占用

    • BGSAVE 会占用 COW 内存,监控 used_memoryused_memory_rss 差值,可判断 COW 消耗。
  3. 调整 rdb-bgsave-payload-memory-factor

    • 该参数控制子进程写盘时分配内存上限,比率越低,COW 内存压力越小,但可能影响写盘速度。
  4. 使用 SSD

    • SSD 写入速度更快,可缩短 BGSAVE 持久化时间,减少对主进程 COW 影响。
# 示例:Redis 只在 900 秒没写操作时快照
save 900 1
# 降低子进程内存预留比例
rdb-bgsave-payload-memory-factor 0.3

5.2. AOF 场景下的性能优化

  1. 选择合适的 appendfsync 策略

    • 推荐 everysec:能在性能与安全间达到平衡,最多丢失 1 秒数据。
    • 尽量避免 always,除非对数据丢失极为敏感。
  2. 调整重写触发阈值

    • auto-aof-rewrite-percentage 值不宜过小,否则会频繁重写;不宜过大,导致 AOF 过大影响性能。
  3. 开启增量 fsync

    • aof-rewrite-incremental-fsync yes:子进程重写期间,主进程写入会分批次 fsync,减轻 I/O 峰值。
  4. 专用磁盘

    • 将 AOF 文件放在独立磁盘上,减少与其他进程的 I/O 竞争。
  5. 限制 AOF 内存使用

    • 若写入缓冲很大,可通过操作系统参数或 Redis client-output-buffer-limit 限制内存占用。
# 示例:AOF 重写阈值
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 200  # 当 AOF 大小是上次重写的 200% 触发重写
auto-aof-rewrite-min-size 128mb   # 且 AOF 至少大于 128MB 时触发
aof-rewrite-incremental-fsync yes

5.3. 何时选择混合策略

  • 低写入、对数据丢失可容忍数分钟:仅启用 RDB,追求最高写入性能和快速冷启动恢复。
  • 写入频繁、对数据一致性要求较高:启用 AOF(appendfsync everysec),最大限度减少数据丢失,但接受恢复慢。
  • 对数据安全和快速恢复都有要求:同时启用 RDB 与 AOF:

    1. 快速重启时,优先加载 AOF;若 AOF 损坏则加载 RDB。
    2. RDB 提供定期冷备份;AOF 提供实时增量备份。
# 混合示例
save 900 1
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

6. 总结

通过本文的对比与示例,我们可以得出:

  1. 写入延迟与吞吐量

    • RDB 仅在快照时有短暂 COW 影响,平时写入延迟极低,吞吐最高;
    • AOF 需要将命令追加写入并根据策略刷盘,写入延迟和吞吐都比 RDB 较差。
  2. 恢复速度

    • RDB 恢复非常快;
    • AOF 恢复相对较慢,因为需要逐条执行命令。
  3. 磁盘占用与 I/O

    • RDB 文件体积小,I/O 开销集中在快照时;
    • AOF 持续追加且重写时 I/O 较大,文件通常比 RDB 大 2–3 倍。
  4. 持久化安全性

    • RDB 在两次快照之间的数据可能丢失;
    • AOF 在 appendfsync everysec 下最多丢失 1 秒数据;
  5. 最佳实践

    • 推荐在生产环境同时启用 RDB 与 AOF,以最大程度兼顾写入性能、数据安全和快速恢复。
    • 根据实际写入压力和可容忍的数据丢失程度,合理调整触发条件和刷盘策略。

希望本文的原理剖析、代码示例和流程图解,能帮助你更直观地理解 RDB 与 AOF 在性能上的差异,并在实践中灵活选择与优化 Redis 持久化方案。