Redis 高可用与分布式架构
一、Redis 高可用的三个层次
Redis 的高可用体系是层层递进的,每一层解决不同的问题:
| 层次 | 机制 | 解决的问题 |
|---|---|---|
| 第一层 | 持久化(Persistence) | 数据不丢 --- 进程重启后数据可恢复 |
| 第二层 | 复制(Replication) | 读扩展 + 数据冗余 --- 多副本提供读能力和数据安全 |
| 第三层 | 哨兵(Sentinel) | 自动故障转移 --- 主节点宕机后自动切换 |
| 第四层 | 集群(Cluster) | 写扩展 + 数据分片 --- 突破单机写瓶颈和内存上限 |
- 持久化是最基础的高可用手段,保证数据落盘,但无法解决单点故障。
- 复制提供了数据冗余,从节点可以接管读请求,但主节点故障时需要手动切换。
- 哨兵在复制的基础上实现了自动故障转移,无需人工干预。
- 集群在哨兵的基础上进一步解决了水平扩展问题,支持数据分片(Sharding),将数据分布到多个节点上。
四层关系:持久化是基础,复制是前提,哨兵依赖复制,集群是最完整的方案。
二、复制(Replication)
2.1 主从复制模型
Redis 采用的是 异步主从复制(Asynchronous Master-Replica Replication) 模型:
-
Master(主节点):负责读写操作,接收所有写请求。
-
Replica(从节点):从主节点同步数据,只提供读服务(默认配置)。
-
拓扑结构:一主多从,也支持链式复制(从节点再作为其他节点的主节点)。
┌── Replica-1Master ─┼── Replica-2
└── Replica-3 ── Replica-4(链式复制)
核心作用:
- 读扩展(Read Scaling):读请求分散到多个从节点,提升读吞吐量。
- 数据冗余(Data Redundancy):多副本存储,防止数据丢失。
- 高可用基础(HA Foundation):为哨兵和集群的故障转移提供前提条件。
配置方式:
在从节点的 redis.conf 中配置:
conf
replicaof <master-ip> <master-port>
# 或运行时执行命令
REPLICAOF <master-ip> <master-port>
# 取消复制
REPLICAOF NO ONE
从节点默认为只读模式(replica-read-only yes),这是推荐配置,防止数据不一致。
2.2 全量同步(Full Sync)
触发条件:
- 从节点首次连接主节点(无复制历史)。
- 增量同步失败(从节点的复制偏移量不在复制积压缓冲区范围内)。
- 从节点发送的 Replication ID 与主节点不匹配。
全量同步流程:
- 从节点发送
PSYNC ? -1请求全量同步。 - 主节点执行
BGSAVE,在后台生成 RDB 快照。 - 主节点将 RDB 文件发送给从节点。
- 主节点同时将生成 RDB 期间的新写命令缓存在复制积压缓冲区中。
- 从节点清空旧数据,加载 RDB 文件。
- 主节点将积压缓冲区中的增量命令发送给从节点。
- 此后进入命令传播(Command Propagation)阶段,主节点持续将写命令发送给从节点。
伪代码:全量同步流程
pseudocode
// 从节点发起全量同步
function fullSync(replica):
// 步骤1:从节点请求全量同步
replica.sendPSYNC(replication_id="?", offset=-1)
// 步骤2:主节点后台生成RDB
master.startBGSAVE()
new_commands = [] // 缓存BGSAVE期间的新写命令
// 步骤3:主节点继续接收写命令,同时缓存
while not master.bgsaveComplete():
cmd = master.receiveCommand()
new_commands.append(cmd)
master.executeCommand(cmd) // 主节点正常执行
// 步骤4:发送RDB文件给从节点
rdb_file = master.getRDB()
replica.sendRDB(rdb_file)
// 步骤5:从节点清空旧数据并加载RDB
replica.flushAllData()
replica.loadRDB(rdb_file)
// 步骤6:发送积压缓冲区中的增量命令
for cmd in new_commands:
replica.sendCommand(cmd)
replica.executeCommand(cmd)
// 步骤7:进入命令传播阶段
enterCommandPropagation(master, replica)
注意事项:
- 全量同步是一个重操作:主节点执行 BGSAVE 消耗 CPU 和内存,RDB 传输消耗网络带宽,从节点加载 RDB 期间会阻塞服务。
- 生产环境应尽量避免频繁触发全量同步,合理配置
repl-backlog-size是关键。
2.3 增量同步(Partial Sync / PSYNC)
触发条件:
从节点短暂断连后重连,且所需的数据仍在主节点的复制积压缓冲区(Replication Backlog)中。
关键概念:
-
Replication ID(复制ID):每个 Redis 主节点有一个唯一的 Replication ID,用于标识数据集的版本。从节点需要匹配主节点的 Replication ID 才能进行增量同步。
-
Offset(偏移量):主节点每产生一个写命令,偏移量递增。每个从节点记录自己已同步到的偏移量。主节点也记录每个从节点的偏移量,用于判断是否可以增量同步。
-
Replication Backlog(复制积压缓冲区):主节点维护的一个固定大小的环形缓冲区(FIFO),默认 1MB。存储最近的写命令及其偏移量。当缓冲区满时,旧数据被覆盖。
PSYNC 判断逻辑:
从节点重连时发送 PSYNC <replication_id> <offset>,主节点根据以下条件判断:
- Replication ID 是否匹配?
- 请求的 offset 是否在复制积压缓冲区的范围内?
如果两个条件都满足,则进行增量同步;否则进行全量同步。
伪代码:PSYNC 判断逻辑
pseudocode
function handlePSYNC(replication_id, offset):
// 条件1:Replication ID是否匹配
if replication_id != master.replication_id:
log("Replication ID不匹配,需要全量同步")
return FULL_SYNC
// 条件2:offset是否在积压缓冲区范围内
backlog_start = master.backlog_offset // 积压缓冲区起始offset
backlog_end = master.current_offset // 主节点当前offset
if offset >= backlog_start and offset <= backlog_end:
// 增量同步:发送offset之后的命令
pending_commands = master.backlog.getCommandsFrom(offset)
replica.sendPartialData(pending_commands)
replica.updateOffset(backlog_end)
log("增量同步成功,发送积压缓冲区数据")
return PARTIAL_SYNC
else:
log("Offset不在积压缓冲区范围内,需要全量同步")
return FULL_SYNC
PSYNC2(Redis 4.0+):
Redis 4.0 引入了 PSYNC2,支持更复杂的增量同步场景:
- 场景:从节点切换主节点后(如 Sentinel 故障转移),新主节点可能是旧主节点的从节点,两者有共同的复制历史。
- 实现:每个主节点维护两个 Replication ID(当前 ID 和之前的 ID),切换后如果新主节点的历史 ID 匹配,仍可增量同步。
- 意义:减少了故障转移后的全量同步次数,降低了故障恢复的开销。
2.4 复制积压缓冲区调优
默认值问题:
repl-backlog-size 默认为 1MB,这在生产环境中通常远远不够。如果从节点断连时间稍长,积压缓冲区就会被覆盖,导致重连时必须进行全量同步。
计算公式:
repl-backlog-size ≈ 写入速率 × 断连最大时间 × 安全系数
示例计算:
-
写入速率:每秒产生 10MB 的写命令数据。
-
断连最大时间:60 秒(网络抖动恢复时间)。
-
安全系数:2(留有余量)。
repl-backlog-size = 10MB/s × 60s × 2 = 1200MB ≈ 1.2GB
生产建议:
- 常规场景:至少 100MB~256MB。
- 高写入场景:根据公式计算,可能需要 1GB 以上。
repl-backlog-ttl:当没有从节点连接时,积压缓冲区的保留时间,默认 3600 秒。设为 0 表示永不释放。
conf
# redis.conf 配置示例
repl-backlog-size 256mb
repl-backlog-ttl 3600
2.5 异步复制的数据安全
默认行为:异步复制
Redis 的复制默认是异步的:主节点执行写命令后立即返回客户端,不等待从节点确认同步完成。
客户端 → 主节点写入成功 → 返回OK给客户端
↓(异步)
从节点同步数据
风险:
如果主节点在写命令同步到从节点之前宕机,这部分数据会丢失。在主从切换后,丢失的数据无法恢复。
配置保障:
Redis 提供了两个配置项来增强数据安全:
conf
# 至少有N个从节点同步成功,主节点才允许写入
min-replicas-to-write 1
# 从节点的延迟不超过N秒,主节点才允许写入
min-replicas-max-lag 10
工作原理:
pseudocode
function checkWritePermission():
// 统计满足条件的从节点数量
healthy_replicas = 0
for replica in master.replicas:
if replica.lag <= min_replicas_max_lag:
healthy_replicas += 1
// 判断是否满足写入条件
if healthy_replicas >= min_replicas_to_write:
return ALLOW_WRITE
else:
return DENY_WRITE // 返回错误:NOREPLICAS
注意:
- 这两个配置会降低可用性:如果从节点全部断连,主节点将拒绝写入。
- 需要在数据安全和可用性之间权衡。
- 对于要求强一致性的场景,Redis 并非最佳选择,应考虑关系型数据库。
三、Sentinel(哨兵)
3.1 Sentinel 的四大功能
Redis Sentinel 是 Redis 官方提供的高可用(High Availability)解决方案,其核心功能包括:
-
监控(Monitoring):Sentinel 持续向主节点、从节点和其他 Sentinel 发送心跳命令(PING),检测节点是否正常运行。
-
通知(Notification):当 Sentinel 检测到 Redis 实例出现故障时,可以通过 API、Pub/Sub 等方式通知管理员或应用程序。
-
自动故障转移(Automatic Failover):当主节点故障时,Sentinel 自动将一个从节点提升为新的主节点,并重新配置其他从节点复制新主节点,同时通知客户端新主节点的地址。
-
配置提供者(Configuration Provider):客户端不直接连接主节点,而是通过 Sentinel 查询当前主节点的地址。当故障转移发生后,Sentinel 会提供新主节点的地址。
Sentinel 的部署架构:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Sentinel1│ │ Sentinel2│ │ Sentinel3│
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└──────────────┼──────────────┘
│
┌───────────┼───────────┐
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│ Master │ │Replica-1│ │Replica-2│
└─────────┘ └─────────┘ └─────────┘
3.2 Sentinel 集群
为什么需要多个 Sentinel:
单个 Sentinel 存在单点故障风险。如果唯一的 Sentinel 宕机,则无法进行故障转移。因此 Sentinel 必须以集群方式部署。
部署要求:
- 至少 3 个 Sentinel 实例(保证多数派选举)。
- 建议部署在不同的物理机或可用区上,避免共因故障。
- Sentinel 实例数量建议为奇数(3、5、7),便于多数派判断。
通信机制:
- Sentinel 与 Redis 节点:Sentinel 每秒向主从节点发送 INFO 命令,获取拓扑信息;每秒发送 PING 命令检测存活。
- Sentinel 之间 :通过 Redis 主节点的 Pub/Sub 机制通信。每个 Sentinel 每两秒在
__sentinel__:hello频道发布自身信息(IP、端口、runid、配置纪元等),同时订阅该频道获取其他 Sentinel 的信息。 - Sentinel 与客户端 :客户端通过 Sentinel 获取主节点地址,并订阅
+switch-master等事件获取主节点变更通知。
伪代码:Sentinel 监控流程
pseudocode
function sentinelMainLoop(sentinel):
while true:
// 1. 向所有被监控的Redis节点发送PING
for instance in sentinel.monitored_instances:
reply = instance.sendPING()
if reply.isHealthy():
instance.last_ok_ping = now()
else:
instance.last_fail_ping = now()
// 2. 向主节点发送INFO,获取从节点列表
for master in sentinel.masters:
info = master.sendINFO()
sentinel.updateReplicaList(master, info.replicas)
// 3. 通过Pub/Sub与其他Sentinel交换信息
sentinel.publishHello() // 发布自身状态
// 自动通过订阅回调接收其他Sentinel信息
// 4. 检查是否需要故障转移
for master in sentinel.masters:
if sentinel.checkObjectivelyDown(master):
sentinel.startFailoverIfNeeded(master)
sleep(1_second) // 每秒执行一次
Sentinel 配置示例:
conf
# sentinel.conf
port 26379
# 监控名为mymaster的主节点
# 最后一个2是quorum:至少2个Sentinel同意才判定客观下线
sentinel monitor mymaster 127.0.0.1 6379 2
# 主节点无响应超过30秒则判定为主观下线
sentinel down-after-milliseconds mymaster 30000
# 故障转移超时时间
sentinel failover-timeout mymaster 180000
# 同时可以有几个从节点与新主节点同步
sentinel parallel-syncs mymaster 1
3.3 故障检测
Sentinel 的故障检测采用两阶段机制,从主观判断升级到客观共识:
主观下线(SDOWN,Subjective Down):
- 单个 Sentinel 认为某个节点不可达。
- 判断条件:节点在
down-after-milliseconds时间内没有响应 PING 命令。 - 单个 Sentinel 的主观判断可能不准确(如网络分区),不能直接触发故障转移。
客观下线(ODOWN,Objective Down):
- 超过
quorum个 Sentinel 都认为主节点主观下线。 - 只有主节点的 ODOWN 才会触发故障转移(从节点和 Sentinel 的下线不需要 quorum)。
- 这是分布式共识的体现,避免了误判。
伪代码:SDOWN → ODOWN 的判断逻辑
pseudocode
function checkSubjectiveDown(sentinel, master):
// 判断主观下线
elapsed = now() - master.last_ok_ping
if elapsed > sentinel.down_after_milliseconds:
master.flags |= SDOWN
log("Master {} is SDOWN".format(master.name))
else:
master.flags &= ~SDOWN
function checkObjectiveDown(sentinel, master):
// 判断客观下线
if not (master.flags & SDOWN):
return // 未主观下线,不可能客观下线
// 统计认为该主节点SDOWN的Sentinel数量
down_count = 1 // 包含自己
for other_sentinel in sentinel.known_sentinels:
if other_sentinel thinks master is SDOWN:
down_count += 1
// 超过quorum则标记为ODOWN
if down_count >= master.quorum:
if not (master.flags & ODOWN):
log("Master {} is ODOWN (quorum={}/{})".format(
master.name, down_count, master.quorum))
master.flags |= ODOWN
// 触发故障转移流程
sentinel.startFailover(master)
else:
master.flags &= ~ODOWN
注意: quorum 的值应设置为 Sentinel 总数的一半以上(多数派)。例如 3 个 Sentinel 时 quorum 设为 2,5 个时设为 3。
3.4 故障转移流程
当主节点被判定为 ODOWN 后,Sentinel 集群执行以下故障转移流程:
步骤 1:选出新的主节点
选择条件按优先级从高到低:
- 优先级(replica-priority) :从节点的
replica-priority配置值越小优先级越高(默认 100)。设为 0 表示永不被选为主节点。 - 复制偏移量(Replication Offset):偏移量最大的从节点拥有最新的数据,优先选择。
- Run ID 最小:如果以上都相同,选择 Run ID 字典序最小的从节点,保证确定性结果。
步骤 2:通知其他从节点复制新主节点
Sentinel 向所有从节点发送 REPLICAOF <new_master_ip> <new_master_port> 命令,使它们开始复制新主节点。
步骤 3:通知客户端新主节点地址
Sentinel 更新自身维护的主节点映射,客户端下次查询时获取新地址。同时通过 Pub/Sub 发布 +switch-master 事件。
步骤 4:继续监控旧主节点
Sentinel 持续监控旧主节点。如果旧主节点恢复上线,Sentinel 将其设置为新主节点的从节点,避免其重新成为主节点造成脑裂(Split Brain)。
伪代码:Sentinel 故障转移完整流程
pseudocode
function executeFailover(sentinel, master):
log("开始故障转移: {}".format(master.name))
// 步骤1:选举新的主节点
new_master = selectNewMaster(master)
if new_master == null:
log("没有可用的从节点,故障转移失败")
return
log("选中新主节点: {}".format(new_master.name))
// 步骤1.1:向新主节点发送SLAVEOF NO ONE
new_master.sendCommand("SLAVEOF NO ONE")
// 等待新主节点就绪
waitUntil(new_master.isMaster())
// 步骤2:通知其他从节点复制新主节点
for replica in master.replicas:
if replica != new_master:
replica.sendCommand("REPLICAOF {} {}".format(
new_master.ip, new_master.port))
// 控制同时同步的从节点数量(parallel-syncs)
if current_syncing >= master.parallel_syncs:
waitUntil(sync_slot_available)
// 步骤3:更新主节点映射并通知客户端
sentinel.updateMasterMapping(master.name, new_master)
sentinel.publishEvent("+switch-master", {
"name": master.name,
"old_host": master.ip,
"old_port": master.port,
"new_host": new_master.ip,
"new_port": new_master.port
})
// 步骤4:继续监控旧主节点
sentinel.monitorOldMaster(master)
// 当旧主节点恢复后,将其设为新主节点的从节点
// 在监控循环中处理
log("故障转移完成")
function selectNewMaster(master):
candidates = []
for replica in master.replicas:
// 排除不可用的从节点
if replica.isDown() or replica.disconnected():
continue
// 排除5秒内未回复INFO的从节点
if now() - replica.last_info_time > 5_seconds:
continue
// 排除断开主节点连接过久的从节点
if replica.down_after_master:
continue
candidates.append(replica)
if candidates.is_empty():
return null
// 按优先级排序:replica-priority → offset → run_id
candidates.sort(by=[
replica_priority ASC, // 优先级越小越好
replication_offset DESC, // 偏移量越大越好(数据越新)
run_id ASC // Run ID越小越好(确定性)
])
return candidates[0]
3.5 领导者选举
为什么需要领导者:
如果多个 Sentinel 同时发起故障转移,会导致混乱:多个从节点可能同时被提升为主节点,造成脑裂。因此必须选举一个 Sentinel 领导者来负责执行故障转移。
选举规则:
- 最先发现主节点 ODOWN 的 Sentinel 向其他所有 Sentinel 发送
SENTINEL IS-MASTER-DOWN-BY-ADDR命令,申请成为领导者。 - 每个 Sentinel 在同一个配置纪元(Configuration Epoch)中只能投票一次,先到先得。
- 获得多数票 (大于等于
quorum,且大于 Sentinel 总数的一半)的 Sentinel 成为领导者。 - 如果在
failover-timeout时间内没有选出领导者,将在下一个纪元重新选举。
伪代码:领导者选举
pseudocode
function electLeader(sentinel, master):
// 增加配置纪元
sentinel.current_epoch += 1
// 向其他Sentinel请求投票
votes_received = 1 // 投给自己
for other in sentinel.known_sentinels:
response = other.requestVote(
epoch=sentinel.current_epoch,
candidate_id=sentinel.run_id,
master_name=master.name
)
if response.vote_granted:
votes_received += 1
// 判断是否获得多数票
majority = ceil(sentinel.total_count / 2) + 1
if votes_received >= majority:
log("成为领导者,获得{}票".format(votes_received))
return true
else:
log("未获得足够票数({}/{})".format(votes_received, majority))
return false
// Sentinel接收投票请求的逻辑
function handleVoteRequest(request):
// 每个纪元只能投票一次
if request.epoch > sentinel.voted_epoch:
// 先到先得
sentinel.voted_epoch = request.epoch
sentinel.voted_for = request.candidate_id
return {vote_granted: true}
else:
return {vote_granted: false}
3.6 客户端连接 Sentinel
客户端连接流程:
- 客户端连接 Sentinel 集群(可以连接任一 Sentinel 实例)。
- 客户端发送
SENTINEL GET-MASTER-ADDR-BY-NAME <master_name>获取当前主节点地址。 - 客户端连接主节点进行正常读写操作。
- 客户端订阅 Sentinel 的
+switch-master事件,当主节点变更时收到通知并重新连接。
伪代码:客户端连接 Sentinel
pseudocode
function connectViaSentinel(sentinel_addresses, master_name):
// 步骤1:连接Sentinel
sentinel = connectToSentinel(sentinel_addresses)
// 步骤2:获取主节点地址
master_addr = sentinel.sendCommand(
"SENTINEL GET-MASTER-ADDR-BY-NAME", master_name)
// 步骤3:连接主节点
master = connectToRedis(master_addr.ip, master_addr.port)
// 步骤4:订阅主节点变更事件
sentinel.subscribe("+switch-master", function(event):
if event.master_name == master_name:
log("主节点变更: {}:{} → {}:{}".format(
event.old_ip, event.old_port,
event.new_ip, event.new_port))
// 重新连接新主节点
master.close()
master = connectToRedis(event.new_ip, event.new_port)
)
return master
Sentinel 常用命令:
conf
# 获取主节点地址
SENTINEL GET-MASTER-ADDR-BY-NAME mymaster
# 获取主节点的所有从节点信息
SENTINEL SLAVES mymaster
# 获取所有Sentinel信息
SENTINEL SENTINELS mymaster
# 手动触发故障转移
SENTINEL FAILOVER mymaster
# 检查Sentinel配置是否一致
SENTINEL CKQUORUM mymaster
# 重置主节点状态
SENTINEL RESET mymaster
四、Cluster(集群)
4.1 为什么需要 Cluster
单机 Redis 的瓶颈:
- 写瓶颈:单主节点无法承受高并发写入。
- 内存上限:单机内存有限(通常建议不超过 20~30GB),超过后 RDB 持久化和恢复耗时长。
- 单点故障:即使有 Sentinel,同一时刻只有一个主节点处理写请求。
Sentinel 的局限:
Sentinel 解决了高可用问题(主节点故障自动切换),但不解决水平扩展问题。所有数据仍然在同一个主节点上,写吞吐和内存容量仍然是单机上限。
Cluster 的定位:
Redis Cluster 同时解决了高可用和水平扩展两个问题:
- 数据分片(Sharding):将数据分散到多个主节点,突破单机写瓶颈和内存上限。
- 自动故障转移:内置故障检测和转移机制,不需要单独部署 Sentinel。
4.2 Hash Slot(哈希槽)
Redis Cluster 将整个键空间划分为 16384 个 Hash Slot(哈希槽),每个主节点负责一部分 Slot。
Key 到 Slot 的映射算法:
slot = CRC16(key) % 16384
- CRC16 是一种循环冗余校验算法,输出 16 位值(0~65535)。
- 对 16384 取模,将结果映射到 0~16383 的 Slot 编号。
伪代码:Key 到 Slot 的映射过程
pseudocode
function keyToSlot(key):
// 处理Hash Tag:如果key中包含{},只取{}内的部分计算
hash_key = extractHashTag(key)
// CRC16算法计算哈希值
crc_value = CRC16(hash_key)
// 对16384取模得到Slot编号
slot = crc_value % 16384
return slot // 返回0~16383之间的整数
function extractHashTag(key):
// Hash Tag规则:取第一个{和其后第一个}之间的内容
start = key.indexOf('{')
if start == -1:
return key // 没有{},使用整个key
end = key.indexOf('}', start + 1)
if end == -1 or end == start + 1:
return key // {}不完整或为空,使用整个key
return key.substring(start + 1, end) // 只用{}内的部分
// 示例
// "user:1001" → CRC16("user:1001") % 16384 = 12578
// "{user}:1001" → CRC16("user") % 16384 = 5474
// "{user}:1002" → CRC16("user") % 16384 = 5474(同一个Slot)
Slot 到 Node 的映射:
每个节点维护一个 Slot 映射表,记录每个 Slot 属于哪个节点。集群中的所有节点共享同一份 Slot 映射信息,通过 Gossip 协议同步。
4.3 为什么是 16384 个槽
面试高频题:为什么不是 65536 个槽?
Redis 作者 antirez 在 GitHub Issue 中给出了详细解释,核心原因如下:
1. 节点间通信开销
Redis 节点间通过 Gossip 协议交换信息。每个节点需要将自身的 Slot 分布信息发送给其他节点。Slot 分布用位图(Bitmap)表示:
- 16384 个 Slot 的位图大小:16384 / 8 = 2048 字节(2KB)
- 65536 个 Slot 的位图大小:65536 / 8 = 8192 字节(8KB)
Gossip 消息中,位图会频繁在节点间传输。8KB 的位图在 1000 个节点的集群中,心跳消息的网络开销显著增大。
2. 实际规模足够
Redis Cluster 的设计目标是最多支持约 1000 个节点。16384 个 Slot 分配给 1000 个节点,每个节点平均负责约 16 个 Slot,已经足够均匀。即使只有 3 个节点,每个节点约 5461 个 Slot,也不会因为 Slot 太少导致数据倾斜。
3. Slot 数量与迁移粒度的权衡
Slot 越多,每个 Slot 包含的 Key 越少,迁移粒度越细,灵活性更高。但如前所述,更多 Slot 意味着更大的通信开销。16384 是在两者之间的合理折中。
4. 65536 的实际收益有限
即便将 Slot 数增加到 65536,对于 1000 个节点以内的集群,数据分布的均匀程度提升有限,但通信开销增加了 4 倍。
总结:
| 对比项 | 16384 槽 | 65536 槽 |
|---|---|---|
| 位图大小 | 2KB | 8KB |
| 支持 1000 节点 | 每节点约 16 槽 | 每节点约 65 槽 |
| 心跳消息开销 | 低 | 高 4 倍 |
| 迁移粒度 | 合理 | 更细但收益有限 |
4.4 集群拓扑
基本结构:
-
Redis Cluster 由多个节点(Node)组成,至少需要 6 个节点(3 主 3 从)才能保证高可用。
-
每个主节点负责一部分 Hash Slot。
-
每个主节点可以有一个或多个从节点,推荐至少 1 个从节点。
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Master-A │ │ Master-B │ │ Master-C │
│ Slot 0~5460│ │5461~10922 │ │10923~16383 │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│Replica-A│ │Replica-B│ │Replica-C│
└─────────┘ └─────────┘ └─────────┘
节点间通信:Gossip 协议
- 每个节点每秒向集群中随机选择的少量节点发送 Ping 消息。
- Ping 消息包含自身状态和部分其他节点的状态信息。
- 接收节点用这些信息更新自身的集群状态视图。
- Pong 消息作为 Ping 的响应,也携带状态信息。
- Meet 消息用于新节点加入集群时的握手。
Gossip 协议的优势:
- 去中心化:不需要中心协调节点。
- 最终一致性:集群状态在有限时间内收敛。
- 容错性:部分节点故障不影响协议运行。
Gossip 协议的劣势:
- 状态收敛有延迟:集群规模越大,延迟越明显。
- 消息冗余:每个节点可能收到重复的状态信息。
4.5 请求路由
当客户端向集群中的某个节点发送命令时,可能出现以下情况:
1. Key 所在 Slot 在当前节点: 直接执行命令并返回结果。
2. Key 所在 Slot 不在当前节点: 返回 MOVED 重定向错误,告知客户端正确的节点地址。
-MOVED 3999 127.0.0.1:6380
含义:Slot 3999 在 127.0.0.1:6380 节点上,客户端应该重新发送命令到该节点。
3. Slot 正在迁移中: 返回 ASK 重定向错误,告知客户端临时迁移到目标节点。
-ASK 3999 127.0.0.1:6380
含义:Slot 3999 正在迁移,请求 127.0.0.1:6380 节点处理。
MOVED vs ASK 的区别:
- MOVED:永久性重定向,客户端应更新本地的 Slot 映射缓存,后续请求直接发到新节点。
- ASK:临时性重定向,客户端仅本次请求发到新节点,不更新本地缓存。下次请求仍然先尝试原节点。
伪代码:MOVED vs ASK 的处理逻辑
pseudocode
function sendCommand(client, key, command):
slot = keyToSlot(key)
// 首先查询本地Slot映射缓存
target_node = client.slot_cache.get(slot)
if target_node == null:
// 随机选择一个节点发送
target_node = client.getRandomNode()
// 发送命令
response = target_node.send(command)
if response.isMovedError():
// MOVED:永久重定向,更新本地缓存
new_node = parseMovedResponse(response)
client.slot_cache.update(slot, new_node)
// 重新发送命令到新节点
return new_node.send(command)
else if response.isAskError():
// ASK:临时重定向,不更新本地缓存
new_node = parseAskResponse(response)
// 先发送ASKING命令(标识接下来是ASK重定向请求)
new_node.send("ASKING")
// 发送实际命令
return new_node.send(command)
else:
return response
ASKING 命令的作用:
目标节点在 Slot 迁移期间,对于不属于自己负责的 Slot,默认会返回 MOVED 错误。发送 ASKING 命令后,目标节点会临时接受该请求(仅一次),这就是 ASK 重定向的工作方式。
4.6 Slot 迁移
Slot 迁移是 Redis Cluster 在线扩容和缩容的核心操作。迁移期间集群仍可正常提供服务。
迁移过程:
- 向目标节点发送
CLUSTER SETSLOT <slot> IMPORTING <source_node_id>,标记目标节点准备接收该 Slot。 - 向源节点发送
CLUSTER SETSLOT <slot> MIGRATING <target_node_id>,标记源节点准备迁出该 Slot。 - 逐个将源节点的 Key 迁移到目标节点:使用
MIGRATE命令。 - 迁移完成后,向所有节点发送
CLUSTER SETSLOT <slot> NODE <target_node_id>,更新 Slot 映射。
迁移期间的请求处理:
- 客户端请求先到达源节点。
- 如果 Key 仍在源节点上,源节点正常处理。
- 如果 Key 已迁移到目标节点,源节点返回 ASK 重定向。
- 客户端发送 ASKING + 实际命令到目标节点,目标节点临时处理。
伪代码:Slot 迁移流程
pseudocode
function migrateSlot(source_node, target_node, slot):
// 步骤1:目标节点标记为IMPORTING
target_node.sendCommand(
"CLUSTER SETSLOT {} IMPORTING {}".format(
slot, source_node.node_id))
// 步骤2:源节点标记为MIGRATING
source_node.sendCommand(
"CLUSTER SETSLOT {} MIGRATING {}".format(
slot, target_node.node_id))
// 步骤3:逐个迁移Key
while true:
// 获取源节点上该Slot的一小批Key
keys = source_node.sendCommand(
"CLUSTER GETKEYSINSLOT {} {}".format(slot, 100))
if keys.is_empty():
break // 该Slot的所有Key已迁移完毕
// 将这批Key迁移到目标节点
// MIGRATE命令是原子的:GET+DEL+RESTORE
for key in keys:
source_node.sendCommand(
"MIGRATE {} {} {} 0 5000".format(
target_node.ip, target_node.port,
key))
// 步骤4:通知所有节点更新Slot映射
for node in cluster.all_nodes:
node.sendCommand(
"CLUSTER SETSLOT {} NODE {}".format(
slot, target_node.node_id))
log("Slot {} 迁移完成:{} → {}".format(
slot, source_node.node_id, target_node.node_id))
4.7 故障检测与转移
Redis Cluster 内置了类似 Sentinel 的故障检测和转移机制,不需要额外部署 Sentinel 进程。
故障检测:
- 主观下线(PFAIL,Possibly Fail) :节点 A 向节点 B 发送 Ping,如果在
cluster-node-timeout(默认 15 秒)内未收到 Pong 响应,A 将 B 标记为 PFAIL。 - 客观下线(FAIL):当超过半数的主节点将某个主节点标记为 PFAIL 时,发现该情况的节点将其标记为 FAIL,并广播给集群中所有节点。
故障转移:
- 从节点发现主节点 FAIL 后,开始竞选成为新主节点。
- 竞选条件:复制偏移量(Replication Offset)最大的从节点优先,因为它拥有最新的数据。
- 竞选过程:从节点向其他主节点请求投票,获得多数主节点同意后成为新主节点。
- 新主节点执行
SLAVEOF NO ONE,更新 Slot 映射。 - 其他从节点开始复制新主节点。
伪代码:Cluster 故障转移
pseudocode
function clusterFailover(failed_master, replica):
// 步骤1:从节点检测到主节点FAIL
if not replica.master.flags & FAIL:
return
// 步骤2:竞选准备
// 更新配置纪元(每次故障转移递增)
replica.current_config_epoch += 1
// 步骤3:向其他主节点请求投票
votes_received = 0
for master in cluster.masters:
if master == failed_master:
continue // 跳过故障主节点
vote = master.requestVote(
config_epoch=replica.current_config_epoch,
candidate=replica.node_id
)
if vote.granted:
votes_received += 1
// 步骤4:判断是否获得多数票
majority = ceil(cluster.master_count / 2)
if votes_received < majority:
log("未获得足够票数")
return
// 步骤5:成为新主节点
replica.sendCommand("SLAVEOF NO ONE")
replica.role = MASTER
// 步骤6:接管故障主节点的Slot
for slot in failed_master.slots:
replica.addSlot(slot)
// 广播Slot映射更新
cluster.broadcastSlotUpdate(slot, replica.node_id)
// 步骤7:其他从节点开始复制新主节点
for other_replica in failed_master.replicas:
if other_replica != replica:
other_replica.sendCommand(
"CLUSTER REPLICATE {}".format(replica.node_id))
log("故障转移完成:{} 成为新主节点".format(replica.node_id))
与 Sentinel 故障转移的区别:
| 对比项 | Sentinel | Cluster |
|---|---|---|
| 部署方式 | 独立进程 | 集成在 Redis 节点中 |
| 检测机制 | Sentinel 投票(quorum) | 主节点投票(多数派) |
| 选主依据 | 优先级 → 偏移量 → Run ID | 偏移量优先 |
| Slot 感知 | 无 | 有,更新 Slot 映射 |
4.8 集群限制
1. 不支持多 Key 操作
当多个 Key 不在同一个 Slot 时,MGET、MSET、SUNION 等多 Key 操作不可用。
解决方案:Hash Tag
Hash Tag 是一种机制,通过在 Key 中使用 {} 来指定用于计算 Slot 的部分。{} 内的内容相同,Key 就会映射到同一个 Slot。
伪代码:Hash Tag 的工作原理
pseudocode
function extractHashTag(key):
// 查找第一个 {
start = key.indexOf('{')
if start == -1:
return key // 没有{,使用完整key
// 查找{之后的第一个}
end = key.indexOf('}', start + 1)
if end == -1:
return key // 没有匹配的},使用完整key
if end == start + 1:
return key // {}为空,使用完整key
// 使用{}内的内容计算Slot
return key.substring(start + 1, end)
// 示例:
// "user:{1001}:name" → hash_tag="1001" → CRC16("1001") % 16384 = Slot X
// "user:{1001}:age" → hash_tag="1001" → CRC16("1001") % 16384 = Slot X
// 两个Key在同一个Slot,可以执行MGET等操作
// 注意:Hash Tag会影响数据分布
// 如果大量Key使用相同的Hash Tag,会导致Slot数据倾斜
2. 不支持 SELECT 命令
Redis Cluster 只使用 0 号数据库(db0),不支持多数据库。如果客户端发送 SELECT 命令,会返回错误。
3. 其他限制
- Lua 脚本中访问的 Key 必须在同一个 Slot(除非使用 Hash Tag)。
- 事务中涉及的 Key 必须在同一个 Slot(除非使用 Hash Tag)。
- 集群不支持
KEYS *等全局命令(只能在每个节点上单独执行)。
五、分布式锁(Distributed Lock)
5.1 基本实现
在分布式系统中,多个进程可能同时访问共享资源,需要通过分布式锁来保证互斥访问。
Redis 实现分布式锁的基本命令:
conf
SET lock_key unique_value NX PX 30000
参数解析:
- lock_key:锁的名称,即 Redis 中的 Key。
- unique_value:锁的唯一标识,通常使用 UUID 或线程 ID + 时间戳。必须全局唯一,防止误删其他客户端的锁。
- NX:Only set the key if it does not already exist,即 Key 不存在时才设置。这保证了互斥性:只有第一个请求能成功获取锁。
- PX 30000:设置 Key 的过期时间为 30 秒(30000 毫秒)。这防止了死锁:即使持有锁的客户端崩溃未能主动释放锁,锁也会在超时后自动过期。
伪代码:获取锁
pseudocode
function acquireLock(lock_name, acquire_timeout, lock_timeout):
unique_value = generateUUID()
lock_key = "lock:" + lock_name
end_time = now() + acquire_timeout
while now() < end_time:
// 尝试设置锁(NX + PX原子操作)
result = redis.set(lock_key, unique_value,
"NX", "PX", lock_timeout)
if result == "OK":
log("获取锁成功: {}".format(lock_key))
return unique_value // 返回锁的唯一标识
// 短暂等待后重试
sleep(10_ms)
log("获取锁超时: {}".format(lock_key))
return null // 获取失败
5.2 释放锁的原子性
为什么不能直接 DEL:
考虑以下场景:
- 客户端 A 获取锁(value=A),过期时间 30 秒。
- 客户端 A 业务执行缓慢,锁过期自动释放。
- 客户端 B 获取锁(value=B)。
- 客户端 A 执行完毕,发送
DEL lock_key,误删了客户端 B 的锁。 - 客户端 C 也能获取锁,导致 A、B、C 三个客户端同时持有"锁",互斥性被破坏。
正确做法:使用 Lua 脚本
先检查 value 是否匹配,匹配则删除。Lua 脚本在 Redis 中以原子方式执行,不会被其他命令打断,保证了检查和删除的原子性。
伪代码:释放锁的 Lua 脚本
lua
-- 释放锁的Lua脚本
-- KEYS[1] = 锁的Key
-- ARGV[1] = 客户端的唯一标识
if redis.call("GET", KEYS[1]) == ARGV[1] then
-- value匹配,说明是自己的锁,可以删除
return redis.call("DEL", KEYS[1])
else
-- value不匹配,说明锁已被其他人持有,不能删除
return 0
end
伪代码:释放锁的完整流程
pseudocode
function releaseLock(lock_name, unique_value):
lock_key = "lock:" + lock_name
// 使用Lua脚本原子地检查并删除
lua_script = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
"""
result = redis.eval(lua_script, 1, lock_key, unique_value)
if result == 1:
log("释放锁成功: {}".format(lock_key))
return true
else:
log("释放锁失败(锁不属于当前客户端): {}".format(lock_key))
return false
5.3 分布式锁的边界问题
问题 1:锁过期但业务未完成
如果客户端的业务执行时间超过了锁的过期时间,锁会自动释放,其他客户端可以获取锁,导致互斥性被破坏。
解决方案:看门狗(Watchdog)自动续期
Redisson(Redis 的 Java 客户端)实现了看门狗机制:
- 锁的默认过期时间为 30 秒(
lockWatchdogTimeout)。 - 每隔 10 秒(过期时间的 1/3),看门狗检查锁是否仍被持有,如果是,则将过期时间重置为 30 秒。
- 如果持有锁的客户端宕机,看门狗停止续期,锁在 30 秒后自动过期。
伪代码:看门狗自动续期
pseudocode
function startWatchdog(lock_key, unique_value, lock_timeout):
renewal_interval = lock_timeout / 3 // 续期间隔
while true:
sleep(renewal_interval)
// 检查锁是否仍属于当前客户端
lua_script = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end
"""
result = redis.eval(lua_script, 1,
lock_key, unique_value, lock_timeout)
if result == 0:
// 锁不再属于当前客户端,停止续期
log("锁已不属于当前客户端,停止续期")
break
else:
log("锁续期成功")
问题 2:Redis 主从切换导致锁丢失
考虑以下场景(主从异步复制导致的锁丢失问题):
- 客户端 A 在主节点获取锁成功。
- 主节点将锁信息异步复制到从节点(尚未完成)。
- 主节点宕机,从节点被提升为新主节点。
- 新主节点没有客户端 A 的锁信息,客户端 B 可以获取锁。
- 客户端 A 和 B 同时持有"锁"。
解决方案:Redlock 算法
Redlock 由 Redis 作者 antirez 提出,使用多个独立的 Redis 实例(非主从关系)来保证锁的安全性。
伪代码:Redlock 算法的核心逻辑
pseudocode
function redlockAcquire(lock_name, unique_value, lock_timeout, retry_count):
// N个独立的Redis实例(通常为5个)
instances = [redis1, redis2, redis3, redis4, redis5]
quorum = len(instances) / 2 + 1 // 多数派,5个实例需要3个同意
for attempt in range(retry_count):
acquired_count = 0
start_time = now()
// 向所有实例依次请求锁
for instance in instances:
try:
result = instance.set(
"lock:" + lock_name, unique_value,
"NX", "PX", lock_timeout)
if result == "OK":
acquired_count += 1
except:
continue // 实例不可用,跳过
// 计算获取锁的实际有效时间
elapsed = now() - start_time
validity_time = lock_timeout - elapsed
// 判断是否获得多数派,且锁仍有效
if acquired_count >= quorum and validity_time > 0:
log("Redlock获取锁成功,有效时间: {}ms".format(validity_time))
return {
success: true,
validity_time: validity_time
}
// 获取失败,向所有实例释放锁
for instance in instances:
try:
releaseLockOnInstance(instance, lock_name, unique_value)
except:
continue
// 随机延迟后重试(避免多个客户端同时重试导致活锁)
sleep(random(1_ms, 200_ms))
log("Redlock获取锁失败")
return {success: false}
function redlockRelease(lock_name, unique_value):
// 向所有实例释放锁
for instance in instances:
try:
releaseLockOnInstance(instance, lock_name, unique_value)
except:
continue
Redlock 的核心思想:
- 使用 N 个独立 Redis 实例(推荐 5 个),不是主从关系。
- 获取锁时需要多数派(≥ N/2+1)实例同意。
- 考虑了获取锁消耗的时间,计算锁的实际有效时间。
- 释放锁时向所有实例发送释放请求。
5.4 Redlock 争议
Martin Kleppmann 的批评(2016 年):
分布式系统专家 Martin Kleppmann 发表文章《How to do distributed locking》,指出 Redlock 存在以下问题:
-
时钟漂移(Clock Drift):Redlock 依赖各实例的系统时钟来计算锁的有效时间。如果某个实例的时钟发生跳变(如 NTP 同步),可能导致锁提前过期或延迟过期,影响正确性。
-
进程暂停(Process Pause):持有锁的进程可能因为 GC(Garbage Collection)暂停而长时间不响应。暂停结束后,进程可能不知道锁已过期,继续操作共享资源。
-
网络分区:在复杂的网络分区场景下,Redlock 可能无法保证互斥性。
Martin 的建议:对于需要正确性保证的场景,应使用基于一致性协议(如 Raft)的分布式协调服务(如 ZooKeeper、etcd),而非 Redlock。
Antirez 的回应:
Redis 作者 antirez 发表了回应文章《Is Redlock safe?》:
- 时钟问题:Redlock 不依赖绝对时间,只依赖相对时间(过期计时)。合理的时钟同步(如 NTP)足以保证正确性。
- 进程暂停:这个问题对所有分布式锁方案都存在,不是 Redlock 特有的问题。使用 fencing token(递增的令牌)可以缓解此问题。
- 网络分区:Redlock 的多数派机制已经考虑了分区容错。
结论与生产实践建议:
- 大多数场景:单实例 Redis 锁 + 看门狗续期已经足够。大部分业务场景可以容忍极端情况下的偶尔锁失效。
- 极高安全要求:如金融交易、库存核心扣减等,应使用基于 Raft 的协调服务(etcd、ZooKeeper、Consul),它们提供更强的一致性保证。
- Redlock 适用于:需要比单实例锁更高的安全性,但又不想引入 ZooKeeper/etcd 等额外组件的场景。
六、事务(Transaction)
6.1 Redis 事务的基本操作
Redis 事务通过 MULTI、EXEC、DISCARD、WATCH 四个命令实现:
- MULTI:标记事务开始,之后的命令进入队列而非立即执行。
- EXEC:执行事务中的所有命令。
- DISCARD:放弃事务,清空命令队列。
- WATCH:乐观锁,监视一个或多个 Key,如果在 EXEC 执行前这些 Key 被其他客户端修改,则事务自动放弃。
伪代码:事务执行流程
pseudocode
function redisTransaction(watch_keys, commands):
// 步骤1:WATCH监视Key(可选)
if watch_keys:
for key in watch_keys:
redis.watch(key)
// 记录Key的当前版本
watched_versions[key] = redis.get_version(key)
// 步骤2:MULTI开启事务
redis.multi()
// 步骤3:命令入队
for cmd in commands:
result = redis.queue_command(cmd)
// 注意:入队时返回QUEUED,不执行命令
assert result == "QUEUED"
// 步骤4:EXEC执行事务
results = redis.exec()
if results == null:
// WATCH的Key被修改,事务被放弃
log("事务放弃:WATCH的Key已被修改")
return TRANSACTION_ABORTED
else:
// 事务执行成功,返回每条命令的结果
return results
// 示例:银行转账
// 从账户A转100元到账户B
function transfer(account_a, account_b, amount):
return redisTransaction(
watch_keys=[account_a, account_b],
commands=[
"DECRBY {} {}".format(account_a, amount),
"INCRBY {} {}".format(account_b, amount)
]
)
6.2 Redis 事务不是 ACID
Redis 的事务与传统关系型数据库的 ACID 事务有显著差异:
原子性(Atomicity)------ 有限支持
- Redis 事务保证命令要么全部执行(EXEC 后),要么都不执行(EXEC 前 DISCARD 或 WATCH 失败)。
- 但是,如果事务中的某条命令执行失败(如类型错误),其他命令仍会继续执行,不会回滚。Redis 不支持回滚(Rollback)。
conf
MULTI
SET key1 "value1"
INCR key1 // key1是字符串,INCR会失败
SET key2 "value2" // 仍会执行
EXEC
// 结果:key1="value1"(SET成功),INCR报错,key2="value2"(SET成功)
为什么 Redis 不支持回滚:
Redis 作者认为:Redis 命令失败只可能由编程错误引起(如对字符串执行 INCR),这类错误应该在开发阶段被发现和修复。不支持回滚可以保持 Redis 的简单和高效。
一致性(Consistency)------ 基本保证
- Redis 事务不会破坏数据的一致性约束(如唯一性、类型约束等)。
- 但 Redis 本身没有严格的一致性约束(无外键、无约束检查),这个特性意义不大。
隔离性(Isolation)------ 部分保证
- EXEC 前的命令对其他客户端不可见(命令在队列中)。
- EXEC 后的命令一次性执行,不会被其他客户端的命令穿插。
- 但没有隔离级别的概念,无法防止脏读、不可重复读等问题。
持久性(Durability)------ 不保证
- 事务的持久性取决于 Redis 的持久化配置(RDB/AOF)。
- 如果未开启持久化或使用 RDB 且未到保存时间点,事务执行后的数据可能在宕机后丢失。
6.3 WATCH 机制
WATCH 实现了乐观锁(Optimistic Locking),基于 CAS(Compare And Set)的思想。
工作原理:
- WATCH 命令监视一个或多个 Key,记录 Key 的当前修改版本。
- 客户端执行 MULTI 并入队命令。
- 当客户端执行 EXEC 时,Redis 检查所有被 WATCH 的 Key 是否被修改过:
- 如果没有被修改:正常执行事务中的所有命令。
- 如果被修改:放弃事务,返回 nil。
伪代码:WATCH 的实现逻辑
pseudocode
// Redis服务端WATCH的实现
function watchKey(client, key):
client.watched_keys.add(key)
// 记录Key的当前修改版本号
client.watched_versions[key] = redis.getModificationVersion(key)
// 判断WATCH的Key是否被修改
function isWatchedKeyModified(client):
for key in client.watched_keys:
current_version = redis.getModificationVersion(key)
if current_version != client.watched_versions[key]:
return true // Key已被修改
return false
// EXEC时检查WATCH
function execTransaction(client):
// 检查WATCH的Key是否被修改
if isWatchedKeyModified(client):
// 放弃事务
client.discardTransaction()
client.unwatchAll()
return null
// 执行事务中的所有命令
results = []
for cmd in client.queued_commands:
result = executeCommand(cmd)
results.append(result)
// 执行完毕,清除WATCH
client.unwatchAll()
return results
// 客户端使用WATCH实现乐观锁
function optimisticLockExample(key, new_value):
max_retries = 100
for i in range(max_retries):
// 读取当前值
current_value = redis.get(key)
// WATCH监视Key
redis.watch(key)
// 开启事务
redis.multi()
redis.set(key, new_value)
// 尝试执行
result = redis.exec()
if result != null:
return true // 成功
// 否则重试
return false // 重试次数耗尽
6.4 事务 vs Lua 脚本
| 对比项 | 事务(Transaction) | Lua 脚本 |
|---|---|---|
| 原子性 | 有限(不支持回滚) | 天然原子(整体执行) |
| 复杂逻辑 | 不支持(只有命令队列) | 支持(条件判断、循环等) |
| WATCH | 需要手动 WATCH | 不需要(脚本执行期间不插入其他命令) |
| 错误处理 | 某条失败不影响其他 | 可在脚本中处理错误 |
| 复用性 | 每次需重新发送 | EVALSHA 可复用 |
| 性能 | 一般 | 更好(减少网络往返) |
Lua 脚本更推荐的原因:
- 天然原子:Lua 脚本在 Redis 中以原子方式执行,期间不会执行其他客户端的命令,无需 WATCH。
- 更灵活:可以在脚本中编写条件判断、循环等复杂逻辑。
- 更高效:减少客户端与服务器之间的网络往返次数。
事务适用场景:
- 简单的批量操作,不涉及条件判断。
- 需要监视 Key 变化的简单乐观锁场景。
七、Lua 脚本
7.1 基本用法
Redis 内置了 Lua 解释器,可以在服务器端执行 Lua 脚本。脚本以原子方式执行,期间不会执行其他客户端的命令。
相关命令:
- EVAL:执行 Lua 脚本。
- EVALSHA:根据脚本的 SHA1 校验和执行已缓存的脚本。
- SCRIPT LOAD:将脚本加载到缓存中,返回 SHA1。
- SCRIPT EXISTS:检查脚本是否在缓存中。
- SCRIPT FLUSH:清空脚本缓存。
EVAL 命令格式:
conf
EVAL script numkeys key [key ...] arg [arg ...]
script:Lua 脚本代码。numkeys:Key 的数量。key:Key 参数(在脚本中通过 KEYS1、KEYS2 访问)。arg:附加参数(在脚本中通过 ARGV1、ARGV2 访问)。
伪代码:EVAL 执行流程
pseudocode
function evalScript(script, keys, args):
// 步骤1:计算脚本的SHA1
sha1 = SHA1(script)
// 步骤2:检查脚本是否已缓存
if not scriptCache.exists(sha1):
// 编译并缓存脚本
compiled_script = lua.compile(script)
scriptCache.set(sha1, compiled_script)
// 步骤3:在Lua环境中设置参数
lua_env.setGlobal("KEYS", keys)
lua_env.setGlobal("ARGV", args)
lua_env.setGlobal("redis", redis_api) // 提供redis.call等API
// 步骤4:原子执行脚本(期间不执行其他客户端命令)
redis.blockOtherClients()
try:
result = lua_env.execute(scriptCache.get(sha1))
finally:
redis.unblockOtherClients()
// 步骤5:将Lua返回值转换为Redis回复
return convertLuaToRedisReply(result)
Lua 脚本中可用的 Redis API:
lua
-- 调用Redis命令
redis.call('SET', KEYS[1], ARGV[1])
redis.call('GET', KEYS[1])
-- 安全调用(命令出错返回错误对象,不抛异常)
local result = redis.pcall('GET', KEYS[1])
-- 返回日志
redis.log(redis.LOG_WARNING, "something is wrong")
-- 获取当前数据库中匹配的Key
-- 注意:生产环境中慎用KEYS命令,会阻塞服务器
7.2 秒杀场景示例
秒杀(Flash Sale)场景的核心需求是:库存扣减和订单创建必须原子执行,不能超卖。
问题分析:
- 普通命令无法保证原子性:先检查库存再扣减,两步之间可能被其他请求插入。
- 使用事务 + WATCH 可以实现,但并发冲突多时重试开销大。
- Lua 脚本是最佳选择:原子执行,无需 WATCH。
伪代码:秒杀 Lua 脚本
lua
-- 秒杀Lua脚本
-- KEYS[1] = 库存Key(如:seckill:stock:1001)
-- KEYS[2] = 已购用户集合Key(如:seckill:bought:1001)
-- ARGV[1] = 用户ID
-- ARGV[2] = 购买数量
-- 步骤1:检查用户是否已购买(防止重复购买)
if redis.call("SISMEMBER", KEYS[2], ARGV[1]) == 1 then
return -1 -- 用户已购买
end
-- 步骤2:检查库存是否充足
local stock = tonumber(redis.call("GET", KEYS[1]))
if stock == nil then
return -2 -- 商品不存在
end
if stock < tonumber(ARGV[2]) then
return 0 -- 库存不足
end
-- 步骤3:扣减库存
redis.call("DECRBY", KEYS[1], ARGV[2])
-- 步骤4:记录已购用户
redis.call("SADD", KEYS[2], ARGV[1])
return 1 -- 秒杀成功
伪代码:客户端调用秒杀脚本
pseudocode
function seckill(product_id, user_id, quantity):
stock_key = "seckill:stock:" + product_id
bought_key = "seckill:bought:" + product_id
result = redis.eval(seckill_script, 2,
stock_key, bought_key,
user_id, str(quantity))
switch result:
case 1: return "秒杀成功"
case 0: return "库存不足"
case -1: return "请勿重复购买"
case -2: return "商品不存在"
7.3 Lua 脚本注意事项
1. 不能执行耗时操作
Lua 脚本在 Redis 中以原子方式执行,意味着脚本执行期间,其他所有客户端的命令都会被阻塞。因此:
- 禁止在脚本中执行耗时操作(如大循环、复杂计算)。
- 禁止使用
KEYS *等扫描全库的命令。 - 脚本执行时间默认限制为 5 秒(
lua-time-limit配置),超时后 Redis 只能通过 SCRIPT KILL 或shutdown 来中断。
conf
# 设置Lua脚本执行超时时间(毫秒),0表示无限制
lua-time-limit 5000
2. 脚本缓存与 EVALSHA
Redis 会缓存已执行过的 Lua 脚本(基于 SHA1 校验和)。EVALSHA 命令允许通过 SHA1 直接执行已缓存的脚本,避免每次都传输完整的脚本代码,节省网络带宽。
伪代码:EVALSHA 优化流程
pseudocode
// 客户端使用EVALSHA的优化流程
function executeLuaScript(redis, script, keys, args):
// 步骤1:计算脚本的SHA1
sha1 = SHA1(script)
// 步骤2:尝试使用EVALSHA执行
try:
result = redis.evalsha(sha1, len(keys), keys, args)
return result
except NOSCRIPT:
// 步骤3:脚本不在缓存中,使用EVAL执行
// EVAL会自动将脚本加入缓存
result = redis.eval(script, len(keys), keys, args)
return result
// 更优化的方式:启动时预加载所有脚本
function preloadScripts(redis, scripts):
sha_map = {}
for name, script in scripts:
sha1 = redis.script_load(script) // 加载到缓存
sha_map[name] = sha1
return sha_map
// 执行时直接用SHA1
function executePreloaded(redis, sha_map, name, keys, args):
return redis.evalsha(sha_map[name], len(keys), keys, args)
3. 确定性要求
Redis Cluster 要求 Lua 脚本中的所有操作必须针对同一个 Slot 的 Key,除非脚本被标记为允许非确定性(通过 redis.replicate_commands() 在 Redis 7.0 之前)。从 Redis 7.0 开始,所有脚本默认在 effect replication 模式下运行,不再需要显式调用 redis.replicate_commands()。
八、发布订阅(Pub/Sub)
8.1 基本用法
Redis 的发布订阅(Publish/Subscribe)是一种消息通信模式,发送者(Publisher)发送消息,接收者(Subscriber)接收消息。
核心命令:
- PUBLISH channel message:向指定频道发送消息。
- SUBSCRIBE channel channel ...:订阅一个或多个频道。
- PSUBSCRIBE pattern pattern ...:订阅匹配模式的频道(支持通配符)。
- UNSUBSCRIBE channel \[channel ...]:取消订阅。
- PUNSUBSCRIBE pattern \[pattern ...]:取消模式订阅。
示例:
conf
// 终端1:订阅者
SUBSCRIBE news.sports // 精确订阅
PSUBSCRIBE news.* // 模式订阅
// 终端2:发布者
PUBLISH news.sports "Redis wins the race" // 精确发布
PUBLISH news.tech "Redis 8.0 released" // 模式匹配也会收到
模式匹配规则:
*匹配任意数量字符。?匹配单个字符。[...]匹配字符集合。
8.2 特点与局限
特点:
- 实时推送:消息在发布时立即推送给所有订阅者,延迟极低。
- 多对多:一个消息可以有多个订阅者,一个订阅者可以订阅多个频道。
- 轻量级:无需额外中间件,直接使用 Redis 即可实现。
局限:
-
不持久化消息:发布订阅是"即发即忘"(Fire and Forget)模式。消息发出后,如果没有订阅者在线,消息直接丢弃,不会保存。
-
消费者离线则消息丢失:如果订阅者断开连接,断开期间的消息无法补收。
-
不支持消费确认:没有 ACK 机制,无法保证消息被成功处理。
-
不支持消息堆积:消息不会在 Redis 中堆积等待消费,如果消费速度慢于生产速度,消息直接丢失。
-
可能阻塞 Redis:如果订阅者处理缓慢,Redis 的输出缓冲区可能积压大量消息,甚至导致内存问题。
与 Stream 的对比:
| 特性 | Pub/Sub | Stream |
|---|---|---|
| 消息持久化 | 不持久化 | 持久化 |
| 消费者离线 | 消息丢失 | 可补收 |
| 消费确认 | 不支持 | 支持(ACK) |
| 消费者组 | 不支持 | 支持(Consumer Group) |
| 消息堆积 | 不支持 | 支持 |
| 适用场景 | 实时通知、事件广播 | 可靠消息队列 |
8.3 应用场景
1. 实时通知
如实时聊天、在线状态变更通知等。这些场景对消息丢失不太敏感,更看重实时性。
2. Sentinel 节点间通信
Sentinel 利用 Redis 主节点的 __sentinel__:hello 频道进行节点间信息交换,这是 Pub/Sub 在 Redis 内部的典型应用。
3. 配置变更推送
当配置变更时,发布配置变更消息,所有订阅的客户端收到通知后重新加载配置。
4. 跨进程事件通知
如一个进程完成数据预热后,通知其他进程可以开始读取数据。
注意: 对于需要可靠消息传递的场景,应优先考虑 Redis Stream 或专业的消息队列(如 Kafka、RabbitMQ)。