3. Redis 高级特性:事务、Pipeline、Lua 脚本与发布订阅

在构建高并发、高可用的缓存系统时,仅掌握基础的数据结构操作远远不够。Redis 提供的事务、Pipeline、Lua 脚本和发布订阅四大高级特性,分别解决了原子性、批量性能、复杂逻辑编排和消息通知等核心问题。本章将逐一剖析其原理、适用场景及实战中的“坑”。

3.1 Redis 事务:弱隔离与原子性保障

Redis 事务通过 MULTI、EXEC、DISCARD 和 WATCH 命令实现。与关系型数据库不同,Redis 事务不支持回滚,且不保证严格的隔离性(ACID 中的 I)。

3.1.1 事务执行流程

  1. 开启事务:客户端发送 MULTI 命令,进入事务队列模式。
  2. 命令入队:后续所有命令不会立即执行,而是被放入一个队列。
  3. 执行事务:发送 EXEC,Redis 按顺序串行执行队列中的所有命令。
  4. 取消事务:发送 DISCARD,清空队列并退出事务状态。
关键特性:事务中的所有命令在 EXEC 时一次性、顺序执行,期间不会被其他客户端的命令插入(弱隔离)。但如果在入队阶段发生语法错误,整个事务会被拒绝;如果在执行阶段发生运行时错误(如对 string 类型执行 list 操作),错误命令会失败,但后续命令仍会继续执行,不会回滚。

3.1.2 乐观锁:WATCH 命令

WATCH 用于实现 CAS(Check-And-Set)乐观锁。在事务执行前监视一个或多个 key,如果 EXEC 时这些 key 的值被其他客户端修改过,则事务被中断(返回 nil)。

-- 伪代码:库存扣减
WATCH stock:1001
stock = GET stock:1001
if stock > 0 then
    MULTI
    DECR stock:1001
    EXEC
else
    UNWATCH
    return "库存不足"
end
特性 Redis 事务 关系型数据库事务
原子性 部分支持(无回滚) 完全支持(可回滚)
隔离性 弱隔离(串行执行) 强隔离(MVCC/锁)
持久性 依赖 AOF/RDB 依赖 WAL 日志
适用场景 简单原子操作、乐观锁 复杂业务逻辑、强一致性

3.2 Pipeline:批量请求的性能利器

Pipeline 允许客户端将多条命令一次性发送到 Redis 服务器,减少网络往返时间(RTT)。它不保证原子性,但能显著提升批量操作的吞吐量。

3.2.1 工作原理

普通模式下,每条命令都需要一次网络请求和一次响应。Pipeline 将多条命令打包发送,Redis 处理完后一次性返回所有结果。

Pipeline vs 普通模式:网络交互对比 客户端:普通模式(逐条发送) Redis 服务器 SET a 1 +OK GET a "1" 客户端:Pipeline 模式(批量发送) Redis 服务器 SET a 1 / GET a / INCR b +OK / "1" / 2 Pipeline 将多次 RTT 合并为一次,吞吐量提升 5~10 倍(取决于网络延迟)

3.2.2 使用注意事项

  • 非原子性:Pipeline 中的命令可能部分成功、部分失败,需要业务层处理。
  • 内存压力:客户端需要缓存所有响应结果,命令数量过多时可能导致客户端 OOM。
  • 建议批次大小:单次 Pipeline 建议控制在 100~1000 条命令,避免阻塞其他客户端。
实战建议:在缓存预热、批量写入 Session、批量删除缓存 key 等场景中,优先使用 Pipeline 替代循环逐条执行,可降低 80% 以上的网络开销。

3.3 Lua 脚本:原子性与复杂逻辑的完美结合

Redis 从 2.6 版本开始支持内嵌 Lua 脚本。脚本在 Redis 服务器端原子性执行,且可以包含分支、循环等复杂逻辑,是替代事务和 Pipeline 的“终极武器”。

3.3.1 核心命令

命令 说明 示例
EVAL 执行 Lua 脚本 EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 name alice
EVALSHA 执行已缓存的脚本(通过 SHA1 摘要) EVALSHA "a1b2c3..." 1 name alice
SCRIPT LOAD 将脚本缓存到 Redis,返回 SHA1 SCRIPT LOAD "return redis.call('GET', KEYS[1])"
SCRIPT EXISTS 检查脚本是否已缓存 SCRIPT EXISTS "a1b2c3..."

3.3.2 典型应用:原子限流

以下 Lua 脚本实现了一个滑动窗口限流器,在 Redis 中原子性完成计数、过期和阈值判断:

-- 限流脚本:滑动窗口计数器
-- KEYS[1]: 限流 key(如 user:123:rate)
-- ARGV[1]: 窗口大小(毫秒)
-- ARGV[2]: 最大请求数
-- ARGV[3]: 当前时间戳(毫秒)

local key = KEYS[1]
local window = tonumber(ARGV[1])
local max = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- 移除窗口外的旧记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 获取当前窗口内的请求数
local count = redis.call('ZCARD', key)

if count < max then
    -- 添加当前请求
    redis.call('ZADD', key, now, now)
    -- 设置过期时间
    redis.call('PEXPIRE', key, window)
    return 1  -- 允许通过
else
    return 0  -- 限流
end
性能陷阱:Lua 脚本执行期间会阻塞 Redis 服务器,所有其他命令必须等待。因此脚本必须轻量、快速,避免长时间循环或调用耗时命令(如 KEYS、SMEMBERS 大集合)。建议单个脚本执行时间控制在 10 毫秒以内。

3.4 发布订阅:轻量级消息通知

Redis 发布订阅(Pub/Sub)是一种消息传递模式,发布者将消息发送到频道,订阅者接收该频道的所有消息。它不依赖磁盘存储,消息即发即失,适合实时通知场景。

3.4.1 核心命令

  • PUBLISH channel message:向指定频道发送消息。
  • SUBSCRIBE channel [channel ...]:订阅一个或多个频道。
  • PSUBSCRIBE pattern:订阅匹配模式的频道(如 news.*)。
  • UNSUBSCRIBE [channel]:取消订阅。

3.4.2 架构与局限

Redis 发布订阅架构 发布者 (Publisher) 频道 (Channel) 订阅者 A 订阅者 B 订阅者 C PUBLISH 推送 推送 推送 消息不持久化:如果订阅者不在线,消息直接丢失。适合缓存失效通知、配置变更广播等场景

3.4.3 与专业消息队列的对比

特性 Redis Pub/Sub RabbitMQ / Kafka
消息持久化 不支持 支持(磁盘存储)
消息确认 无(发后即忘) 支持 ACK 机制
消费者组 不支持 支持(负载均衡)
适用场景 实时广播、轻量通知 可靠投递、异步任务
缓存一致性场景:当某个缓存 key 被更新时,通过 Pub/Sub 广播“缓存失效”消息,所有订阅该频道的服务实例可以立即删除本地缓存,实现最终一致性。这种方式比轮询更实时,比集中式缓存中心更轻量。

3.5 四大特性选型指南

需求 推荐方案 原因
简单原子操作(如 INCR、SETNX) Redis 单命令 天然原子,无需额外开销
批量读取/写入,不要求原子性 Pipeline 减少 RTT,吞吐量最高
多步操作需要原子性(如扣库存+记录日志) Lua 脚本 原子执行,支持复杂逻辑
乐观锁场景(如秒杀) WATCH + 事务 轻量 CAS,避免锁开销
实时消息广播(如配置变更通知) Pub/Sub 低延迟,一对多推送

在实际项目中,Lua 脚本 + Pipeline 是最常用的组合:用 Pipeline 批量获取数据,用 Lua 脚本保证关键业务的原子性。而 Pub/Sub 则作为辅助手段,用于跨实例的缓存失效通知和配置动态更新。