
引言:从"能用"到"高可用"
在上一篇文章中,我们基于 FlinkJedisPoolConfig 构建了一个可运行的 Flink Redis Sink。但生产环境从来不是"能跑就行"------当 Redis 单节点宕机时,整个 Flink 任务会直接崩溃,因为连接池配置的 localhost:6379 已经不可用了。
那么问题来了:如果部署了 Redis 主从+Sentinel 高可用架构,Flink 任务能在主从切换时自动恢复吗?
答案是:能,但有前提------你必须使用 FlinkJedisSentinelConfig 替代 FlinkJedisPoolConfig,并且正确配置重试机制。 否则,任务依然会崩溃。
本文将深入剖析:
- Redis Sentinel 的故障转移机制及其对 Flink 任务的影响
- Flink Redis Connector 在 Sentinel 模式下的连接管理原理
- 一份可直接运行的 Sentinel 高可用配置代码
- 主从切换期间的"数据黑洞"风险与应对策略
- 生产环境必做的 5 项高可用加固措施
一、前置知识:Redis Sentinel 如何实现高可用?
1.1 主从复制 + 哨兵 = 自动故障转移
Redis Sentinel(哨兵)是 Redis 官方提供的高可用解决方案。它的核心工作流程如下:
| 阶段 | 哨兵行为 | 耗时 |
|---|---|---|
| 监控 | 每 1 秒向主从节点发送 PING,检测存活状态 |
持续 |
| 主观下线(SDOWN) | 单个哨兵发现主节点无响应,标记为 SDOWN | 约 3 秒 |
| 客观下线(ODOWN) | 多数哨兵(quorum)确认主节点不可用,触发故障转移 | 取决于 quorum 配置 |
| Leader 选举 | 哨兵集群通过 Raft 协议选举一个 Leader 执行故障转移 | 数秒 |
| 主从切换 | Leader 将一个从节点提升为新主节点,其他从节点切换复制目标 | 数秒 |
| 客户端感知 | 哨兵将新主节点信息通知客户端(通过 SENTINEL GET-MASTER-ADDR-BY-NAME) |
即时 |
关键数据 :在 3 节点 Sentinel 集群中,需至少 2 个节点确认故障才会触发 ODOWN,避免网络分区导致的误切换。整个故障转移过程通常在 10~30 秒 内完成。
1.2 Flink 任务在故障转移期间会发生什么?
假设你的 Flink 任务正在向 Redis 主节点(master-1:6379)写入数据,此时主节点宕机:
阶段一:故障发生 → 哨兵检测(0~5 秒)
- Flink 任务尝试写入 Redis,但连接已断开。
- Jedis 客户端抛出异常(如
JedisConnectionException或SocketTimeoutException)。 - 此时 Flink 任务会报错,但尚未崩溃------错误会被 Flink 的容错机制捕获。
阶段二:哨兵选举 + 主从切换(5~15 秒)
- 哨兵集群正在选举新主节点,Redis 服务暂时不可用。
- Flink 任务持续重试写入,不断报错。
- 如果重试策略配置不当,任务会在多次失败后崩溃。
阶段三:新主节点上线 + 客户端感知(15~30 秒)
- 新主节点(原
slave-1)已提升为master-2。 - Sentinel 客户端(JedisSentinelPool)通过哨兵获取新主节点地址。
- 如果使用了
FlinkJedisSentinelConfig,连接池会自动发现新主节点并重建连接。 - 任务恢复写入,数据继续流向新主节点。
结论 :FlinkJedisPoolConfig(直连单节点)→ 任务崩溃 ;FlinkJedisSentinelConfig(通过哨兵获取主节点)→ 任务短暂报错后自动恢复。
二、核心剖析:FlinkJedisSentinelConfig 的工作原理
2.1 三种配置类的本质区别
Flink Redis Connector 提供了三种配置类,对应三种 Redis 部署模式:
| 配置类 | 适用场景 | 主从切换时行为 |
|---|---|---|
FlinkJedisPoolConfig |
单机 Redis | ❌ 连接失效,任务崩溃 |
FlinkJedisClusterConfig |
Redis Cluster 集群模式 | ⚠️ 客户端可感知拓扑变化,但对主从切换支持有限 |
FlinkJedisSentinelConfig |
Redis Sentinel 高可用模式 | ✅ 自动发现新主节点,连接自动恢复 |
2.2 Sentinel 模式下的连接获取流程
当 RedisSink 使用 FlinkJedisSentinelConfig 时,内部连接管理流程如下:
1. RedisSink.open() 被调用
↓
2. RedisCommandsContainerBuilder.build(jedisSentinelConfig)
↓
3. 创建 JedisSentinelPool(内部持有哨兵地址列表)
↓
4. JedisSentinelPool 调用 SENTINEL GET-MASTER-ADDR-BY-NAME master
↓
5. 获取当前主节点 IP + 端口
↓
6. 建立到主节点的连接池
↓
7. 每条数据写入时:从连接池借用 Jedis 连接 → 执行 HSET → 归还
关键差异 :JedisSentinelPool 不会在初始化时"固定"一个主节点地址。每次获取连接时,它都会先向哨兵查询当前主节点,再建立连接。这意味着即使主节点发生切换,下一次获取连接时就能拿到新主节点地址。
2.3 一个容易被忽略的坑:连接池缓存
JedisSentinelPool 内部有一个 master 字段缓存了主节点地址。如果故障转移发生在两次连接获取之间,缓存的地址可能已经失效。
Jedis 的处理方式是:当使用缓存地址连接失败时,会重新向哨兵查询 并更新缓存。但这个过程需要时间,且可能抛出异常 。因此,Flink 任务在切换瞬间仍可能出现短暂报错------这是正常现象,只要配置了重试机制,任务就能自愈。
三、手把手实操:从 PoolConfig 升级到 SentinelConfig
3.1 环境准备:搭建 Redis Sentinel 集群
最低配置:1 个主节点 + 1 个从节点 + 3 个哨兵(生产环境哨兵至少 3 个)
bash
# 以 Docker Compose 为例(快速验证用)
version: '3'
services:
redis-master:
image: redis:7
command: redis-server --port 6379 --appendonly yes
ports:
- "6379:6379"
redis-slave:
image: redis:7
command: redis-server --port 6380 --slaveof redis-master 6379 --appendonly yes
ports:
- "6380:6380"
sentinel-1:
image: redis:7
command: redis-sentinel /usr/local/etc/redis/sentinel.conf
volumes:
- ./sentinel.conf:/usr/local/etc/redis/sentinel.conf
ports:
- "26379:26379"
# sentinel-2, sentinel-3 同理...
sentinel.conf 核心配置:
port 26379
sentinel monitor mymaster redis-master 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
3.2 升级后的 Scala 代码(完整可运行)
scala
package sink
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.connectors.redis.RedisSink
import org.apache.flink.streaming.connectors.redis.common.config.FlinkJedisSentinelConfig
import org.apache.flink.streaming.connectors.redis.common.mapper.{RedisCommand, RedisCommandDescription, RedisMapper}
import source.{ClickSource, Event}
import java.util.{HashSet => JHashSet}
import scala.collection.JavaConverters._
object sinkToRedisSentinel {
def main(args: Array[String]): Unit = {
val env = StreamExecutionEnvironment.getExecutionEnvironment
env.enableCheckpointing(10000)
val dataStream: DataStream[Event] = env.addSource(new ClickSource)
dataStream.print("Input from Source")
// 1. 配置哨兵地址(至少 3 个)
val sentinels = new JHashSet[String]()
sentinels.add("sentinel-1:26379")
sentinels.add("sentinel-2:26379")
sentinels.add("sentinel-3:26379")
// 2. 构建 Sentinel 配置(核心!)
val conf: FlinkJedisSentinelConfig = new FlinkJedisSentinelConfig.Builder()
.setMasterName("mymaster") // 必须与 sentinel.conf 中的名称一致
.setSentinels(sentinels) // 哨兵地址列表
.setConnectionTimeout(5000) // 连接超时 5 秒
.setSoTimeout(5000) // Socket 超时 5 秒
.setMaxTotal(20) // 最大连接数
.setMaxIdle(10) // 最大空闲连接
.setMinIdle(5) // 最小空闲连接
.setTestOnBorrow(true) // 借用时检查连接可用性
.setTestWhileIdle(true) // 空闲时检查连接可用性
.build()
// 3. 添加 Redis Sink(Mapper 逻辑与之前完全一致)
val redisSink = new RedisSink[Event](conf, new RedisMapper[Event] {
override def getCommandDescription: RedisCommandDescription =
new RedisCommandDescription(RedisCommand.HSET, "click")
override def getKeyFromData(t: Event): String = t.user
override def getValueFromData(t: Event): String = t.url
})
dataStream.addSink(redisSink)
.name("Redis Sentinel Sink")
.setParallelism(1)
env.execute("Flink Redis Sentinel Job")
}
}
代码变化对比:
| 原版(PoolConfig) | 新版(SentinelConfig) |
|---|---|
.setHost("localhost") |
.setSentinels(sentinels) + .setMasterName("mymaster") |
| 直连单节点 | 通过哨兵动态发现主节点 |
| 主从切换 → 任务崩溃 | 主从切换 → 自动重连 |
3.3 验证 Sentinel 高可用效果
Step 1:启动 Flink 任务,确认数据正常写入 Redis。
bash
redis-cli -h localhost -p 6379 HGETALL click
# 正常返回数据
Step 2:模拟主节点宕机。
bash
docker stop redis-master
Step 3:观察 Flink 任务日志。
# 预期会看到类似以下日志(非精确,取决于 Jedis 版本):
WARN JedisSentinelPool - master mymaster is down, trying to discover new master
WARN JedisSentinelPool - new master found: redis-slave:6380
INFO RedisSink - reconnected to Redis successfully
Step 4:验证数据仍在写入(新主节点)。
bash
redis-cli -h localhost -p 6380 HGETALL click
# 数据持续增长,说明故障转移成功
四、进阶思考:主从切换期间的"数据黑洞"
4.1 异步复制导致的数据丢失风险
Redis 主从复制是异步的。这意味着:
- 主节点收到写入请求 → 返回
OK给客户端 → 然后才异步同步到从节点。 - 如果在同步完成之前主节点宕机,这部分数据永远丢失了。
这就是所谓的 "数据黑洞" ------在故障转移期间,部分已确认写入的数据可能永久消失。
对 Flink 任务的影响:
- Flink 的 Checkpoint 机制认为数据已成功写入(因为
RedisSink返回了成功)。 - 但实际上数据并未复制到从节点,主节点宕机后数据丢失。
- Flink 的 Exactly-Once 语义无法覆盖这种场景,因为数据丢失发生在 Redis 内部,Flink 无法感知。
4.2 应对策略
| 策略 | 实现方式 | 优缺点 |
|---|---|---|
| 开启 AOF 持久化 | appendonly yes + appendfsync always |
数据更安全,但性能下降明显 |
| 使用 WAIT 命令 | 主节点写入后等待从节点确认 | 牺牲可用性换取一致性 |
| 业务层容忍丢失 | 接受故障转移期间少量数据丢失 | 大多数实时场景可接受 |
| 双写 + 去重 | 同时写入两个 Redis 集群,消费端去重 | 成本翻倍,复杂度高 |
生产建议 :对于实时点击流这类非关键数据 ,可以容忍少量丢失;对于交易数据,则不应依赖 Redis 作为唯一存储,而应使用支持事务的数据库。
五、生产环境必做的 5 项高可用加固
5.1 配置 Flink 重启策略
scala
// 在 env 创建后立即配置
env.setRestartStrategy(
RestartStrategies.fixedDelayRestart(
10, // 最多重试 10 次
Time.seconds(30) // 每次重试间隔 30 秒
)
)
为什么重要:故障转移期间 Redis 可能不可用 10~30 秒,如果没有重启策略,任务会在第一次报错时就失败。
5.2 设置合理的超时时间
scala
.setConnectionTimeout(10000) // 连接超时 10 秒(故障转移期间可能较长)
.setSoTimeout(10000) // 读取超时 10 秒
为什么重要:故障转移期间,连接建立可能比平时慢,过短的超时会导致误判。
5.3 开启连接池健康检查
scala
.setTestOnBorrow(true)
.setTestWhileIdle(true)
.setTimeBetweenEvictionRunsMillis(30000) // 每 30 秒检查一次空闲连接
为什么重要 :主从切换后,旧主节点的连接已失效。开启 TestOnBorrow 可以在借用连接时执行 PING 检测,确保不会拿到死连接。
5.4 监控告警
| 监控项 | 告警阈值 | 意义 |
|---|---|---|
| Flink 任务重启次数 | > 3 次/小时 | 可能存在持续性故障 |
| Redis 连接异常日志 | 任何 JedisConnectionException |
主从切换或网络问题 |
| Redis 主从延迟 | > 1 秒 | 异步复制积压,可能丢数据 |
| 哨兵集群健康 | 任意哨兵不可达 | 哨兵集群本身出问题 |
5.5 避免雪崩:连接池预热与限流
故障恢复后,所有并行子任务可能同时尝试重新连接 Redis,瞬间产生大量连接请求。
应对:
scala
// 方式一:限制 Sink 并行度
.setParallelism(1)
// 方式二:在连接池配置中限制最大连接数
.setMaxTotal(10) // 不要设置过大,避免恢复时打爆 Redis
六、总结
| 问题 | 答案 |
|---|---|
| Redis 主从切换时 Flink 任务会崩溃吗? | 使用 FlinkJedisPoolConfig → 会崩溃 ;使用 FlinkJedisSentinelConfig → 不会崩溃,会自动恢复。 |
| 故障转移期间会发生什么? | Flink 任务会短暂报错(连接超时),但配合重启策略和 Sentinel 自动重连,任务可在 10~30 秒内自愈。 |
| 数据会丢失吗? | 可能。Redis 异步复制导致主从切换时存在"数据黑洞",需根据业务重要性决定是否容忍。 |
| 生产环境还需要做什么? | 配置重启策略、合理超时、连接池健康检查、监控告警、防止恢复时的连接风暴。 |
核心要点回顾:
- 配置类升级 :从
FlinkJedisPoolConfig切换到FlinkJedisSentinelConfig,是让 Flink 任务在 Redis 主从切换时"活下来"的第一步。 - 重启策略是保底 :即使 Sentinel 自动重连,故障转移期间的短暂不可用仍可能触发 Flink 报错。
fixedDelayRestart是必选项。 - 数据一致性是上限:Redis 的 AP 特性决定了它在故障转移时无法保证数据零丢失。关键数据请使用支持事务的存储系统。
- 监控是最后的防线:没有监控的高可用是"伪高可用"------你永远不会知道故障发生过,直到数据出问题。
下期预告 :当 Redis 写入成为性能瓶颈时,如何利用 异步批量 Sink 将吞吐量从 1w QPS 提升到 10w+?敬请期待。