当 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(幂等命令),重复写入不会造成数据不一致。

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

  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 反压问题?敬请期待。

相关推荐
一只鹿鹿鹿3 小时前
政务外网整体设计方案(PPT)
大数据·数据库·web安全·系统安全·流程图·政务
2603_965148113 小时前
社区与学习资源:从入门到精通的API选品学习路径
数据库·人工智能·python·学习·数据挖掘
AI砖家3 小时前
安全进入 Agent Loop:2027 年 Coding Agent 的决胜点
数据库·安全·codex·vibecoding
wangqiaowq4 小时前
SQL server C盘空间占满的处理
数据库·sql
文人sec4 小时前
SQL基础入门笔记 | 从零基础到掌握核心查询语句
数据库·笔记·sql
数字新视界4 小时前
机房动环监控提升运维效率
大数据·服务器·数据库·动环监控系统·动力与环境监控系统
SelectDB技术团队4 小时前
冷热分层从建到验:Storage Policy 创建、表级与分区级绑定、SHOW TABLETS 验证的完整命令
大数据·数据库·elasticsearch·搜索引擎·数据分析·apache doris·冷热分层
智能直播4 小时前
网络RTMP拉流不卡顿、声音不同步,一文讲透缓冲区、时间戳
开发语言·ffmpeg·php·音视频·视频编解码
墨云安全5 小时前
内网资产无法暴露到公网,怎么做安全检测?2026 年内网渗透测试的落地路径
开发语言·安全·网络安全·渗透测试·php
lambo mercy6 小时前
Day3:结构化输出
java·数据库·oracle