当 Redis 写入成为性能瓶颈时,如何利用 异步批量 Sink 将吞吐量从 1w QPS 提升到 10w+?

引言:从"能跑"到"跑得快"

在上一篇文章中,我们解决了"高可用"问题------通过 Sentinel 让 Flink 任务在 Redis 主从切换时自动恢复。但高可用解决了"活下来"的问题,却未必解决"活得快"的问题。

真实的生产场景往往比我们想象的要残酷。假设你的实时数据流高峰 QPS 达到 5 万、10 万甚至更高,而每个事件都需要写入 Redis。这时你会发现:任务没有崩溃,但反压(Backpressure)越来越严重,吞吐量死活上不去

为什么会这样?我们用官方 RedisSink(基于 FlinkJedisPoolConfig)逐条写入时,每条数据都要经历一次网络 RTT(Round-Trip Time) 。在局域网环境中,单次 RTT 约 0.5~1ms,这意味着单线程极限吞吐只有 1000~2000 QPS。即使开启多个并行度,受限于 Redis 服务端的连接数和处理能力,整体吞吐通常也就在 1万~2万 QPS 左右。

那么,如何将吞吐量从 1w 提升到 10w+?答案是两个核心技术的组合:异步 I/O + 批量写入(Pipeline)

本文将深入剖析:

  1. 为什么逐条写入 Redis 会成为性能瓶颈------从网络 RTT 到 Redis 服务端处理模型
  2. 异步 I/O 和 Pipeline 各自解决了什么问题
  3. 两种生产级实现方案:基于 Flink Async I/O 和基于 RichSinkFunction 自建批量缓存
  4. 完整的可运行代码、调优参数和常见坑点

一、前置知识:为什么逐条写入这么慢?

1.1 网络 RTT 是最大的隐形杀手

假设你的 Flink 任务和 Redis 部署在同一机房,网络延迟约 0.5ms。逐条写入时,每条数据的处理流程是:

复制代码
Flink Task -> 获取连接 -> 发送HSET命令 -> 等待Redis响应 -> 归还连接 -> 处理下一条
                         ↑_______________0.5ms_______________↓

这 0.5ms 的等待时间里,CPU 和网络带宽都在空转。单条数据本身可能只有几百字节,但每次网络往返的"固定开销"远大于数据传输本身的时间。

公式化表达

  • 单条写入耗时 ≈ 网络 RTT(0.5ms)+ Redis 执行时间(~0.05ms)
  • 单线程极限 QPS ≈ 1000ms / 0.55ms ≈ 1800 QPS

即使开启 10 个并行度,极限也就 1.8 万 QPS------这就是为什么你的任务只能跑到 1w 左右。

1.2 Redis 服务端处理模型的"天花板"

Redis 是单线程处理命令的(指核心事件循环)。这意味着:

  • 无论你有多少个客户端连接,Redis 在同一时刻只能处理一个命令
  • 每个命令的执行时间虽然极短(微秒级),但网络 I/O 和命令解析同样消耗时间

当大量客户端同时涌入时,Redis 的事件循环会出现排队,导致延迟上升。逐条写入放大了这个问题------每个连接每发一条命令就要等一次响应,网络往返次数 = 数据条数。

1.3 两个优化方向

优化方向 解决的问题 原理
异步 I/O 消除 Flink 任务侧的阻塞等待 单并行度可同时发起多个未完成的请求
批量写入(Pipeline) 减少网络往返次数 多条命令合并为一次网络传输

两者结合,理论上可以将吞吐量提升 10 倍以上


二、核心剖析:异步 I/O 与 Pipeline 的底层原理

Flink 在 1.2 版本引入了 Async I/O API。它的核心思想是:

同步模式(MapFunction):

复制代码
数据1 -> 发请求 -> 阻塞等响应 -> 收到 -> 处理数据2 -> 发请求 -> 阻塞等响应 -> 收到 -> ...

异步模式(AsyncFunction):

复制代码
数据1 -> 发请求(不等待)
数据2 -> 发请求(不等待)
数据3 -> 发请求(不等待)
...(同时有多个请求在网络上飞行)
响应1回来 -> 处理
响应2回来 -> 处理
响应3回来 -> 处理

单个并行度可以同时发起 N 个未完成的请求 (N 由 capacity 参数控制,默认 100)。这意味着网络等待时间被"重叠"了------在等待响应1的时候,已经在发送请求2、3、4了。

关键参数

  • capacity:最大并发请求数。设置越大吞吐越高,但会增大内存压力和 Redis 服务端压力
  • timeout:请求超时时间。超时未返回的请求会触发异常

注意 :Async I/O 更适合读取(维表关联)场景 。对于写入(Sink)场景,官方 RedisSink 并不直接支持 Async I/O。我们需要自建 Sink 或使用社区增强版连接器。

2.2 原理二:Redis Pipeline ------ 让"多次往返"变成"一次往返"

Redis Pipeline 是 Redis 协议层面的批量处理机制。普通模式下,客户端发送一条命令,必须等到响应后才能发送下一条:

复制代码
Client: SET key1 value1
Server: +OK
Client: SET key2 value2
Server: +OK
Client: SET key3 value3
Server: +OK
# 3次网络往返

Pipeline 模式下,客户端可以一次性发送多条命令,然后一次性读取所有响应:

复制代码
Client: SET key1 value1
        SET key2 value2
        SET key3 value3   ← 三条命令一起发
Server: +OK              ← 三条响应一起回
        +OK
        +OK
# 1次网络往返

性能提升的数学原理

  • 假设 100 条数据,单条 RTT = 0.5ms
  • 逐条写入:100 × 0.5ms = 50ms
  • Pipeline 批量(100条一批):1 × 0.5ms + 执行时间 ≈ 1ms
  • 提升约 50 倍(理想情况)

实际生产中,受限于网络带宽、Redis 处理能力和批次大小,通常能提升 5~10 倍 。有团队在测试中将 hincrBy 操作从 5w+ ops 提升到了 60w+ ops

2.3 两种实现路径对比

实现方式 核心机制 适用场景 复杂度
Flink Async I/O + 逐条写入 并发发请求,不阻塞 读多写少(维表关联)
RichSinkFunction + Pipeline 攒批后批量提交 写多读少(Sink 场景) 中高
AsyncSink(FLIP-171) Flink 官方异步 Sink API 通用 Sink 场景 低(需 Flink 1.15+)

对于写入 Redis 的 Sink 场景,最推荐的方案是自定义 RichSinkFunction + Redis Pipeline + 定时 flush


三、手把手实操:两种生产级实现方案

虽然 Async I/O 更适合读取,但如果你需要异步写入 Redis(比如每条数据需要先查 Redis 再决定写什么),这个方案依然适用。

环境依赖(与之前一致):

scala 复制代码
// build.sbt
val flinkVersion = "1.13.6"
libraryDependencies ++= Seq(
  "org.apache.flink" %% "flink-streaming-scala" % flinkVersion,
  "redis.clients" % "jedis" % "3.7.0"
)

核心代码:异步写入 Redis

scala 复制代码
package async

import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.api.functions.async.{RichAsyncFunction, AsyncFunction}
import org.apache.flink.streaming.api.functions.async.collector.AsyncCollector
import redis.clients.jedis.{Jedis, JedisPool, JedisPoolConfig}
import source.{Event, ClickSource}

import java.util.concurrent.CompletableFuture
import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.ExecutionContext.Implicits.global

class AsyncRedisSinkFunction(pool: JedisPool) extends RichAsyncFunction[Event, Event] {
  
  override def asyncInvoke(input: Event, collector: AsyncCollector[Event]): Unit = {
    // 使用 CompletableFuture 包装异步操作
    val future = CompletableFuture.supplyAsync(() => {
      val jedis = pool.getResource
      try {
        // 执行 HSET 命令
        jedis.hset("click", input.user, input.url)
        input // 返回原数据(或转换为更丰富的类型)
      } finally {
        if (jedis != null) jedis.close()
      }
    })
    
    // 处理完成回调
    future.thenAccept(result => {
      collector.collect(java.util.Collections.singletonList(result))
    }).exceptionally(e => {
      // 异常处理:可记录日志或发送到死信队列
      collector.collect(java.util.Collections.emptyList[Event]())
      null
    })
  }
  
  override def timeout(input: Event, collector: AsyncCollector[Event]): Unit = {
    // 超时处理
    collector.collect(java.util.Collections.emptyList[Event]())
  }
}

object AsyncRedisSinkDemo {
  def main(args: Array[String]): Unit = {
    val env = StreamExecutionEnvironment.getExecutionEnvironment
    env.enableCheckpointing(10000)
    
    // 初始化连接池
    val poolConfig = new JedisPoolConfig()
    poolConfig.setMaxTotal(50)
    poolConfig.setMaxIdle(20)
    poolConfig.setMinIdle(5)
    poolConfig.setTestOnBorrow(true)
    val jedisPool = new JedisPool(poolConfig, "localhost", 6379, 5000)
    
    val dataStream: DataStream[Event] = env.addSource(new ClickSource)
    
    // 使用 AsyncDataStream 应用异步函数
    // unorderedWait: 不保证顺序,吞吐更高
    // orderedWait: 保证顺序,吞吐略低
    val resultStream = AsyncDataStream.unorderedWait(
      dataStream,
      new AsyncRedisSinkFunction(jedisPool),
      5000,           // 超时时间 5 秒
      java.util.concurrent.TimeUnit.MILLISECONDS,
      100             // capacity: 最大并发请求数
    )
    
    resultStream.print("Written to Redis")
    env.execute("Async Redis Sink Demo")
  }
}

方案一的局限性

  • AsyncDataStream 本质上是流转换算子,不是 Sink。它会产生一个输出流,这在"写入"场景中有些别扭
  • 如果不需要下游继续处理,这种模式会浪费资源
  • 实际吞吐提升约 2~3 倍,不如 Pipeline 方案显著

3.2 方案二:RichSinkFunction + Pipeline + 定时 Flush(强烈推荐)

这是生产环境最常用的方案。核心思路:在 Sink 内部维护一个缓冲区,攒够一批数据后使用 Redis Pipeline 一次性提交。

完整代码实现

scala 复制代码
package sink

import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.api.functions.sink.{RichSinkFunction, SinkFunction}
import org.apache.flink.streaming.api.checkpoint.CheckpointedFunction
import org.apache.flink.api.common.state.{ListState, ListStateDescriptor}
import org.apache.flink.runtime.state.{FunctionInitializationContext, FunctionSnapshotContext}
import org.apache.flink.streaming.api.functions.sink.SinkFunction.Context
import org.apache.flink.util.Preconditions
import redis.clients.jedis.{Jedis, JedisPool, JedisPoolConfig, Pipeline}
import source.{Event, ClickSource}

import scala.collection.mutable.ListBuffer
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._

class BulkRedisSink(
    host: String,
    port: Int,
    batchSize: Int = 100,           // 批量大小阈值
    flushIntervalMs: Long = 1000    // 定时刷新间隔(毫秒)
) extends RichSinkFunction[Event] with CheckpointedFunction {
  
  // 缓冲区:使用 ListBuffer 存储待写入的数据
  @transient private var buffer: ListBuffer[Event] = _
  @transient private var jedisPool: JedisPool = _
  @transient private var lastFlushTime: Long = _
  
  // Checkpoint 状态:用于故障恢复时重新处理未 flush 的数据
  @transient private var checkpointedState: ListState[Event] = _
  
  override def open(parameters: org.apache.flink.configuration.Configuration): Unit = {
    // 1. 初始化 Redis 连接池
    val poolConfig = new JedisPoolConfig()
    poolConfig.setMaxTotal(20)
    poolConfig.setMaxIdle(10)
    poolConfig.setMinIdle(5)
    poolConfig.setTestOnBorrow(true)
    poolConfig.setTestWhileIdle(true)
    poolConfig.setTimeBetweenEvictionRunsMillis(30000)
    jedisPool = new JedisPool(poolConfig, host, port, 5000)
    
    // 2. 初始化缓冲区和最后刷新时间
    buffer = ListBuffer.empty[Event]
    lastFlushTime = System.currentTimeMillis()
    
    // 3. 注册 ProcessingTimeTimer(定时刷新)
    val runtimeContext = getRuntimeContext
    // 注意:此处简化实现,实际可使用 ProcessingTimeService
    // 或通过单独的调度线程实现定时 flush
  }
  
  override def invoke(value: Event, context: SinkFunction.Context): Unit = {
    // 1. 将数据加入缓冲区
    buffer.synchronized {
      buffer += value
    }
    
    // 2. 检查是否达到批量阈值
    if (buffer.size >= batchSize) {
      flush()
    }
    
    // 3. 检查是否到达定时刷新时间
    val now = System.currentTimeMillis()
    if (now - lastFlushTime >= flushIntervalMs) {
      flush()
    }
  }
  
  /**
   * 核心方法:使用 Pipeline 批量写入 Redis
   */
  private def flush(): Unit = {
    buffer.synchronized {
      if (buffer.isEmpty) return
      
      // 取出当前批次数据(不移除,等写入成功后再清空)
      val batch = buffer.toList
      
      var jedis: Jedis = null
      try {
        jedis = jedisPool.getResource
        val pipeline: Pipeline = jedis.pipelined()
        
        // 批量添加命令到 Pipeline
        batch.foreach { event =>
          pipeline.hset("click", event.user, event.url)
        }
        // 可选:设置过期时间(如 7 天)
        // pipeline.expire("click", 604800)
        
        // 执行 Pipeline(一次网络往返)
        pipeline.sync()
        
        // 写入成功,清空缓冲区
        buffer.clear()
        lastFlushTime = System.currentTimeMillis()
        
      } catch {
        case e: Exception =>
          // 写入失败:保留缓冲区数据,等待重试
          // 生产环境可增加重试机制和死信队列
          println(s"Failed to flush to Redis: ${e.getMessage}")
          throw e
      } finally {
        if (jedis != null) jedis.close()
      }
    }
  }
  
  // ===== Checkpoint 相关:保证 Exactly-Once 语义 =====
  
  override def snapshotState(context: FunctionSnapshotContext): Unit = {
    // 在 Checkpoint 前强制 flush 所有数据
    flush()
    
    // 将缓冲区数据保存到 Checkpoint 状态中
    checkpointedState.clear()
    buffer.synchronized {
      buffer.foreach(checkpointedState.add)
    }
  }
  
  override def initializeState(context: FunctionInitializationContext): Unit = {
    val descriptor = new ListStateDescriptor[Event](
      "bulk-redis-buffer-state",
      classOf[Event]
    )
    checkpointedState = context.getOperatorStateStore.getListState(descriptor)
    
    // 从 Checkpoint 恢复数据
    if (context.isRestored) {
      import scala.collection.JavaConverters._
      buffer = ListBuffer.empty[Event]
      checkpointedState.get().asScala.foreach(buffer += _)
      println(s"Restored ${buffer.size} events from checkpoint")
    }
  }
  
  override def close(): Unit = {
    // 关闭前强制 flush 所有剩余数据
    flush()
    if (jedisPool != null) jedisPool.close()
  }
}

object BulkRedisSinkDemo {
  def main(args: Array[String]): Unit = {
    val env = StreamExecutionEnvironment.getExecutionEnvironment
    // 必须开启 Checkpoint,否则批量 Sink 在故障时可能丢数据
    env.enableCheckpointing(10000)
    
    val dataStream: DataStream[Event] = env.addSource(new ClickSource)
    
    // 使用自定义批量 Sink
    dataStream.addSink(new BulkRedisSink(
      host = "localhost",
      port = 6379,
      batchSize = 100,      // 每 100 条 flush 一次
      flushIntervalMs = 1000 // 或每 1 秒 flush 一次
    )).name("Bulk Redis Sink")
      .setParallelism(2)    // 建议并行度不要太高
    
    env.execute("Bulk Redis Sink Demo")
  }
}

代码要点解析

组件 作用 关键实现
缓冲区(buffer) 攒批 ListBuffer[Event],线程安全需手动加锁
Pipeline 批量提交 jedis.pipelined() + pipeline.sync()
双触发条件 兼顾吞吐和延迟 buffer.size >= batchSize时间间隔 >= flushIntervalMs
CheckpointedFunction 故障恢复 snapshotState 中 flush + 保存状态
连接池 复用连接 JedisPool,配置 TestOnBorrow 确保连接可用

3.3 性能调优参数指南

参数 推荐值 说明
batchSize 100~500 批次太小提升不明显,太大会增加延迟和内存压力
flushIntervalMs 500~2000 低流量时保证数据不积压太久
MaxTotal(连接池) 并行度 × 2 连接数太少会阻塞,太多会压垮 Redis
并行度 1~4 Redis 单线程,并行度过高反而增加竞争
setTestOnBorrow true 防止拿到已断开的连接

一个重要的权衡 :批量写入会引入延迟 。如果 batchSize=100,那么第 1 条数据可能要等第 100 条到达才会被写入,最坏延迟 = 100 条数据的到达间隔。通过 flushIntervalMs 可以控制最大延迟。


四、进阶思考:当吞吐量继续飙升时

4.1 突破单机 Redis 瓶颈

即使 Pipeline 将单机 Redis 吞吐推到 10w+ QPS,单机 Redis 终究有上限(取决于命令复杂度和网络带宽)。当需要更高吞吐时:

方案 原理 适用场景
Redis Cluster 数据分片,多节点并行 吞吐 > 10w QPS
Redis 分区(业务层) 按 key 哈希写入不同 Redis 实例 不想引入 Cluster 复杂度
异步 Sink(FLIP-171) Flink 1.15+ 官方异步 Sink API 希望框架层支持

4.2 Checkpoint 与批量 Sink 的"死锁"风险

这是一个容易被忽略的坑:如果 flush() 方法在执行 Pipeline 时耗时过长 (比如 Redis 响应慢),而 Checkpoint 恰好在此时触发,snapshotState 会等待 flush() 完成,可能导致 Checkpoint 超时。

解决方案

  1. 设置合理的 Pipeline 批次大小,单次 flush 不超过 100ms
  2. snapshotState 中设置超时保护
  3. 监控 Checkpoint 耗时,及时调整参数

4.3 数据去重与幂等性

Pipeline 批量写入和逐条写入一样,如果任务故障重启,部分数据可能被重复写入 。但由于我们使用的是 HSET(幂等命令),重复写入不会造成数据不一致。

如果使用非幂等命令 (如 LPUSHINCR),则需要:

  1. 在业务层设计去重逻辑(如使用唯一 ID)
  2. 或使用 Flink 的 Exactly-Once Sink(需 Sink 支持两阶段提交)

五、总结

核心要点 内容回顾
瓶颈根源 逐条写入时,网络 RTT 占用了 90% 以上的时间
异步 I/O 让单并行度同时发起多个请求,消除阻塞等待
Redis Pipeline 多条命令合并为一次网络传输,减少往返次数
推荐方案 RichSinkFunction + 缓冲区 + Pipeline + 定时 Flush
性能提升 从 1w QPS 提升到 10w+ QPS ,典型场景 5~10 倍
关键配置 batchSize(100500)、`flushIntervalMs`(5002000ms)、开启 Checkpoint

何时选择哪种方案

场景 推荐方案
写入吞吐 < 1w QPS 官方 RedisSink 即可
写入吞吐 1w~5w QPS RichSinkFunction + Pipeline(本文方案二)
写入吞吐 > 5w QPS Pipeline + Redis Cluster + 并行度调优
维表关联(读取) Flink Async I/O(本文方案一)

核心口诀

攒一批,管道送;定时刷,防积压;开 CP,保一致性;调池子,防阻塞。

从"逐条写入"到"批量 Pipeline",改变的不仅仅是代码,更是对"网络 I/O 是最大瓶颈"这一底层认知的升级。下次当你再遇到 Sink 性能问题时,不妨先问问自己:我的数据是"条条都等"还是"攒批再发"?

下期预告:当 Redis 写入不再是瓶颈后,Flink 任务的反压可能来自哪里?如何系统性地定位和解决 Flink 反压问题?敬请期待。

相关推荐
SelectDB4 小时前
Apache Doris 2026 Roadmap:AI 成为主流负载后数据基础设施的演进方向
数据库
SelectDB4 小时前
瓴岳科技洋钱罐 SelectDB Hive 透明加速:湖仓一体化探索分析平台升级实践
数据库
SelectDB4 小时前
飞轮科技 SelectDB Enterprise 4.0.5:企业级实时分析与 AI 数据底座的安全治理实践
数据库
SelectDB4 小时前
金城银行 Apache Doris 实时数据平台:从 T+1 到分钟级的金融级高可靠实践
数据库
SelectDB4 小时前
Apache Doris 4.1:面向 AI & Search 的统一数据存储与检索底座技术能力
数据库
qq_401700414 小时前
Qt自定义信号槽详解:带参发射 vs sender()获取,两种多信号关联方案全掌握
java·数据库·qt
广州浮点FLOATLIC5 小时前
Mentor Graphics浮动许可证管理为什么一到流片节点就失控
java·大数据·数据库·许可证管理·cad 许可证优化
小王C语言6 小时前
MySQL 库的操作:字符集和校验规则、数据库的增删查改、备份和还原
数据库·mysql
小王C语言6 小时前
MySQL 数据类型:数值类型、字符串类型、日期和时间类型、enum 和 set、find_in_set 查询
android·数据库·mysql