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 持久化方案。

2025-06-02

Redis持久化机制详解:RDB快照与AOF日志全面剖析

在高性能缓存与数据存储领域,Redis 以其高速读写和丰富的数据结构广受欢迎。然而,Redis 默认将数据保存在内存中,一旦发生宕机或意外重启,所有数据将丢失。为了解决这一问题,Redis 提供了两种主要的持久化机制——**RDB 快照(Snapshotting)**与 AOF 日志(Append-Only File),以及它们的混合使用方式。本文将从原理、配置、优缺点、实战示例和最佳实践等方面,对 Redis 的持久化机制进行全面剖析,帮助你掌握如何在不同场景下选择与优化持久化策略。


目录

  1. 为什么需要持久化
  2. RDB 快照机制详解
    2.1. RDB 原理与触发条件
    2.2. RDB 配置示例及说明
    2.3. RDB 生成流程图解
    2.4. RDB 优缺点分析
    2.5. 恢复数据示例
  3. AOF 日志机制详解
    3.1. AOF 原理与写入方式
    3.2. AOF 配置示例及说明
    3.3. AOF 重写(Rewrite)流程图解
    3.4. AOF 优缺点分析
    3.5. 恢复数据示例
  4. RDB 与 AOF 的对比与混合配置
    4.1. 对比表格
    4.2. 混合使用场景与实践
    4.3. 配置示例:同时开启 RDB 和 AOF
  5. 持久化性能优化与常见问题
    5.1. RDB 快照对性能影响的缓解
    5.2. AOF 重写对性能影响的缓解
    5.3. 可能遇到的故障与排查
  6. 总结

1. 为什么需要持久化

Redis 本质上是基于内存的键值存储,读写速度极快。然而,内存存储也带来一个显著的问题——断电或进程崩溃会导致数据丢失。因此,为了保证数据可靠性, Redis 提供了两套持久化方案:

  1. RDB (Redis Database) 快照

    • 定期生成内存数据的全量快照,将数据以二进制形式保存在磁盘。
    • 快照文件体积小、加载速度快,适合冷备份或灾难恢复。
  2. AOF (Append-Only File) 日志

    • 将每次写操作以命令形式追加写入日志文件,实现操作的持久记录。
    • 支持实时数据恢复,可选不同的刷新策略以权衡性能和持久性。

通过合理配置 RDB 与 AOF,可在性能和持久性之间达到平衡,满足不同业务场景对数据可靠性的要求。


2. RDB 快照机制详解

2.1. RDB 原理与触发条件

RDB 快照机制会将 Redis 内存中的所有数据以二进制格式生成一个 .rdb 文件,当 Redis 重启时可以通过该文件快速加载数据。其核心流程如下:

  1. 触发条件

    • 默认情况下,Redis 会在满足以下任一条件时自动触发 RDB 快照:

      save <seconds> <changes>

      例如:

      save 900 1   # 900 秒内至少有 1 次写操作
      save 300 10  # 300 秒内至少有 10 次写操作
      save 60 10000 # 60 秒内至少有 10000 次写操作
    • Redis 也可以通过命令 BGSAVE 手动触发后台快照。
  2. Fork 子进程写盘

    • 当满足触发条件后,Redis 会调用 fork() 创建子进程,由子进程负责将内存数据序列化并写入磁盘,主进程继续处理前端请求。
    • 序列化采用高效的紧凑二进制格式,保存键值对、数据类型、过期时间等信息。
  3. 持久化文件位置

    • 默认文件名为 dump.rdb,存放在 dir(工作目录)下,可在配置文件中修改。
    • 快照文件写入完成,子进程退出,主进程更新 RDB 最后保存时间。

示例:触发一次 RDB 快照

# 在 redis-cli 中执行
127.0.0.1:6379> BGSAVE
OK

此时主进程返回 OK,子进程会在后台异步生成 dump.rdb


2.2. RDB 配置示例及说明

redis.conf 中,可以配置 RDB 相关参数:

# 持久化配置(RDB)
# save <seconds> <changes>: 自动触发条件
save 900 1      # 900 秒内至少发生 1 次写操作则触发快照
save 300 10     # 300 秒内至少发生 10 次写操作则触发快照
save 60 10000   # 60 秒内至少发生 10000 次写操作则触发快照

# RDB 文件保存目录
dir /var/lib/redis

# RDB 文件名
dbfilename dump.rdb

# 是否开启压缩(默认 yes)
rdbcompression yes

# 快照写入时扩展缓冲区大小(用于加速写盘)
rdb-bgsave-payload-memory-factor 0.5

# RDB 文件保存时最大增量副本条件(开启复制时)
rdb-del-sync-files yes
rdb-del-sync-files-safety-margin 5
  • save:配置多条条件语句,只要满足任意一条即触发快照。
  • dir:指定工作目录,RDB 文件会保存在该目录下。
  • dbfilename:RDB 快照文件名,可根据需求修改为 mydump.rdb 等。
  • rdbcompression:是否启用 LZF 压缩,压缩后文件体积更小,但占用额外 CPU。
  • rdb-bgsave-payload-memory-factor:子进程写盘时,内存拷贝会占据主进程额外内存空间,缓冲因子用来限制分配大小。

2.3. RDB 生成流程图解

下面的 ASCII 图展示了 RDB 快照的简化生成流程:

           ┌────────────────────────────────────────────────┐
           │                  Redis 主进程                  │
           │   (接受客户端读写请求,并维护内存数据状态)     │
           └───────────────┬────────────────────────────────┘
                           │ 满足 save 条件 或 BGSAVE 命令
                           ▼
           ┌────────────────────────────────────────────────┐
           │                    fork()                     │
           └───────────────┬────────────────────────────────┘
           │               │
┌──────────▼─────────┐     ┌▼───────────────┐
│  Redis 子进程(BGSAVE) │     │ Redis 主进程  │
│   (将数据序列化写入  ) │     │ 继续处理客户端  │
│   (dump.rdb 文件)   │     │   请求          │
└──────────┬─────────┘     └────────────────┘
           │
           │ 写盘完成后退出
           ▼
     通知主进程更新 rdb_last_save_time
  • 通过 fork(),Redis 将内存数据拷贝到子进程地址空间,再由子进程顺序写入磁盘,不会阻塞主进程
  • 写盘时会对内存进行 Copy-on-Write(COW),意味着在写盘过程中,如果主进程写入修改某块内存,操作系统会在写盘后将该内存复制一份给子进程,避免数据冲突。

2.4. RDB 优缺点分析

优点

  1. 生成的文件体积小

    • RDB 是紧凑的二进制格式,文件较小,适合备份和迁移。
  2. 加载速度快

    • 通过一次性读取 RDB 文件并快速反序列化,可在数十毫秒/百毫秒级别恢复上百万条键值对。
  3. 对主进程影响小

    • 采用 fork() 生成子进程写盘,主进程仅有 Copy-on-Write 开销。
  4. 适合冷备份场景

    • 定期持久化并存储到远程服务器或对象存储。

缺点

  1. 可能丢失最后一次快照后与宕机之间的写入数据

    • 比如配置 save 900 1,则最多丢失 15 分钟内的写操作。
  2. 在生成快照时会占用额外内存

    • Copy-on-Write 会导致内存峰值增高,需要留出一定预留内存。
  3. 不能保证每次写操作都持久化

    • RDB 是基于时间和写操作频率触发,不适合对数据丢失敏感的场景。

2.5. 恢复数据示例

当 Redis 重启时,如果 dir 目录下存在 RDB 文件,Redis 会自动加载该文件恢复数据。流程简述:

  1. 以配置文件中的 dirdbfilename 定位 RDB 文件(如 /var/lib/redis/dump.rdb)。
  2. 将 RDB 反序列化并将数据加载到内存。
  3. 如果同时开启 AOF,并且 appendonly.aof 文件更“新”,则优先加载 AOF。
# 停止 Redis
sudo systemctl stop redis

# 模拟数据丢失后的重启:保留 dump.rdb 即可
ls /var/lib/redis
# dump.rdb

# 启动 Redis
sudo systemctl start redis

# 检查日志,确认已从 RDB 加载
tail -n 20 /var/log/redis/redis-server.log
# ... Loading RDB produced by version ...
# ... RDB memory usage ...

3. AOF 日志机制详解

3.1. AOF 原理与写入方式

AOF(Append-Only File)持久化会将每一条写操作命令以 Redis 协议(RESP)序列化后追加写入 appendonly.aof 文件。重启时,通过顺序执行 AOF 文件中的所有写命令来恢复数据。其核心流程如下:

  1. 写操作捕获

    • 客户端向 Redis 发起写命令(如 SET key valueHSET hash field value)后,Redis 在执行命令前会将完整命令以 RESP 格式追加写入 AOF 文件。
  2. 刷盘策略

    • Redis 提供三种 AOF 同步策略:

      • appendfsync always:每次写命令都执行 fsync,最安全但性能最差;
      • appendfsync everysec:每秒 fsync 一次,推荐使用;
      • appendfsync no:完全由操作系统决定何时写盘,性能好但最不安全。
  3. AOF 重写(BGREWRITEAOF)

    • 随着时间推移,AOF 文件会越来越大,Redis 支持后台重写将旧 AOF 文件重写为仅包含当前数据库状态的最小命令集合。
    • Backend 通过 fork() 创建子进程,子进程将当前内存数据转换为一条条写命令写入新的 temp-rewrite.aof 文件,写盘完毕后,主进程执行命令日志到重写子进程,最后替换原 AOF 文件。

示例:触发一次 AOF 重写

127.0.0.1:6379> BGREWRITEAOF
Background append only file rewriting started

3.2. AOF 配置示例及说明

redis.conf 中,可以配置 AOF 相关参数:

# AOF 持久化开关
appendonly yes

# AOF 文件名
appendfilename "appendonly.aof"

# AOF 同步策略: always | everysec | no
# 推荐 everysec:可在 1 秒内容忍数据丢失
appendfsync everysec

# AOF 重写触发条件(文件大小增长百分比)
auto-aof-rewrite-percentage 100   # AOF 文件变为上次重写后 100% 大时触发
auto-aof-rewrite-min-size 64mb     # 且 AOF 文件至少大于 64MB 时才触发

# AOF 重写时最大复制延迟(秒),防止主从节点差距过大会中断重写
aof-rewrite-incremental-fsync yes
  • appendonly:是否启用 AOF;
  • appendfilename:指定 AOF 文件名;
  • appendfsync:指定 AOF 的刷盘策略;
  • auto-aof-rewrite-percentageauto-aof-rewrite-min-size:配合使用,防止频繁重写。

3.3. AOF 重写(Rewrite)流程图解

下面 ASCII 图展示了 AOF 重写的简化流程:

            ┌────────────────────────────────────────────────┐
            │                  Redis 主进程                  │
            └───────────────────────┬────────────────────────┘
                                    │ 满足重写条件(BGREWRITEAOF 或 auto 触发)
                                    ▼
            ┌────────────────────────────────────────────────┐
            │                    fork()                     │
            └───────────────┬────────────────────────────────┘
            │               │
┌──────────▼─────────┐     ┌▼───────────────┐
│  子进程(AOF_REWRITE) │     │ Redis 主进程  │
│   (1) 将内存数据遍历生成   │     │   (2) 继续处理客户端  │
│       的写命令写入        │     │       请求          │
│     temp-rewrite.aof    │     └────────────────┘
└──────────┬─────────┘               │(3) 收集正在执行的写命令
           │                         │    并写入临时缓冲队列
           │                         ▼
           │   (4) 子进程完成写盘 → 通知主进程
           │
           ▼
   ┌─────────────────────────────────────┐
   │    主进程将缓冲区中的写命令追加到   │
   │    temp-rewrite.aof 末尾            │
   └─────────────────────────────────────┘
           │
           ▼
   ┌─────────────────────────────────────┐
   │ 替换 appendonly.aof 为 temp-rewrite │
   │ 并删除旧文件                       │
   └─────────────────────────────────────┘
  • 子进程只负责基于当前内存数据生成最小写命令集,主进程继续处理请求并记录新的写命令到缓冲区;
  • 当子进程写盘完成后,主进程将缓冲区命令追加到新文件尾部,保证不丢失任何写操作;
  • 并发与数据一致性得以保障,同时将旧 AOF 文件体积大幅度缩小。

3.4. AOF 优缺点分析

优点

  1. 写操作的高可靠性

    • 根据 appendfsync 策略,能保证最大 1 秒内数据同步到磁盘,适合对数据丢失敏感的场景。
  2. 恢复时最大限度地还原写操作顺序

    • AOF 文件按命令顺序记录每一次写入,数据恢复时会重新执行命令,能最大限度还原数据一致性。
  3. 支持命令可读性

    • AOF 文件为文本(RESP)格式,可通过查看日志直观了解写操作。

缺点

  1. 文件体积偏大

    • AOF 文件记录了所有写命令,往往比同样数据量的 RDB 快照文件大 2\~3 倍。
  2. 恢复速度较慢

    • 恢复时需要对 AOF 中所有命令逐条执行,恢复过程耗时较长。
  3. 重写过程对 I/O 有额外开销

    • AOF 重写同样会 fork 子进程及写盘,且在高写入速率下,子进程和主进程都会产生大量 I/O,需合理配置。

3.5. 恢复数据示例

当 Redis 重启时,如果 appendonly.aof 存在且比 dump.rdb 更“新”,Redis 会优先加载 AOF:

  1. 主进程启动后,检查 appendonly.aof 文件存在。
  2. 逐条读取 AOF 文件中的写命令并执行,恢复到最新状态。
  3. 完成后,如果同时存在 RDB,也会忽略 RDB。
# 停止 Redis
sudo systemctl stop redis

# 确保 aof 文件存在
ls /var/lib/redis
# appendonly.aof  dump.rdb

# 启动 Redis
sudo systemctl start redis

# 检查日志,确认已从 AOF 重放数据
tail -n 20 /var/log/redis/redis-server.log
# ... Ready to accept connections
# ... AOF loaded OK

4. RDB 与 AOF 的对比与混合配置

4.1. 对比表格

特性RDB 快照AOF 日志
数据文件二进制 dump.rdb文本 appendonly.aof(RESP 命令格式)
触发方式定时或写操作阈值触发每次写操作追加或定期 fsync
持久性可能丢失最后一次快照后到宕机间的数据最多丢失 1 秒内的数据(appendfsync everysec
文件体积紧凑,体积小较大,约是 RDB 的 2\~3 倍
恢复速度快速加载,适合冷备份恢复命令逐条执行,恢复速度慢,适合热备份
对性能影响BGSAVE 子进程会产生 Copy-on-Write 开销每次写操作按照 appendfsync 策略对 I/O 有影响
压缩支持支持 LZF 压缩不支持(AOF 重写后可压缩新文件)
可读性不可读可读,可手动查看写入命令
适用场景定期备份、快速重启恢复对数据一致性要求高、想最大限度减少数据丢失的场景

4.2. 混合使用场景与实践

在生产环境中,通常推荐同时开启 RDB 及 AOF,以兼具两者优点:

  • RDB:提供定期完整数据备份,能够实现快速重启恢复(秒级别)。
  • AOF:保证在持久化时间窗(如 1 秒)内的数据几乎不丢失。

同时开启后,Redis 重启时会优先加载 AOF,如果 AOF 损坏也可回退加载 RDB。

# 同时开启 RDB 与 AOF
save 900 1
save 300 10
save 60 10000

appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec

4.3. 配置示例:同时开启 RDB 和 AOF

假设需要兼顾性能和数据安全,将 redis.conf 中相关持久化配置部分如下:

# ================== RDB 配置 ==================
save 900 1
save 300 10
save 60 10000

dbfilename dump.rdb
dir /var/lib/redis
rdbcompression yes

# ================== AOF 配置 ==================
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
aof-rewrite-incremental-fsync yes
  • 这样配置后,Redis 会每当满足 RDB 条件时自动触发 BGSAVE;并在每秒将写命令追加并 fsync 到 AOF。
  • 当 AOF 文件增长到上次重写后两倍且大于 64MB 时,会自动触发 BGREWRITEAOF

5. 持久化性能优化与常见问题

5.1. RDB 快照对性能影响的缓解

  1. 合理设置 save 条件

    • 对于写入量大的环境,可将触发条件设置得更高,或干脆通过定时调度运行 BGSAVE
    • 例如,某些场景下不需要 60 秒内刷一次,可以只保留 save 900 1
  2. 限制 rdb-bgsave-payload-memory-factor

    • 该参数限制子进程写盘时内存分配开销,默认 0.5 表示最多占用一半可用内存来做 COW。
    • 若内存有限,可调小该值,避免 OOM。
  3. 监控 COW 内存增量

    • Redis 会在日志中输出 COW 内存峰值,通过监控可及时发现“内存雪崩”风险。
    • 可定期查看 INFO 中的 used_memoryused_memory_rss 差值。
# 监控示例
127.0.0.1:6379> INFO memory
# ...
used_memory:1500000000
used_memory_rss:1700000000   # COW 导致额外 200MB

5.2. AOF 重写对性能影响的缓解

  1. 合理设置 auto-aof-rewrite-percentageauto-aof-rewrite-min-size

    • 避免过于频繁地触发 AOF 重写,也要避免 AOF 文件过大后才触发,造成过度 I/O。
  2. 使用 aof-rewrite-incremental-fsync

    • 在重写过程中开启增量 fsync,能减少对主进程写性能的影响。
  3. 控制 appendfsync everysec 刷盘策略

    • always 会显著影响写性能,除非对持久化要求极高,否则推荐 everysec
  4. 硬件优化

    • 使用 SSD 或 RAID 提高磁盘 I/O 性能,优化 AOF 写入延迟。
    • 在高写场景下,可将 AOF 存储目录放在单独的磁盘上,减少与其他业务 I/O 干扰。

5.3. 可能遇到的故障与排查

  1. RDB 子进程失败

    • 可能原因:磁盘空间不足、内存不足导致 fork() 失败、文件权限问题。
    • 排查方法:查看 Redis 日志(通常位于 /var/log/redis/redis-server.log),关注 BGSAVE 错误信息。
  2. AOF 重写失败

    • 可能原因:AOF 文件过大会导致重写时间过长、写盘 I/O 过慢、fork() 失败。
    • 排查方法:查看 BGREWRITEAOF 的日志输出,检查 aof_rewrite_scheduledaof_rewrite_in_progress 标记以及 aof_current_size
  3. 数据恢复失败

    • RDB 损坏:可以从旧快照恢复或使用 AOF 作为备选;
    • AOF 损坏:Redis 提供 redis-check-aof --fix 工具尝试修复,或从较新 RDB 恢复。
# AOF 修复示例
redis-check-aof --fix /var/lib/redis/appendonly.aof

6. 总结

本文详细解析了 Redis 的两种持久化机制:RDB 快照AOF 日志。通过原理讲解、配置示例和流程图解,我们了解到:

  • RDB(Snapshot)

    • 适合定期冷备份,文件体积小、加载快,但存在数据丢失窗口。
    • 通过 BGSAVE 子进程写盘,对主进程影响较小。
  • AOF(Append-Only File)

    • 适合对数据持久性要求高的场景,几乎不会丢失写数据。
    • 文件体积大、恢复慢,但支持实时写日志与后台重写。

在生产环境中,推荐同时开启 RDB 与 AOF,通过合理调优 save 条件、appendfsync 策略、重写阈值等参数,平衡性能与数据可靠性。此外,通过监控内存、磁盘 I/O、重写日志等指标,能及时发现潜在风险并进行优化。

Redis实战篇:深入探索分布式锁的应用

在微服务与分布式系统中,多个进程或节点需要协调访问共享资源。如何保证在并发环境下,只有一个客户端能够对某个资源进行操作,是架构设计的一项重要挑战。分布式锁正是为了解决这一类问题而存在。作为一个高性能的内存数据库,Redis 以其原子命令和丰富的数据结构,天然适合用于实现分布式锁。本文将通过代码示例、图解与详细说明,帮助你从零开始掌握 Redis 分布式锁的原理与最佳实践。


目录

  1. 分布式锁概述
  2. 基于 SETNX 的简易分布式锁

    • 2.1 SETNX 原理与语义
    • 2.2 Java 代码示例(Jedis)
    • 2.3 存在的问题:死锁与误删
  3. 使用 Lua 脚本保证原子性与安全释放

    • 3.1 Lua 脚本原理解析
    • 3.2 Java 调用 Lua 脚本示例(Spring Data Redis)
    • 3.3 流程图解:加锁与解锁的时序
  4. Redisson:生产级分布式锁方案

    • 4.1 Redisson 简介
    • 4.2 Java 示例:使用 Redisson 实现公平锁与可重入锁
  5. 分布式锁常见应用场景

    • 5.1 限流与排队
    • 5.2 分布式任务调度
    • 5.3 资源抢购与秒杀系统
  6. 分布式锁的性能与注意事项

    • 6.1 锁粒度与加锁时长控制
    • 6.2 避免单点故障:哨兵与集群模式
    • 6.3 看门狗(Watchdog)机制与续期
  7. 完整实战示例:秒杀场景下的库存扣减

    • 7.1 需求描述与设计思路
    • 7.2 Lua 脚本实现原子库存扣减
    • 7.3 Java 端集成与高并发测试
  8. 总结与最佳实践

分布式锁概述

在单机程序中,我们常常使用操作系统提供的互斥锁(如 Java 中的 synchronizedReentrantLock)来保证同一 JVM 内线程对共享资源的互斥访问。但是在微服务架构下,往往多个服务实例部署在不同的机器或容器上,进程间无法直接使用 JVM 锁机制。此时,需要借助外部组件来协调——这就是分布式锁的用途。

分布式锁的核心目标

  1. 互斥(Mutual Exclusion)
    任意时刻,只有一个客户端持有锁,其他客户端无法同时获得锁。
  2. 可重入(Reentrancy,可选)
    如果同一客户端在持有锁的情况下再次请求锁,应当允许(可重入锁);否则可能陷入死锁。
  3. 阻塞与非阻塞

    • 阻塞式:若获取锁失败,客户端会阻塞、等待;
    • 非阻塞式:若获取锁失败,直接返回失败,让客户端决定重试或退出。
  4. 防止死锁
    若客户端在持有锁后崩溃或网络抖动导致无法释放锁,必须有过期机制自动释放,以避免其他客户端永远无法获取。
  5. 高可用与性能
    分布式锁的实现需要具备高可用性,不能成为系统瓶颈;在并发量非常高的场景下,需要保证性能足够好。

Redis 为分布式锁提供了天然支持:

  • 原子性命令(如 SETNXDEL 等)可用作加锁与解锁;
  • 内置过期时间(TTL),可避免死锁;
  • Lua 脚本可以将多步操作封装为原子执行;
  • 有成熟的客户端库(如 Redisson)封装了可靠的分布式锁机制。

接下来,我们将一步步深入,从最简单的 SETNX 实现,到 Lua 脚本优化,再到生产级 Redisson 应用,全面掌握 Redis 分布式锁的实践方法。


基于 SETNX 的简易分布式锁

最基础的分布式锁思路是:客户端使用 Redis 命令 SETNX key value(SET if Not eXists)尝试创建一个锁标识。当 SETNX 返回 1 时,表示锁成功获取;当返回 0 时,表示锁已被其他客户端持有,需要重试或直接失败。

2.1 SETNX 原理与语义

  • 语法

    SETNX lock_key client_id
    • lock_key:锁对应的 Redis 键;
    • client_id:唯一标识当前客户端或线程(通常使用 UUID 或 IP+线程ID)。
  • 返回值

    • 如果 lock_key 不存在,Redis 会将其设置为 client_id,并返回 1
    • 如果 lock_key 已存在,什么都不做,返回 0
  • 加锁示例

    > SETNX my_lock "client_123"
    1   # 表示加锁成功
    > SETNX my_lock "client_456"
    0   # 表示加锁失败,my_lock 已被 "client_123" 持有

由于 SETNX 具有原子性,多客户端并发执行时只有一个会成功,满足最基本的互斥需求。

但是,光用 SETNX 还不足够。假设客户端 A 成功设置了锁,但在执行业务逻辑前崩溃或网络中断,锁永远不会被删除,导致后续客户端一直阻塞或失败,出现“死锁”问题。为了解决这一点,需要为锁设置过期时间(TTL),在客户端未正常释放时,由 Redis 自动删除锁键。

Redis 2.6.12 之后推荐使用 SET 命令带上参数 NX(只在键不存在时设置)和 PX(设置过期时间,毫秒级),以原子方式完成“加锁+设置过期”两步操作:

SET lock_key client_id NX PX 5000
  • NX:当且仅当 lock_key 不存在时,才执行设置;
  • PX 5000:将 lock_key 的过期时间设为 5000 毫秒(即 5 秒)。

这种写法避免了先 SETNXEXPIRE 可能出现的竞态问题(在 SETNXEXPIRE 之间 Redis 异常导致锁没有过期时间)。

2.2 Java 代码示例(Jedis)

下面用 Jedis 客户端演示基于 SET NX PX 的简易分布式锁:

import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;

import java.util.UUID;

public class SimpleRedisLock {

    private Jedis jedis;
    private String lockKey;
    private String clientId;         // 唯一标识,确保解锁安全
    private int expireTimeMillis;    // 锁超时时间(毫秒)

    public SimpleRedisLock(Jedis jedis, String lockKey, int expireTimeMillis) {
        this.jedis = jedis;
        this.lockKey = lockKey;
        this.clientId = UUID.randomUUID().toString();
        this.expireTimeMillis = expireTimeMillis;
    }

    /**
     * 尝试获取锁
     *
     * @return true 表示加锁成功;false 表示加锁失败
     */
    public boolean tryLock() {
        SetParams params = new SetParams();
        params.nx().px(expireTimeMillis);
        String result = jedis.set(lockKey, clientId, params);
        return "OK".equals(result);
    }

    /**
     * 释放锁(非安全方式:直接 DEL)
     */
    public void unlockUnsafe() {
        jedis.del(lockKey);
    }

    /**
     * 释放锁(安全方式:检查 value 再删除)
     *
     * @return true 表示释放成功;false 表示未释放(可能锁已过期或非自己的锁)
     */
    public boolean unlockSafe() {
        String value = jedis.get(lockKey);
        if (clientId.equals(value)) {
            jedis.del(lockKey);
            return true;
        }
        return false;
    }
}
  • 构造函数中,为当前客户端生成唯一的 clientId,用来在解锁时验证自身持有锁的合法性。
  • tryLock() 方法使用 jedis.set(lockKey, clientId, nx, px) 原子地完成“加锁 + 过期设置”。
  • unlockUnsafe() 直接 DEL,无法防止客户端误删其他客户端的锁。
  • unlockSafe()GET 判断值是否与 clientId 相同,只有相同时才 DEL,避免误删他人锁。但这段逻辑并非原子,存在并发风险:

    • A 客户端执行 GET,发现和自身 clientId 相同;
    • 在 A 调用 DEL 之前,锁意外过期,B 客户端重新获得锁并设置了新的 clientId
    • A 继续执行 DEL,将 B 加的锁错误删除,导致锁失效。

2.3 存在的问题:死锁与误删

基于上面示例,我们可以总结简易锁实现中常见的两个风险:

  1. 死锁风险

    • 如果客户端在持锁期间崩溃或网络抖动,导致无法主动释放锁,但使用了带过期时间的 SET NX PX,锁会在到期后自动释放,从而避免死锁。但如果不设过期,或者业务时间超过过期时间,又没有续期机制,会造成后续客户端加锁失败。
  2. 误删他人锁

    • 在非原子 “检查再删除” 逻辑中,客户端有可能在检查到锁属于自己但在调用 DEL 之前发生超时或运行延迟,造成误删了后来获得锁的其他客户端的锁。
    • 因此,必须用 Lua 脚本将“比对 value + 删除 key”两步操作封装为原子命令。

为保证安全释放,我们需要借助 Lua 脚本。下面详细演示如何在 Redis 端使用 Lua 脚本,确保原子执行。


使用 Lua 脚本保证原子性与安全释放

Redis 内置的 Lua 引擎允许我们将多条命令组合为单个原子操作。借助 Lua 脚本,可以在解锁时进行“判断 value 是否匹配”与“删除 key”两步的原子化,从而完全杜绝误删他人锁的问题。

3.1 Lua 脚本原理解析

3.1.1 加锁脚本

我们使用更通用的 SET 命令带参数实现“加锁 + 过期”,无需额外的 Lua 脚本。示例:

EVAL "return redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2])" 1 lock_key client_id 5000
  • KEYS[1]:锁键(lock_key
  • ARGV[1]:客户端标识(client_id
  • ARGV[2]:过期时间(5000 毫秒)
  • 返回值:

    • "OK" 表示加锁成功;
    • nil 表示加锁失败。

不过,因为 SET NX PX 本身就是原子命令,没有必要用 Lua 包装。我们直接在客户端用 jedis.set(key, value, nx, px) 即可。

3.1.2 解锁脚本

下面是一段完整的 Lua 脚本 unlock.lua,用于安全释放分布式锁:

-- unlock.lua
-- KEYS[1] = lock_key
-- ARGV[1] = client_id

if redis.call("GET", KEYS[1]) == ARGV[1] then
    -- 只有当锁的持有者与传入 client_id 一致时,才删除锁
    return redis.call("DEL", KEYS[1])
else
    return 0
end
  • 逻辑解析

    1. redis.call("GET", KEYS[1]):获取锁键存储的 client_id
    2. 如果与 ARGV[1] 相同,说明当前客户端确实持有锁,于是执行 redis.call("DEL", KEYS[1]) 删除锁,返回值为 1 (表示删除成功);
    3. 否则返回 0,表示未执行删除(可能锁已过期或锁持有者不是当前客户端)。
  • 原子性保证
    整段脚本在 Redis 端一次性加载并执行,期间不会被其他客户端命令打断,保证“比对+删除”操作的原子性,从根本上避免了在“GET”与“DEL”之间的竞态条件。

3.2 Java 调用 Lua 脚本示例(Spring Data Redis)

假设你在 Spring Boot 项目中使用 Spring Data Redis,可以这样加载并执行 Lua 脚本:

3.2.1 将 unlock.lua 放到 resources/scripts/ 目录下

src
└── main
    └── resources
        └── scripts
            └── unlock.lua

3.2.2 定义 Spring Bean 加载 Lua 脚本

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;

@Configuration
public class RedisScriptConfig {

    /**
     * 将 unlock.lua 脚本加载为 DefaultRedisScript
     */
    @Bean
    public DefaultRedisScript<Long> unlockScript() {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        // 指定脚本路径 相对于 classpath
        script.setLocation(new ClassPathResource("scripts/unlock.lua"));
        // 返回值类型
        script.setResultType(Long.class);
        return script;
    }
}

3.2.3 在分布式锁工具类中执行脚本

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Service
public class RedisDistributedLock {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private DefaultRedisScript<Long> unlockScript;

    private static final long DEFAULT_EXPIRE_MILLIS = 5000; // 默认锁过期 5 秒

    /**
     * 获取分布式锁
     *
     * @param lockKey 锁 Key
     * @return clientId 用于之后解锁时比对;如果返回 null 表示获取锁失败
     */
    public String tryLock(String lockKey) {
        String clientId = UUID.randomUUID().toString();
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, clientId, DEFAULT_EXPIRE_MILLIS, TimeUnit.MILLISECONDS);
        if (Boolean.TRUE.equals(success)) {
            return clientId;
        }
        return null;
    }

    /**
     * 释放锁:使用 Lua 脚本保证原子性
     *
     * @param lockKey  锁 Key
     * @param clientId 获取锁时返回的 clientId
     */
    public boolean unlock(String lockKey, String clientId) {
        // KEYS[1] = lockKey; ARGV[1] = clientId
        Long result = redisTemplate.execute(
                unlockScript,
                Collections.singletonList(lockKey),
                clientId
        );
        return result != null && result > 0;
    }
}
  • tryLock 方法:

    • 通过 setIfAbsent(key, value, timeout, unit) 相当于 SET key value NX PX timeout,如果返回 true,表示加锁成功并设置过期时间。
    • 返回随机 clientId,用于后续安全解锁。若返回 null,表示加锁失败(已被占用)。
  • unlock 方法:

    • 通过 redisTemplate.execute(unlockScript, keys, args)unlock.lua 脚本在 Redis 端执行,原子地完成判断与删除。

3.3 流程图解:加锁与解锁的时序

下面用一个简化的 ASCII 图,帮助理解 Redis 分布式锁在加锁与解锁时的各个步骤:

                          ┌──────────────────────────────────┐
                          │            Redis Server          │
                          └──────────────────────────────────┘
                                     ▲             ▲
                                     │             │
          1. tryLock("my_lock")      │             │ 4. unlock("my_lock", clientId)
             SET my_lock clientId NX PX expireTime │
                                     │             │
                                     ▼             │
   ┌───────────────────────┐    ┌──────────────────────────────────┐
   │   应用 A(客户端)     │    │ 1. Redis 端执行 SETNX + EXPIRE     │
   │                       │    │    原子完成后返回 OK               │
   │ clientId = uuid-A     │    └──────────────────────────────────┘
   │ 加锁成功              │              │
   │ 业务逻辑执行中...     │              ▼
   │                       │    ┌──────────────────────────────────┐
   │                       │    │  /Lock Keys                       │
   │                       │    │  my_lock -> uuid-A (TTL: expire)  │
   └───────────────────────┘    └──────────────────────────────────┘
                                     ▲
                                     │
                 2. 其他客户端 B    │    3. A 调用 unlock 前锁过期?
                    tryLock        │
                 SET my_lock uuid-B?│
                   返回 null       │
                                     │
                                     │
           ┌───────────────────────┐  │            ┌───────────────────────┐
           │ 应用 B(客户端)      │  │            │ 应用 A 调用 unlock   │
           │ 加锁失败,返回 null   │  │            │(执行 Lua 脚本)     │
           └───────────────────────┘  │            └───────────────────────┘
                                     │                   │
                                     │ 4.1 Redis 接收 Lua 脚本  │
                                     │    if GET(key)==clientId │
                                     │      then DEL(key)       │
                                     │      else return 0       │
                                     │
                                     ▼
                           ┌──────────────────────────────────┐
                           │     Lock Key 可能已过期或被 B 获得   │
                           │  - 若 my_lock 值 == uuid-A: DEL 成功  │
                           │  - 否则返回 0,不删除任何数据        │
                           └──────────────────────────────────┘
  • 步骤 1:客户端 A 通过 SET key value NX PX expire 成功加锁;
  • 步骤 2:锁过期前,客户端 B 反复尝试 SET key 均失败;
  • 步骤 3:客户端 A 业务逻辑执行完毕,调用 unlock 方法,在 Redis 端运行 unlock.lua 脚本;
  • 步骤 4:Lua 脚本比对 GET(key)clientId,如果一致则 DEL(key),否则不做任何操作,保证安全释放。

通过上述方式,我们既保证了锁在超时后自动释放,也避免了误删他人锁的风险。


Redisson 生产级分布式锁方案

虽然自己动手实现分布式锁可以帮助理解原理,但在生产环境中有以下挑战:

  • 需要处理锁续期、锁失效、锁可重入、可重试、超时控制等复杂逻辑;
  • 要考虑 Redis 单点故障,需要使用 Redis Sentinel 或 Cluster 模式保证高可用;
  • 如果自己实现的代码不够健壮,在极端并发情况下可能出现竞态或性能瓶颈。

为此,Redisson(基于 Jedis/Lettuce 封装的 Redis 客户端工具包)提供了一套成熟的分布式锁方案,功能丰富、易用且可靠。Redisson 内部会自动完成续期看门狗、超时回退等机制,支持多种锁类型(可重入锁、公平锁、读写锁、信号量等)。

4.1 Redisson 简介

  • 起源:由 Redisson 团队开发,是一个基于 Netty 的 Redis Java 客户端,封装了众多 Redis 功能。
  • 核心特性

    • 可重入锁(Reentrant Lock)
    • 公平锁(Fair Lock)
    • 读写锁(ReadWrite Lock)
    • 信号量(Semaphore)Latch
    • 分布式队列、集合、映射 等。
    • 支持单机、Sentinel、Cluster 模式。
    • 内置看门狗(Watchdog)机制,自动续期锁,防止锁误释放。
  • maven 依赖

    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>3.25.0</version>
    </dependency>

    也可以只引入 redisson 核心包,根据需要自行配置。

4.2 Java 示例:使用 Redisson 实现公平锁与可重入锁

下面演示如何在 Spring Boot 中,通过 Redisson 快速实现分布式锁。

4.2.1 配置 Redisson Client

application.yml 中配置 Redis 地址(以单机模式为例):

spring:
  redis:
    host: 127.0.0.1
    port: 6379
  redisson:
    # 可以将 Redisson 配置都放在 config 文件中,也可以使用 spring-boot-starter 默认自动配置
    # 这里使用简单模式,指向单个 Redis 节点
    address: redis://127.0.0.1:6379
    lockWatchdogTimeout: 30000 # 看门狗超时时间(ms),Redisson 会自动续期直到 30 秒

如果希望使用 Sentinel 或 Cluster,只需将 addresssentinelAddressesclusterNodes 等配置项配置好即可。

4.2.2 注入 RedissonClient 并获取锁

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
public class RedissonLockService {

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 获取可重入锁并执行业务
     *
     * @param lockKey  锁名称
     * @param leaseTime 锁过期时间(秒)
     * @return 返回业务执行结果
     */
    public String doBusinessWithReentrantLock(String lockKey, long leaseTime) {
        RLock lock = redissonClient.getLock(lockKey);
        boolean acquired = false;
        try {
            // 尝试加锁:等待时间 3 秒,锁超时时间由 leaseTime 决定
            acquired = lock.tryLock(3, leaseTime, TimeUnit.SECONDS);
            if (!acquired) {
                return "无法获取锁,业务拒绝执行";
            }
            // 模拟业务逻辑
            Thread.sleep(2000);
            return "业务执行完成,锁自动续期或定时释放";
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "业务执行被打断";
        } finally {
            if (acquired && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    /**
     * 公平锁示例:保证先请求锁的线程先获得锁
     */
    public String doBusinessWithFairLock(String lockKey) {
        RLock fairLock = redissonClient.getFairLock(lockKey + ":fair");
        boolean acquired = false;
        try {
            acquired = fairLock.tryLock(5, 10, TimeUnit.SECONDS);
            if (!acquired) {
                return "无法获取公平锁,业务拒绝执行";
            }
            // 模拟业务
            Thread.sleep(1000);
            return "公平锁业务执行完成";
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "业务执行被打断";
        } finally {
            if (acquired && fairLock.isHeldByCurrentThread()) {
                fairLock.unlock();
            }
        }
    }
}
  • getLock(lockKey) 返回一个常规的可重入锁(非公平),Redisson 会在内部创建并维护一个有序的临时节点队列,结合看门狗机制自动续期。
  • getFairLock(lockKey) 返回一个公平锁,会严格按照请求顺序分配锁,适用于对公平性要求高的场景。
  • lock.tryLock(waitTime, leaseTime, unit)

    • waitTime:尝试获取锁的最长等待时间,超过则返回 false
    • leaseTime:加锁成功后,锁的自动过期时间;如果 leaseTime 为 0,则会启用看门狗模式,Redisson 会在锁快到过期时自动续期(续期周期为过期时间的 1/3)。

4.2.3 在 Controller 中使用

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class RedissonLockController {

    @Autowired
    private RedissonLockService lockService;

    @GetMapping("/redisson/reentrant")
    public String testReentrantLock() {
        return lockService.doBusinessWithReentrantLock("myReentrantLock", 10);
    }

    @GetMapping("/redisson/fair")
    public String testFairLock() {
        return lockService.doBusinessWithFairLock("myLock");
    }
}
  • 并发访问 /redisson/reentrant/redisson/fair 即可看到锁的排队与互斥执行效果。

分布式锁常见应用场景

分布式锁广泛应用于多实例系统中对共享资源或关键业务的互斥保护,以下列举常见场景:

5.1 限流与排队

  • 流量突发保护:当某个接口或资源承受高并发请求时,可先通过获取锁(或令牌桶、信号量)来限制同时访问人数。
  • 排队处理:对一批请求,串行化顺序执行,例如限购系统中,先获取锁的用户方可继续扣库存、下单,其他用户需排队等待或返回 “系统繁忙”。

5.2 分布式任务调度

  • 定时任务去重:在多台机器上同时部署定时任务,为了避免同一个任务被多次执行,可以在执行前获取一把分布式锁,只有持有锁的实例才执行任务。
  • Leader 选举:多个调度节点中,只有 Leader(获得锁的节点)执行特定任务,其他节点处于候选或 standby 状态。

5.3 资源抢购与秒杀系统

  • 库存扣减:当大批量用户同时抢购某个商品时,需要保证库存只被扣减一次。加锁可让一个用户在扣减库存期间,其他并发请求暂时阻塞或失败。
  • 支付与退款:对于同一订单多次支付或退款操作,需要使用分布式锁保证只能有一个线程对该订单进行状态变更。

分布式锁的性能与注意事项

在生产环境使用 Redis 分布式锁,需要注意以下性能和可靠性细节:

6.1 锁粒度与加锁时长控制

  • 锁粒度:不要为了简单而把全局资源都用同一个锁。应尽可能缩小锁粒度,例如对同一个“用户 ID”加锁,而非对整个“商品库存”加锁。
  • 加锁时长:合理设置过期时间,既要足够长以完成业务,又不能过度冗余,避免长时间持有锁阻塞其他请求。对于无法预估业务耗时场景,推荐使用看门狗模式(Redisson 自动续期),或定时手动续期。
  • 超时退避:当获取锁失败时,可采用指数退避(Exponential Backoff)策略,避免大量客户端瞬间重试造成雪崩。

6.2 避免单点故障:哨兵与集群模式

  • 单机模式:若 Redis 单节点出现故障,锁服务不可用。生产环境应避免使用单机模式。
  • 哨兵模式(Sentinel):可配置多个 Redis 实例组成哨兵集群,实现主从切换与自动故障转移。Redisson 与 Jedis 都支持哨兵模式的连接。
  • 集群模式(Cluster):Redis Cluster 将数据分片到多台节点,可实现更高的可用与可扩展。Redisson 也支持 Cluster 模式下的分布式锁。需注意:在 Cluster 模式下,使用分布式锁时要保证加锁与解锁操作发送到同一主节点,否则由于网络分片机制造成一致性问题。

6.3 看门狗(Watchdog)机制与续期

  • 看门狗概念:一些客户端(如 Redisson)会在加锁时启动一个“看门狗”线程,不断向 Redis 发送 PEXPIRE 延长过期时间,防止锁在持有过程中因过期而被其他客户端误获取。
  • 实现原理:Redisson 在 lock()tryLock() 成功后,会根据锁的 leaseTime 或默认值,启动一个后台定时任务,周期性地续期。例如默认 leaseTime=30 秒时,每隔 10 秒(默认 1/3)向 Redis 发送延时续命令,直到调用 unlock() 或看门狗检测到应用宕机。
  • 注意:如果使用自己手撰的 SET NX PX 方案,需要自行实现续期逻辑,否则锁在超时时间到达后,Redis 会自动删除,可能导致持锁客户端仍在执行业务时锁被误释放。

完整实战示例:秒杀场景下的库存扣减

下面通过一个典型的“秒杀系统”案例,将前文所述技术串联起来,演示如何在高并发场景下,利用 Redis 分布式锁与 Lua 脚本实现原子库存扣减并防止超卖。

7.1 需求描述与设计思路

  • 场景:假设某电商平台对某款热门商品发起秒杀活动,初始库存为 100 件。短时间内可能有上万用户并发请求秒杀。
  • 核心挑战

    1. 防止超卖:在高度并发下,只允许库存 > 0 时才能扣减,扣减后库存减 1,并录入订单信息。
    2. 保证原子性:库存检查与扣减必须在 Redis 端原子执行,防止出现并发竞态造成库存负数(即超卖)。
    3. 分布式锁保护:在订单生成和库存扣减的代码区域,需保证同一件商品只有一个线程能操作库存。
  • 解决方案思路

    1. 使用 Redis Lua 脚本,将“检查库存 + 扣减库存 + 记录订单”三步操作打包为一次原子执行,保证不会中途被其他客户端打断。
    2. 使用分布式锁(Redisson 或原生 SET NX PX + Lua 解锁脚本)保护下单流程,避免在库存扣减与订单写库之间发生并发冲突。
    3. 结合本地缓存或消息队列做削峰,进一步减轻 Redis 压力,此处主要聚焦 Redis 分布式锁与 Lua 脚本实现,不展开队列削峰。

7.2 Lua 脚本实现原子库存扣减

7.2.1 脚本逻辑

将以下 Lua 脚本保存为 seckill.lua,放置在项目资源目录(如 resources/scripts/seckill.lua):

-- seckill.lua
-- KEYS[1] = 库存 key,例如 "seckill:stock:1001"
-- KEYS[2] = 订单 key,例如 "seckill:order:userId"
-- ARGV[1] = 当前用户 ID (用户标识)
-- ARGV[2] = 秒杀订单流水号 (唯一 ID)

-- 查询当前库存
local stock = tonumber(redis.call("GET", KEYS[1]) or "-1")
if stock <= 0 then
    -- 库存不足,直接返回 0 表示秒杀失败
    return 0
else
    -- 库存充足,扣减库存
    redis.call("DECR", KEYS[1])
    -- 生成用户订单,可以把订单流水号存入一个 Set 或者按需存储
    -- 这里示例为将订单记录到 HASH 结构中,key 为 KEYS[2], field 为 用户ID, value 为 订单流水号
    redis.call("HSET", KEYS[2], ARGV[1], ARGV[2])
    -- 返回 1 表示秒杀成功
    return 1
end
  • 参数说明

    • KEYS[1]:当前商品的库存键,初始值为 库存数量
    • KEYS[2]:用于存储所有成功秒杀订单的键(HASH 结构),键名格式可自定义,如 seckill:order:1001 表示商品 ID 为 1001 的订单集合。
    • ARGV[1]:秒杀用户 ID,用于作为 HASH 的 field。
    • ARGV[2]:秒杀订单流水号,用于作为 HASH 的 value。
  • 执行逻辑

    1. 通过 redis.call("GET", KEYS[1]) 获取当前库存数,若 <= 0 返回 0,秒杀失败;
    2. 否则,执行 DECR 扣减库存;
    3. 将该用户的订单流水号记录到 HSET KEYS[2] ARGV[1] ARGV[2],用于后续下游处理(如持久化到数据库)。
    4. 最后返回 1,表示秒杀成功。

7.2.2 优势分析

  • 由于整个脚本在 Redis 端以单次原子操作执行,不会被其他客户端命令插入,因此库存检查与扣减的逻辑绝对不会出现竞态,避免了“超卖”。
  • 通过 HSET 记录订单,仅当扣减库存成功时才执行,保证库存与订单信息一致。
  • Lua 脚本执行速度远快于客户端多次 GET/DECR/HSET 的网络往返,性能更高。

7.3 Java 端集成与高并发测试

下面以 Spring Boot + Spring Data Redis 为例,展示如何加载并执行 seckill.lua 脚本,并模拟高并发进行秒杀测试。

7.3.1 项目依赖(pom.xml

<dependencies>
    <!-- Spring Boot Starter Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Data Redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!-- Lettuce Client(Redis 客户端) -->
    <dependency>
        <groupId>io.lettuce</groupId>
        <artifactId>lettuce-core</artifactId>
    </dependency>

    <!-- Redisson,用于分布式锁 -->
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>3.25.0</version>
    </dependency>
</dependencies>

7.3.2 加载 Lua 脚本 Bean

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;

@Configuration
public class SeckillScriptConfig {

    @Bean
    public DefaultRedisScript<Long> seckillScript() {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setLocation(new ClassPathResource("scripts/seckill.lua"));
        script.setResultType(Long.class);
        return script;
    }
}

7.3.3 秒杀服务实现

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Service
public class SeckillService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private DefaultRedisScript<Long> seckillScript;

    @Autowired
    private RedissonClient redissonClient;

    // 模拟秒杀接口
    public String seckill(String productId, String userId) {
        String stockKey = "seckill:stock:" + productId;
        String orderKey = "seckill:order:" + productId;
        String orderId = UUID.randomUUID().toString();

        // 1. 获取分布式锁,防止同一用户并发重复购买(可选)
        String userLockKey = "seckill:userLock:" + userId;
        RLock userLock = redissonClient.getLock(userLockKey);
        boolean lockAcquired = false;
        try {
            lockAcquired = userLock.tryLock(3, 5, TimeUnit.SECONDS);
            if (!lockAcquired) {
                return "请勿重复请求";
            }

            // 2. 调用 Lua 脚本执行原子库存扣减 + 记录订单
            Long result = redisTemplate.execute(
                    seckillScript,
                    Collections.singletonList(stockKey),
                    Collections.singletonList(orderKey),
                    userId,
                    orderId
            );
            if (result != null && result == 1) {
                return "秒杀成功,订单ID=" + orderId;
            } else {
                return "秒杀失败,库存不足";
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "系统异常,请重试";
        } finally {
            if (lockAcquired && userLock.isHeldByCurrentThread()) {
                userLock.unlock();
            }
        }
    }

    /**
     * 初始化库存,用于测试
     */
    public void initStock(String productId, int count) {
        String stockKey = "seckill:stock:" + productId;
        redisTemplate.opsForValue().set(stockKey, count);
    }
}
  • 步骤解析

    1. 分布式锁保护

      • userLockKey = "seckill:userLock:" + userId 为锁的 Key,只允许同一个用户在并发场景下只有一把锁,避免重复请求。
      • Redisson 的 tryLock 会自动续期(看门狗),锁过期后自动解锁,防止死锁。
    2. 调用 Lua 脚本

      • redisTemplate.execute(seckillScript, keys, args...) 会在 Redis 端原子执行 seckill.lua 脚本,实现库存检查与扣减、订单记录。
      • 脚本返回 1 表示扣减成功,返回 0 表示库存不足。
    3. 释放分布式锁

      • 无论秒杀成功或失败,都要在 finally 中释放锁,避免锁泄漏。

7.3.4 Controller 暴露秒杀接口

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/seckill")
public class SeckillController {

    @Autowired
    private SeckillService seckillService;

    /**
     * 初始化库存,非真实场景仅用于测试
     */
    @PostMapping("/init")
    public String init(@RequestParam String productId, @RequestParam int count) {
        seckillService.initStock(productId, count);
        return "初始化库存成功,商品ID=" + productId + ",库存=" + count;
    }

    /**
     * 秒杀接口
     */
    @PostMapping("/buy")
    public String buy(@RequestParam String productId, @RequestParam String userId) {
        return seckillService.seckill(productId, userId);
    }
}

7.3.5 高并发测试演示

  1. 启动 Redis(建议单机模式即可)
  2. 启动 Spring Boot 应用
  3. 初始化库存

    curl -X POST "http://localhost:8080/seckill/init?productId=1001&count=100"
  4. 并发模拟用户抢购

    • 编写一个简单的脚本或使用压测工具(如 ApacheBench、JMeter)发送并发 curl 请求:

      for i in {1..200}; do
        curl -X POST "http://localhost:8080/seckill/buy?productId=1001&userId=user_$i" &
      done
      wait
    • 观察执行结果,大约有 100 条返回 “秒杀成功”,其余“秒杀失败,库存不足”。
    • 可以从 Redis 中查看库存剩余为 0,订单记录存储成功。
  5. Redis 中验证结果

    redis-cli
    > GET seckill:stock:1001
    "0"
    
    > HGETALL seckill:order:1001
    1) "user_1"
    2) "orderId-xxx"
    3) "user_2"
    4) "orderId-yyy"
    ...
    • HGETALL seckill:order:1001 列出了所有成功抢购的用户 ID 及订单流水号,确保没有超卖。

通过上述示例,我们利用 Redis Lua 脚本完成了关键的“检查库存 + 扣减库存 + 记录订单”原子操作,并结合分布式锁(Redisson)防止同一用户重复请求,达到了秒杀场景下的高并发安全保护。


总结与最佳实践

本文从最基础的 SETNX 实现,到使用 Lua 脚本保证原子性,再到 Redisson 生产级分布式锁 的使用,系统地讲解了 Redis 分布式锁的原理与实践。以下几点是实际项目中经常需要注意的最佳实践与总结:

  1. Redis 单点要避免

    • 生产环境请部署 Redis Sentinel 或 Cluster,保证分布式锁服务的高可用。
    • Redisson 能够自动感知主从切换,并维护锁的续期与数据一致性。
  2. 加锁时长需合理

    • 业务执行时间不可预估时,推荐使用 Redisson 的 Watchdog 机制,让锁自动续期,避免锁在业务执行过程中意外过期。
    • 如果选择手动管理过期时间(PX 参数),务必确保过期时间大于业务耗时,并考虑超时续期机制。
  3. 锁粒度需细化

    • 避免使用过于粗糙的全局锁,合理拆分资源维度,按业务对象(如“商品ID+用户ID”或“订单ID”)加锁,减少锁冲突。
    • 可以结合本地缓存、消息队列等方式,减少对 Redis 分布式锁的压力。
  4. Lua 脚本封装关键逻辑

    • 将“检查值 + 修改值”这种需要原子执行的操作都封装到 Lua 脚本中,避免客户端多次网络往返和中途竞态。
    • Lua 脚本性能优异,几乎和普通 Redis 命令一样快,可放心在高并发场景下使用。
  5. 监控与日志

    • 对于分布式锁的获取与释放,需要做好监控与日志记录,尤其是失败场景的告警与追踪,保证系统可观测性。
    • 记录锁获取失败的次数和耗时,帮助调试性能瓶颈和锁等待问题。
  6. 竞态重试与退避策略

    • 并发非常高时,大量客户端同时抢锁,可能造成 Redis 压力陡增。可在客户端实现重试次数与退避机制,避免“热点”锁雪崩。
    • 例如:tryLock 失败后,先 sleep 10ms,再重试;若再次失败,则根据指数退避逐渐延长重试间隔。

通过深入理解分布式锁的原理、常见风险以及成熟的解决方案(如 Redisson),你可以在实际场景中灵活应用 Redis 分布式锁,保证系统在高并发情况下仍能正确、稳定地完成关键业务逻辑。

SpringBoot实战:利用Redis Lua脚本实现分布式多命令原子操作与锁

在分布式系统中,多个客户端同时访问同一份共享资源时,往往需要保证操作的原子性与并发安全。Redis 天然支持高并发场景,但如果仅依赖其单命令原子性,对于多命令组合场景(比如同时修改多个键、检查并更新等)就无法保证原子性。而借助 Lua 脚本,Redis 可以将多条命令包装在同一个脚本里执行,保证**“一组命令”**在 Redis 侧原子执行,从而避免并发冲突。此外,Lua 脚本也常用于实现可靠的分布式锁逻辑。

本文将以 Spring Boot + Spring Data Redis 为基础,全面讲解如何通过 Redis Lua 脚本实现:

  1. 多命令原子操作
  2. 分布式锁(含锁超时续命令与安全释放)

内容包含环境准备、概念介绍、关键代码示例、以及图解说明,帮助你更容易上手并快速应用到项目中。


目录

  1. 环境准备
    1.1. 技术栈与依赖
    1.2. Redis 环境部署
  2. Lua 脚本简介
  3. Spring Boot 集成 Spring Data Redis
    3.1. 引入依赖
    3.2. RedisTemplate 配置
  4. Redis Lua 脚本的原子性与执行流程
    4.1. 为什么要用 Lua 脚本?
    4.2. Redis 调用 Lua 脚本执行流程(图解)
  5. 分布式多命令原子操作示例
    5.1. 场景描述:库存扣减 + 订单状态更新
    5.2. Lua 脚本编写
    5.3. Java 端调用脚本
    5.4. 代码示例详解
    5.5. 执行流程图示
  6. 分布式锁实现示例
    6.1. 分布式锁设计思路
    6.2. 简易版锁:SETNX + TTL
    6.3. 安全释放锁:Lua 脚本检测并删除
    6.4. Java 实现分布式锁类
    6.5. 使用示例与图解
  7. 完整示例项目结构一览
  8. 总结

环境准备

1.1 技术栈与依赖

  • JDK 1.8+
  • Spring Boot 2.5.x 或更高
  • Spring Data Redis 2.5.x
  • Redis 6.x 或更高版本
  • Maven 构建工具

主要依赖示例如下(摘自 pom.xml):

<dependencies>
    <!-- Spring Boot Starter Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Data Redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    
    <!-- Lettuce (Redis Client) -->
    <dependency>
        <groupId>io.lettuce</groupId>
        <artifactId>lettuce-core</artifactId>
    </dependency>

    <!-- 可选:用于 Lombok 简化代码 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    
    <!-- 可选:用于日志 -->
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
    </dependency>
</dependencies>

1.2 Redis 环境部署

本地调试可通过 Docker 快速启动 Redis 实例,命令示例:

docker run -d --name spring-redis -p 6379:6379 redis:6.2.6 redis-server --appendonly yes

如果已经安装 Redis,可直接在本地启动:

redis-server /usr/local/etc/redis/redis.conf

确认 Redis 可用后,可使用 redis-cli 测试连接:

redis-cli ping
# 若返回 PONG 则表示正常

Lua 脚本简介

Lua 是一种轻量级脚本语言,语法简单且灵活。Redis 原生集成了一个 Lua 解释器(基于 Lua 5.1),允许客户端通过 EVAL 命令将“一段” Lua 脚本上传到 Redis 服务器并执行。Lua 脚本执行以下特点:

  1. 原子性
    整段脚本会以单个“调用”原子执行,中间不被其他客户端命令插入。
  2. 效率高
    避免了客户端-服务器之间多次网络往返,直接在服务器端执行多条命令。
  3. 可使用 Redis 原生命令
    在 Lua 脚本里,所有 Redis 命令都可通过 redis.call()redis.pcall() 调用。

常见指令:

  • EVAL script numkeys key1 key2 ... arg1 arg2 ...
  • EVALSHA sha1 numkeys key1 ... arg1 ...

其中:

  • script:Lua 代码
  • numkeys:脚本中要访问的 key 的数量
  • key1/key2...:传入的 key 列表
  • arg1/arg2...:传入的其他参数列表

Spring Boot 集成 Spring Data Redis

3.1 引入依赖

pom.xml 中,确保存在以下依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
</dependency>

Spring Boot 自动配置了 Lettuce 作为 Redis 客户端。如果你想使用 Jedis,只需排除 Lettuce 并引入 Jedis 依赖即可。

3.2 RedisTemplate 配置

在 Spring Boot 中,推荐使用 RedisTemplate<String, Object> 来操作 Redis。我们需要在配置类中进行基础配置:

@Configuration
public class RedisConfig {

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        // 默认 LettuceConnectionFactory 会读取 application.properties 中的配置
        return new LettuceConnectionFactory();
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        // 使用 StringRedisSerializer 序列化 key
        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);

        // 使用 Jackson2JsonRedisSerializer 序列化 value
        Jackson2JsonRedisSerializer<Object> jacksonSerializer =
                new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jacksonSerializer.setObjectMapper(om);
        template.setValueSerializer(jacksonSerializer);
        template.setHashValueSerializer(jacksonSerializer);

        template.afterPropertiesSet();
        return template;
    }
}

application.properties 中,添加 Redis 连接配置:

spring.redis.host=127.0.0.1
spring.redis.port=6379
# 如果有密码,可加上:
# spring.redis.password=yourpassword

有了上述配置后,我们就能在其它组件或 Service 中注入并使用 RedisTemplate<String, Object> 了。


Redis Lua 脚本的原子性与执行流程

4.1 为什么要用 Lua 脚本?

  • 多命令原子性
    如果你在业务逻辑里需要对多个 Key 进行操作(例如:扣库存后更新订单状态),而只是使用多条 Redis 命令,就无法保证这几步操作“同时”成功或失败,存在中途出错导致数据不一致的风险。
  • 减少网络开销
    如果客户端需要执行多条命令,通常要经历 N 次网络往返(RTT)。而使用 Lua 脚本,只需要一次调用,就能在服务器端执行多条命令,极大提高性能。
  • 实现复杂逻辑
    某些场景下,需要复杂的判断、条件分支,这时可以在 Lua 中完成,而不必在客户端反复查询、再发命令,从而减少延迟和潜在的并发问题。

4.2 Redis 调用 Lua 脚本执行流程(图解)

下面是一次典型的 Lua 脚本调用流程示意图:

┌───────────┐               ┌───────────┐               ┌───────────┐
│ Client    │               │ Redis     │               │  Data     │
│ (Java)    │   EVAL LUA     │ Server    │               │ Storage   │
│           ├──────────────▶│           │               │(Key1,Key2)│
└───────────┘    (script)   │           │               └───────────┘
                            │           │
                            │ 1. 加载/执行│
                            │    Lua 脚本│
                            │ 2. 调用 lua │◀────────────┐
                            │    redis.call(... )          │
                            │    多命令执行               │
                            │ 3. 返回结果                  │
                            └───────────┘
                                      ▲
                                      │
                           响应结果    │
                                      │
                              ┌───────────┐
                              │ Client    │
                              │ (Java)    │
                              └───────────┘
  • Step 1:Java 客户端通过 RedisTemplate.execute() 方法,将 Lua 脚本和参数一起提交给 Redis Server。
  • Step 2:Redis 在服务器端加载并执行 Lua 脚本。脚本内可以直接调用 redis.call("GET", key)redis.call("SET", key, value) 等命令。此时,Redis 会对这整个脚本加锁,保证脚本执行期间,其他客户端命令不会插入。
  • Step 3:脚本执行完后,将返回值(可以是数字、字符串、数组等)返回给客户端。

分布式多命令原子操作示例

5.1 场景描述:库存扣减 + 订单状态更新

假设我们有一个电商场景,需要在用户下单时执行两步操作:

  1. 检查并扣减库存
  2. 更新订单状态为“已创建”

如果拆成两条命令:

IF stock > 0 THEN DECR stockKey
SET orderStatusKey "CREATED"

在高并发情况下,这两条命令无法保证原子性,可能出现以下问题:

  1. 扣减库存后,更新订单状态时程序异常,导致库存减少但订单未创建。
  2. 查询库存时,已被其他线程扣减,但未及时更新,导致库存不足。

此时,借助 Lua 脚本可以将“检查库存 + 扣减库存 + 更新订单状态”三步逻辑,放在一个脚本里执行,保证原子性。

5.2 Lua 脚本编写

创建一个名为 decr_stock_and_create_order.lua 的脚本,内容如下:

-- decr_stock_and_create_order.lua

-- 获取传入的参数
-- KEYS[1] = 库存 KEY (e.g., "product:stock:1001")
-- KEYS[2] = 订单状态 KEY (e.g., "order:status:abcd1234")
-- ARGV[1] = 扣减数量 (一般为 1)
-- ARGV[2] = 订单状态 (e.g., "CREATED")

local stockKey = KEYS[1]
local orderKey = KEYS[2]
local decrCount = tonumber(ARGV[1])
local statusVal = ARGV[2]

-- 查询当前库存
local currentStock = tonumber(redis.call("GET", stockKey) or "-1")

-- 如果库存不足,则返回 -1 代表失败
if currentStock < decrCount then
    return -1
end

-- 否则,扣减库存
local newStock = redis.call("DECRBY", stockKey, decrCount)

-- 将订单状态写入 Redis
redis.call("SET", orderKey, statusVal)

-- 返回剩余库存
return newStock

脚本说明:

  1. local stockKey = KEYS[1]:第一个 Redis Key,表示商品库存
  2. local orderKey = KEYS[2]:第二个 Redis Key,表示订单状态
  3. ARGV[1]:要扣减的库存数量
  4. ARGV[2]:订单状态值
  5. 先做库存检查:若不足,直接返回 -1
  6. 再做库存扣减 + 写入订单状态,最后返回剩余库存

5.3 Java 端调用脚本

在 Spring Boot 项目中,我们可以将上述 Lua 脚本放在 resources/scripts/ 目录下,然后通过 DefaultRedisScript 加载并执行。

1)加载脚本

@Component
public class LuaScriptLoader {

    /**
     * 加载 "decr_stock_and_create_order.lua" 脚本文件
     * 脚本返回值类型是 Long
     */
    @Bean
    public DefaultRedisScript<Long> decrStockAndCreateOrderScript() {
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        // 指定脚本文件路径(classpath 下)
        redisScript.setLocation(new ClassPathResource("scripts/decr_stock_and_create_order.lua"));
        redisScript.setResultType(Long.class);
        return redisScript;
    }
}
注意ClassPathResource("scripts/decr_stock_and_create_order.lua") 要与 src/main/resources/scripts/ 目录对应。

2)Service 层执行脚本

@Service
public class OrderService {

    @Autowired
    private StringRedisTemplate stringRedisTemplate; // 也可用 RedisTemplate<String, Object>

    @Autowired
    private DefaultRedisScript<Long> decrStockAndCreateOrderScript;

    /**
     * 尝试扣减库存并创建订单
     *
     * @param productId   商品ID
     * @param orderId     订单ID
     * @param decrCount   扣减数量,一般为1
     * @return 如果返回 -1 ,表示库存不足;否则返回扣减后的剩余库存
     */
    public long decrStockAndCreateOrder(String productId, String orderId, int decrCount) {
        // 组装 Redis key
        String stockKey = "product:stock:" + productId;
        String orderKey = "order:status:" + orderId;

        // KEYS 列表
        List<String> keys = Arrays.asList(stockKey, orderKey);
        // ARGV 列表
        List<String> args = Arrays.asList(String.valueOf(decrCount), "CREATED");

        // 执行 Lua 脚本
        Long result = stringRedisTemplate.execute(
                decrStockAndCreateOrderScript,
                keys,
                args.toArray()
        );

        if (result == null) {
            throw new RuntimeException("Lua 脚本返回 null");
        }
        return result;
    }
}
  • stringRedisTemplate.execute(...):第一个参数是 DefaultRedisScript,指定脚本和返回类型;
  • 第二个参数是 keys 列表;
  • 剩余可变参数 args 对应脚本中的 ARGV

如果 result == -1,代表库存不足,需在用户侧抛出异常或返回提示;否则返回剩余库存供业务使用。

5.4 代码示例详解

  1. Lua 脚本层面

    • 首先用 redis.call("GET", stockKey) 获取当前库存,这是原子操作。
    • 判断库存是否足够:如果 currentStock < decrCount,直接返回 -1,表示库存不足,并结束脚本。
    • 否则,使用 redis.call("DECRBY", stockKey, decrCount) 进行扣减,返回新的库存数。
    • 接着用 redis.call("SET", orderKey, statusVal) 将订单状态写入 Redis。
    • 最后将 newStock 返回给 Java 客户端。
  2. Java 层面

    • 通过 DefaultRedisScript<Long> 将 Lua 脚本加载到 Spring 容器中,该 Bean 名为 decrStockAndCreateOrderScript
    • OrderService 中注入 StringRedisTemplate(简化版 RedisTemplate<String, String>),同时注入 decrStockAndCreateOrderScript
    • 调用 stringRedisTemplate.execute(...),将脚本、Key 列表与参数列表一并传递给 Redis。
    • 使用脚本返回的 Long 值决定业务逻辑分支。

这样一来,无论在多高并发的场景下,这个“扣库存 + 生成订单”操作,都能在 Redis 侧以原子方式执行,避免并发冲突和数据不一致风险。

5.5 执行流程图示

下面用 ASCII 图解总体执行流程,帮助理解:

┌─────────────────┐      1. 发送 EVAL 脚本请求       ┌─────────────────┐
│  Java 客户端    │ ─────────────────────────────▶ │    Redis Server  │
│ (OrderService)  │    KEYS=[stockKey,orderKey]   │                 │
│                 │    ARGV=[1, "CREATED"]       │                 │
└─────────────────┘                                └─────────────────┘
                                                       │
                                                       │ 2. 在 Redis 端加载脚本
                                                       │   并执行以下 Lua 代码:
                                                       │   if stock<1 then return -1
                                                       │   else decr库存; set 订单状态; return newStock
                                                       │
                                                       ▼
                                                ┌─────────────────┐
                                                │  Redis 数据层    │
                                                │ (Key:product:   │
                                                │  stock:1001)    │
                                                └─────────────────┘
                                                       │
                                                       │ 3. 返回执行结果 = newStock 或 -1
                                                       │
                                                       ▼
┌─────────────────┐                                ┌─────────────────┐
│  Java 客户端    │ ◀──────────────────────────── │    Redis Server  │
│ (OrderService)  │    返回 Long result           │                 │
│                 │    (e.g. 99 或 -1)           │                 │
└─────────────────┘                                └─────────────────┘

分布式锁实现示例

在分布式系统中,很多场景需要通过分布式锁来控制同一资源在某一时刻只能一个客户端访问。例如:秒杀场景、定时任务并发调度、数据迁移等。

下面以 Redis + Lua 脚本方式实现一个安全、可靠的分布式锁。主要思路与步骤如下:

  1. 使用 SET key value NX PX timeout 来尝试获取锁
  2. 如果获取成功,返回 OK
  3. 如果获取失败,返回 null,可重试或直接失败
  4. 释放锁时,需要先判断 value 是否和自己存储的标识一致,以防误删他人锁
注意:判断并删除的逻辑需要通过 Lua 脚本实现,否则会出现“先 GET 再 DEL”期间锁被别的客户端抢走,造成误删。

6.1 分布式锁设计思路

  • 锁 Key:比如 lock:order:1234
  • 值 Value:每个客户端生成一个唯一随机值(UUID),保证释放锁时只删除自己持有的锁
  • 获取锁SET lockKey lockValue NX PX expireTime,NX 表示只有当 key 不存在时才设置,PX 表示设置过期时间
  • 释放锁:通过 Lua 脚本,判断 redis.call("GET", lockKey) == lockValue 时,才执行 DEL lockKey

6.2 简易版锁:SETNX + TTL

在没有 Lua 脚本时,最简单的分布式锁(不推荐):

public boolean tryLockSimple(String lockKey, String lockValue, long expireTimeMillis) {
    // 使用 StringRedisTemplate
    Boolean success = stringRedisTemplate.opsForValue()
        .setIfAbsent(lockKey, lockValue, Duration.ofMillis(expireTimeMillis));
    return Boolean.TRUE.equals(success);
}

public void unlockSimple(String lockKey) {
    stringRedisTemplate.delete(lockKey);
}

缺点:

  1. 释放锁时无法判断当前锁是否属于自己,会误删别人的锁。
  2. 如果业务执行时间超过 expireTimeMillis,锁过期后被别人获取,导致解锁删除了别人的锁。

6.3 安全释放锁:Lua 脚本检测并删除

编写一个 Lua 脚本 redis_unlock.lua,内容如下:

-- redis_unlock.lua
-- KEYS[1] = lockKey
-- ARGV[1] = lockValue

-- 只有当存储的 value 和传入 value 相同时,才删除锁
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

运行流程:

  1. client 传入 lockKeylockValue
  2. 脚本先执行 GET lockKey,若值等于 lockValue,则执行 DEL lockKey,并返回删除结果(1)
  3. 否则直接返回 0,不做任何删除

这样就保证了“只删除自己加的锁”,避免误删锁的问题。

6.4 Java 实现分布式锁类

在 Spring Boot 中,我们可以封装一个 RedisDistributedLock 工具类,封装锁的获取与释放逻辑。

1)加载解锁脚本

@Component
public class RedisScriptLoader {

    // 前面已经加载了 decrStock 脚本,下面加载解锁脚本
    @Bean
    public DefaultRedisScript<Long> unlockScript() {
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setLocation(new ClassPathResource("scripts/redis_unlock.lua"));
        redisScript.setResultType(Long.class);
        return redisScript;
    }
}

2)封装分布式锁工具类

@Service
public class RedisDistributedLock {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private DefaultRedisScript<Long> unlockScript;

    /**
     * 尝试获取分布式锁
     *
     * @param lockKey        锁 Key
     * @param lockValue      锁 Value(通常为 UUID)
     * @param expireTimeMillis 过期时间(毫秒)
     * @return 是否获取成功
     */
    public boolean tryLock(String lockKey, String lockValue, long expireTimeMillis) {
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(lockKey, lockValue, Duration.ofMillis(expireTimeMillis));
        return Boolean.TRUE.equals(success);
    }

    /**
     * 释放锁:只有锁的持有者才能释放
     *
     * @param lockKey   锁 Key
     * @param lockValue 锁 Value
     * @return 是否释放成功
     */
    public boolean unlock(String lockKey, String lockValue) {
        List<String> keys = Collections.singletonList(lockKey);
        List<String> args = Collections.singletonList(lockValue);
        // 执行 lua 脚本,返回 1 代表删除了锁,返回 0 代表未删除
        Long result = stringRedisTemplate.execute(unlockScript, keys, args.toArray());
        return result != null && result > 0;
    }
}
方法解析
  • tryLock

    • 使用 stringRedisTemplate.opsForValue().setIfAbsent(key,value,timeout)SETNX + TTL,保证只有当 key 不存在时,才设置成功
    • expireTimeMillis 用于避免死锁,防止业务没有正常释放锁导致锁永远存在
  • unlock

    • 通过先 GET lockKeylockValue 做对比,等于时再 DEL lockKey,否则不删除
    • 这部分通过 redis_unlock.lua Lua 脚本实现原子“校验并删除”

6.5 使用示例与图解

1)使用示例

@RestController
@RequestMapping("/api/lock")
public class LockController {

    @Autowired
    private RedisDistributedLock redisDistributedLock;

    @GetMapping("/process")
    public ResponseEntity<String> processTask() {
        String lockKey = "lock:task:123";
        String lockValue = UUID.randomUUID().toString();
        long expireTime = 5000; // 5秒过期

        boolean acquired = redisDistributedLock.tryLock(lockKey, lockValue, expireTime);
        if (!acquired) {
            return ResponseEntity.status(HttpStatus.CONFLICT).body("获取锁失败,请稍后重试");
        }

        try {
            // 业务处理逻辑
            Thread.sleep(3000); // 模拟执行 3 秒
            return ResponseEntity.ok("任务执行成功");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("任务执行异常");
        } finally {
            // 释放锁(安全释放)
            boolean released = redisDistributedLock.unlock(lockKey, lockValue);
            if (!released) {
                // 日志记录:释放锁失败(可能锁已过期被其他人持有)
                System.err.println("释放锁失败,lockKey=" + lockKey + ", lockValue=" + lockValue);
            }
        }
    }
}

2)解锁 Lua 脚本流程图(图解)

┌────────────────┐         1. EVAL redis_unlock.lua         ┌─────────────────┐
│ Java 客户端    │ ─────────────────────────────────────────▶ │  Redis Server    │
│ (unlock 方法) │    KEYS=[lockKey], ARGV=[lockValue]      │                  │
└────────────────┘                                         └─────────────────┘
                                                              │
                                                              │ 2. 执行 Lua:
                                                              │    if GET(key)==value 
                                                              │       then DEL(key)
                                                              │       else return 0
                                                              │
                                                              ▼
                                                    ┌──────────────────────────┐
                                                    │   Redis Key-Value 存储     │
                                                    │   lockKey -> lockValue     │
                                                    └──────────────────────────┘
                                                              │
                                                              │ 3. 返回结果 1 或 0
                                                              ▼
┌────────────────┐                                         ┌─────────────────┐
│ Java 客户端    │ ◀───────────────────────────────────────── │  Redis Server    │
│ (unlock 方法) │   返回 1(删除成功)或 0(未删除)         │                  │
└────────────────┘                                         └─────────────────┘

这样,分布式锁的获取与释放就得到了很好的保障,在高并发分布式场景中能避免竞态条件与误删锁带来的风险。


完整示例项目结构一览

以下是本文示例代码对应的典型项目目录结构:

springboot-redis-lua-demo/
├── pom.xml
├── src
│   ├── main
│   │   ├── java
│   │   │   └── com.example.redisluademo
│   │   │       ├── RedisConfig.java
│   │   │       ├── LuaScriptLoader.java
│   │   │       ├── OrderService.java
│   │   │       ├── RedisDistributedLock.java
│   │   │       └── controller
│   │   │            ├── OrderController.java
│   │   │            └── LockController.java
│   │   └── resources
│   │       ├── application.properties
│   │       └── scripts
│   │           ├── decr_stock_and_create_order.lua
│   │           └── redis_unlock.lua
│   └── test
│       └── java
│           └── com.example.redisluademo
│               └── RedisLuaDemoApplicationTests.java
└── README.md

简要说明:

  • RedisConfig.java:配置 RedisTemplate
  • LuaScriptLoader.java:加载 Lua 脚本
  • OrderService.java:演示多命令原子操作脚本调用
  • RedisDistributedLock.java:分布式锁工具类
  • OrderController.java:演示下单调用示例(可选,适当演示接口)
  • LockController.java:演示分布式锁场景
  • decr_stock_and_create_order.luaredis_unlock.lua:两个核心 Lua 脚本

总结

本文详细介绍了在 Spring Boot 项目中,如何借助 Redis Lua 脚本,实现:

  1. 分布式多命令原子操作

    • 通过 Lua 脚本将 “检查库存、扣库存、写订单状态” 三步逻辑打包在一起,保证在 Redis 端以原子方式执行,避免中途失败导致数据不一致。
    • 在 Java 侧,通过 DefaultRedisScript 加载脚本并配合 RedisTemplate.execute() 调用脚本。
  2. 分布式锁

    • 结合 SETNX + TTL 实现基本的加锁操作;
    • 利用 Lua 脚本保证“先校验 Value 再删除”这一操作的原子性,避免误删除锁的问题。
    • 在 Java 侧封装加锁与解锁逻辑,确保业务执行期间获取到合适的并发控制。

通过“代码示例 + 图解”,本文帮助你较为清晰地理解 Redis Lua 脚本在高并发场景下的威力,以及如何在 Spring Boot 中优雅地集成使用。你可以将上述示例直接复制到项目中,根据业务需求进行扩展和优化。

Tip

  • 如果业务中有更复杂的并发控制需求,也可以借助像 Redisson 这样的 Redis 客户端,直接使用它封装好的分布式锁和信号量功能。
  • 发布时间和配置请根据线上的 Redis 版本进行测试,注意 Redis 集群模式下 Lua 脚本涉及到多节点 key 存取时,需要将所有 key 定位到同一个 slot,否则脚本会报错。
2024-09-09

这是一个关于如何使用Scrapy-Redis来管理分布式爬虫的文章,它解释了源代码中的相关组件,包括队列管理和去重策略。




# 假设我们有一个Scrapy项目,我们想要使用Scrapy-Redis来管理分布式爬虫。
 
# 首先,我们需要安装Scrapy-Redis:
pip install scrapy-redis
 
# 然后,在Scrapy的settings.py文件中,我们需要配置调度器(scheduler)和队列类(queue class):
 
# settings.py
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
SCHEDULER_PERSIST = True
 
# 如果你想使用PriorityQueue来保证高优先级的请求先被爬取,可以这样设置:
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.PriorityQueue'
# 或者使用FifoQueue按照先进先出顺序排列请求:
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.FifoQueue'
# 或者使用LifoQueue按照后进先出顺序排列请求:
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.LifoQueue'
 
# 最后,指定使用Redis作为数据库和调度器:
ITEM_PIPELINES = {
    'scrapy_redis.pipelines.RedisPipeline': 300,
}
 
# 这里使用了RedisPipeline来存储项目,而不是默认的Scrapy的PicklePipeline。
 
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
 
# 如果你的Redis服务器需要密码,可以设置REDIS_PASSWORD。
# REDIS_PASSWORD = 'your_password'
 
# 现在,当你运行你的Scrapy爬虫时,Scrapy-Redis会使用Redis作为调度和去重的后端,
# 使得爬虫可以跨多个节点分布式地运行。

这段代码展示了如何配置Scrapy项目以使用Scrapy-Redis提供的调度器和去重功能,并且如何设置优先级队列和Last-In-First-Out队列。最后,它指定了使用Redis作为数据库和调度器,以便爬虫可以跨多个节点分布式运行。

2024-09-09



@Configuration
public class RedisConfig {
 
    @Value("${spring.redis.host}")
    private String host;
 
    @Value("${spring.redis.port}")
    private int port;
 
    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port));
    }
 
    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory());
        return template;
    }
}

这段代码定义了一个配置类RedisConfig,它使用Lettuce客户端连接到Redis服务器。它创建了一个LettuceConnectionFactory实例,并配置了主机和端口。同时,它还定义了一个RedisTemplate的Bean,这是一个用于操作Redis的Spring数据模板,它使用提供的连接工厂。这个模板可以用于存储、检索和操作Redis数据。

2024-09-09

在Redis中,可以使用发布/订阅模式(pub/sub)来实现类似消息队列的消息发布和订阅功能。以下是一个使用Python和redis-py库的简单示例:

首先,确保你已经安装了redis-py库:




pip install redis

然后,你可以使用以下代码来实现发布者(Publisher)和订阅者(Subscriber):




import redis
 
# 连接到Redis
r = redis.Redis(host='localhost', port=6379, db=0)
 
# 发布者将消息发送到特定的频道
def publish_message(channel, message):
    r.publish(channel, message)
 
# 订阅者订阅特定的频道并接收消息
def subscribe_to_channel(channel):
    pubsub = r.pubsub()
    pubsub.subscribe(channel)
    for message in pubsub.listen():
        if message['type'] == 'message':
            print(f"Received message: {message['data'].decode()}")
 
# 使用示例
publish_channel = "my-channel"
subscribe_channel = "my-channel"
 
# 启动订阅者线程
import threading
subscriber_thread = threading.Thread(target=subscribe_to_channel, args=(subscribe_channel,))
subscriber_thread.start()
 
# 发布一条消息
publish_message(publish_channel, b"Hello, Redis!")
 
# 等待订阅者接收消息
subscriber_thread.join()

在这个例子中,publish_message函数负责发布消息到指定的频道,而subscribe_to_channel函数则用于订阅该频道并打印接收到的消息。这两个函数可以在不同的程序中或者在同一个程序的不同部分调用。注意,为了避免发送和接收线程相互冲突,这里使用了线程来运行订阅者。

2024-09-09

以下是一个使用Redis实现简易滑动窗口的Python示例代码:




import redis
import time
 
# 连接Redis
r = redis.Redis(host='localhost', port=6379, db=0)
 
# 滑动窗口限流的实现
def is_rate_limited(user_id, max_requests, window_size):
    key = f'rate_limit:{user_id}'
    timestamp = time.time()
 
    # 请求数加一
    r.incr(key)
 
    # 定义窗口内最大请求数
    r.expire(key, window_size)
 
    # 获取窗口内的请求数
    requests = r.get(key)
 
    # 如果请求数超过限制,则返回True表示被限流
    if requests and int(requests) > max_requests:
        return True
    else:
        return False
 
# 用户ID和最大请求数、窗口大小(秒)
user_id = 'user123'
max_requests = 5
window_size = 60
 
# 检查是否被限流
if is_rate_limited(user_id, max_requests, window_size):
    print("被限流了")
else:
    print("没有被限流")

这段代码定义了一个is_rate_limited函数,它使用Redis来跟踪给定用户ID的请求数,并且如果窗口内的请求数超过最大请求限制,则返回True表示被限流。每次调用该函数,请求数都会增加,并且窗口会被重置。这个简易的实现没有考虑多线程/进程的竞争条件,但它可以作为一个基本的滑动窗口限流策略的示范。

2024-09-09



#include <stdio.h>
#include <stdlib.com
 
// 假设的Redis集群模式API
 
// 初始化集群模式的客户端
void init_cluster_mode_client(const char* nodes) {
    // 实现集群模式客户端的初始化逻辑
    printf("初始化Redis集群模式客户端,节点列表:%s\n", nodes);
}
 
// 执行集群模式的命令
void execute_cluster_mode_command(const char* command) {
    // 实现集群模式下命令的执行逻辑
    printf("执行Redis集群模式命令:%s\n", command);
}
 
// 示例代码
int main() {
    // 假设的Redis集群节点列表
    const char* nodes = "127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002";
    
    // 初始化集群模式客户端
    init_cluster_mode_client(nodes);
    
    // 执行集群模式命令
    execute_cluster_mode_command("SET key value");
    execute_cluster_mode_command("GET key");
    
    return 0;
}

这个示例代码展示了如何初始化Redis集群模式客户端和执行集群模式命令。在实际应用中,你需要根据你的Redis库来实现这些函数的具体逻辑。

2024-09-09

问题解释:

  1. Redis 并发:指的是多个客户端同时访问 Redis 服务器时可能出现的性能瓶颈或不可预期的行为。
  2. 穿透:指的是客户端请求查询不存在的 key 时,Redis 不会返回错误,而是返回 nil。可能导致缓存穿透,请求直接打到后端存储。
  3. 雪崩:指的是当 Redis 服务器宕机或网络问题导致无法提供服务时,大量的并发请求会直接打到后端存储,造成其压力过大,甚至导致服务崩溃。

解决方法:

  1. 并发处理:

    • 使用连接池管理并发连接。
    • 对于高并发操作,可以使用 Redis 的事务或者 Lua 脚本来保证操作的原子性。
    • 限制 Redis 的最大连接数,避免由于过多的连接而导致的服务不稳定。
  2. 防止穿透:

    • 使用布隆过滤器:在客户端查询 key 之前,先在布隆过滤器中检查 key 是否存在。如果不存在,就不会请求 Redis。
    • 缓存空值:当 key 不存在时,可以将一个特殊值(如空字符串或特殊对象)缓存起来,避免后端存储压力。
  3. 雪崩预防:

    • 保证 Redis 高可用性:使用 Redis 集群或者 Sentinel 来保证服务的高可用性。
    • 限流与降级:当服务压力过大时,可以采用限流措施,如果 Redis 服务器宕机,可以快速切换到备用服务器或者关闭 Redis 服务,避免对后端存储的进一步压力。