El principio y la implementación de la rueda del tiempo de Kafka.

  Computación en la nube Xu Yunze 360 

Declaración de heroína






Como plataforma de procesamiento de flujo distribuido que admite el procesamiento en tiempo real de una gran cantidad de solicitudes, Kafka necesita un temporizador bien diseñado para manejar tareas asincrónicas. El autor de este artículo presentará la estructura de datos básica del temporizador en Kafka: el principio y la implementación de la rueda del tiempo basada en el código fuente de Kafka versión 1.1.0.

PD: Las tecnologías ricas de primera línea y las formas de expresión diversificadas se encuentran en el " 360 Cloud Computing ", ¡preste atención!


1

Rueda de tiempo simple

  La rueda de tiempo simple es una lista enlazada circular de cubos de tareas de tiempo, también conocidos como cubos . Sea u el tamaño de la unidad de tiempo, una rueda de tiempo de tamaño n tiene n cubos y puede contener n * u tareas cronometradas, y el tiempo de expiración de cada tarea estará dentro de un intervalo de tiempo. (Nota: U y n a continuación siguen esta definición)

Cada depósito contiene tareas cronometradas que entran en el intervalo de tiempo correspondiente. El primer contenedor contiene tareas en el rango [0, u), el segundo contenedor contiene tareas en el rango [u, 2u) ... el enésimo contenedor contiene [u * (n-1), u * n) Rango de tareas . Después de cada unidad de tiempo u, el temporizador avanzará y se moverá al siguiente segmento, y luego todas las tareas cronometradas en el primer segmento expirarán. Dado que la tarea ha expirado, el temporizador no insertará la tarea en el depósito actual en este momento. El temporizador ejecutará las tareas caducadas de inmediato. Debido a que el cubo vacío está disponible en la siguiente ronda, si el cubo actual corresponde al tiempo t, se convertirá en un cubo de [t + u * n, t + (n + 1) * u) después de avanzar.


En esencia, la rueda de tiempo es una tabla hash y el tiempo de caducidad de la tarea se hash en la posición correspondiente. El cubo correspondiente a cada posición es una lista vinculada, por lo que la complejidad de tiempo de la tarea de sincronización de inserción / eliminación de la rueda de tiempo es O (1). La complejidad de tiempo de insertar / eliminar temporizadores basados ​​en colas de prioridad, como java.util.concurrent.DelayQueue y java.util.Timer, es O (log n).


2

Rueda de tiempo jerárquica

  La principal desventaja de la rueda de tiempo simple es que asume que la solicitud del temporizador está dentro del intervalo de tiempo n * u desde el momento actual. Si la solicitud del temporizador excede este intervalo, se producirá un desbordamiento, lo que hará que la tarea no pueda ser colocado en la rueda del tiempo. La rueda del tiempo jerárquica se ocupará de este tipo de desbordamiento. Organiza la rueda del tiempo en capas. La capa inferior tiene una mayor precisión, y cuanto mayor es el número de capas, menor es la precisión de la representación. La precisión se utiliza aquí para referirse al tamaño de la unidad de tiempo.

Por ejemplo, sea u = 1, n = 3, y establezca la hora de inicio en c, entonces los cubos de cada nivel son

nivel
barril precisión
1
[c, c] [c + 1, c + 1] [c + 2, c + 2]  1
2 [c, c + 2] [c + 3, c + 5] [c + 6, c + 8] 3
3 [c, c + 8] [c + 9, c + 17] [c + 18, c + 26] 9

PD: Aquí se usa la expresión en los comentarios del código, es decir, el intervalo cerrado, mientras que los principios anteriores son todos intervalos cerrados por la izquierda y abiertos por la derecha Los dos son equivalentes, pero son inconsistentes.

En c + 1, los depósitos [c, c], [c, c + 2] y [c, c + 8] expiraron, luego:

  • El reloj de la capa 1 se mueve a c + 1 y se crea un nuevo depósito [c + 3, c + 3];

  • Los relojes de las capas 2 y 3 todavía están en c porque no han expirado por completo.

En este momento, los cubos de cada nivel son

nivel
barril precisión
1
[c + 1, c + 1] [c + 2, c + 2] [c + 3, c + 3] 1
2 [c, c + 2] [c + 3, c + 5] [c + 6, c + 8]  3
3
[c, c + 8] [c + 9, c + 17] [c + 18, c + 26] 9

Tenga en cuenta que el depósito [c, c + 2] no recibirá ninguna tarea, porque el tiempo es c + 1 en este momento, y solo el tiempo de vencimiento de c + 1 y c + 2 se asignará al depósito. Sin embargo, los dos en el nivel 1 Bucket [c + 1, c + 1] [c + 2, c + 2] recibirán las tareas primero. De manera similar, [c + 1, c + 8] en el nivel 3 no recibirá ninguna tarea, porque este rango está cubierto por cubos en el nivel 2.

Para una rueda de tiempo de una sola capa, la complejidad temporal de insertar / eliminar tareas de tiempo es O (1). Para rondas de tiempo jerárquicas, sea m el número de rondas de tiempo, entonces la complejidad de tiempo de inserción es O (m), porque como máximo m veces se insertan hacia arriba. En comparación con el número de solicitudes del sistema, m suele ser mucho más pequeño. La complejidad temporal de la eliminación es O (1).

Un reloj es una rueda de tiempo típica de tres capas, la manecilla de los segundos puede indicar de 0 a 59 segundos, pero durante más de 60 segundos, la manecilla de los minutos debe indicarse más y luego la manecilla de instante se indica más. El rango de tiempo total que se puede mostrar es de 0 a 43199 segundos, con una precisión de 1 segundo. Desde la manecilla de los segundos hasta la manecilla de los minutos y la manecilla de las horas, significa que la precisión está disminuyendo en secuencia. La precisión de la manecilla de los segundos es de 1 segundo y hay 60 divisiones. Por lo tanto, la precisión de la manecilla de los minutos es 1 * 60 = 60 segundos De manera similar, la precisión del reloj es de 3600 segundos.

3

Implementación de TimingWheel

 Después de comprender el concepto de una rueda de tiempo jerárquica, es fácil leer el código e implementarlo. La rueda de tiempo de Kafka es de la clase TimingWheel, ubicada en el paquete kafka.utils.timer.


Campo interno



nombre
Tipos de
Descripción
tickMs Largo
Unidad de tiempo u
tamaño de la rueda En t
Número de cubetas n
startMs Largo
Marca de tiempo de milisegundos 
taskCounter AtomicInteger El número de tareas, es decir, la suma del número de nodos en todos los depósitos.
cola DelayQueue [TimerTaskList] Cola de retardo de la biblioteca estándar

Los siguientes campos privados se pueden calcular mediante los parámetros de construcción principales anteriores (privado [esto], al que pueden acceder otras clases del paquete)

  // 当前时间轮的整个时间跨度,即更高一层时间轮的 tickMs  private[this] val interval = tickMs * wheelSize  // 创建 wheelSize 个桶(定时任务链表)  private[this] val buckets = Array.tabulate[TimerTaskList](wheelSize) { _ => new TimerTaskList(taskCounter) }
 // 向下取整,使起始时间戳能被 tickMs 整除  private[this] var currentTime = startMs - (startMs % tickMs) // rounding down to multiple of tickMs
 // 高一层时间轮,用来保存超过 interval 的任务  @volatile private[this] var overflowWheel: TimingWheel = null

Cree una rueda de tiempo superior a través de addOverflowWheel:

  private[this] def addOverflowWheel(): Unit = {    synchronized {      if (overflowWheel == null) {  // 双重检查上锁        overflowWheel = new TimingWheel(          // 仅有 tickMs 不是原封不动地转发低层时间轮的字段,因为高层时间轮的时间单元粒度更粗(即精度更低)          // 还是参考时钟,时针的 tickMs 是分针 tickMs 的 60 倍          tickMs = interval,          wheelSize = wheelSize,          startMs = currentTime,          taskCounter = taskCounter,          queue        )      }    }  }

添加定时任务



在 Kafka 中,定时任务被抽象为 TimerTaskEntry 类,而桶(定时任务链表)则被抽象为 TimerTaskList 类,在代码中命名都是 bucket(桶)。bucket 实现了 java.util.concurrent.Delayed 接口:

  def getDelay(unit: TimeUnit): Long = {    unit.convert(max(getExpiration - Time.SYSTEM.hiResClockMs, 0), TimeUnit.MILLISECONDS)  }

因此 bucket 能够被加入延时队列中,延时队列在调用 poll 时,会调用内部对象的 getDelay 方法来判断对象是否可以被弹出。再看看实际的 add 实现:

  def add(timerTaskEntry: TimerTaskEntry): Boolean = {    // 定时任务的过期时间戳    val expiration = timerTaskEntry.expirationMs
   if (timerTaskEntry.cancelled) {      // Entry 绑定的 TimerTask 调用了 cancel() 方法主动将 Entry 从链表中移除      false    } else if (expiration < currentTime + tickMs) {      // 过期时间在第一个桶的范围内,表示已经过期,此时无需加入时间轮      false    } else if (expiration < currentTime + interval) {      // 过期时间在当前时间轮能表示的时间范围内,加入到其中一个桶      // 注意按照这个算法,第一个桶的时间范围是 [c+u,c+u*2),因为 [c,c+u) 范围内被视为已过期      // 而且第一个桶对应 buckets 的下标并不一定是 0,因为数组只是作为循环队列的存储方式,起始下标无所谓      val virtualId = expiration / tickMs      val bucket = buckets((virtualId % wheelSize.toLong).toInt)      bucket.add(timerTaskEntry)
     // 设置过期时间,这里也取整了,即可以被 tickMs 整除      if (bucket.setExpiration(virtualId * tickMs)) { // 仅在新的过期时间和之前的不同才返回 true        // 由于进行了取整,同一个 bucket 所有节点的过期时间都相同,因此仅在 bucket 的第一个节点加入时才会进入此 if 块        // 因此保证了每个桶只会被加入一次到 queue 中,queue 存放所有包含定时任务节点的 bucket        // 借助 DelayQueue 来检测 bucket 是否过期,bucket 时遍历即可取出所有节点        queue.offer(bucket)      }      true    } else {      // 过期时间在当前时间轮表示的范围之外,即溢出,需要创建高一层时间轮来加入      if (overflowWheel == null) addOverflowWheel() // 双重检查上锁的第一层检查      overflowWheel.add(timerTaskEntry) // 注意高一层时间轮也可能无法容纳,因此可能会递归创建更高层级的时间轮    }  }

可以看到 DelayQueue 对象 queue 在时间轮的作用是,保存包含定时任务节点的桶,桶可以来自不同层次的时间轮,当然,所有层次时间轮也共享这个队列。

TimingWheel en sí no implementa la función de avance, pero usa la cola de retardo DelayQueue para darse cuenta del paso del tiempo. Suponiendo que hay M tareas de sincronización distribuidas en N cubos, la complejidad del tiempo de inserción es O (M + N * log N), donde M> = N. Si todas las tareas se almacenan en la cola de retardo, la complejidad temporal de la inserción es O (M * log M), por lo que la optimización de la rueda de tiempo de Kafka es significativa.

Avance de la rueda del tiempo



  def advanceClock(timeMs: Long): Unit = {    if (timeMs >= currentTime + tickMs) { // timeMs 超过了当前 bucket 的时间范围      currentTime = timeMs - (timeMs % tickMs) // 修改当前时间,即原先的第一个桶已经失效
     // 若存在更高层的时间轮,则也会向前运转      if (overflowWheel != null) overflowWheel.advanceClock(currentTime)    }  }

Simplemente modifique el currentTime, este campo determina si el depósito interno caduca, consulte la implementación del método de adición anterior.

4

El papel de la rueda del tiempo en las tareas de cronometraje de gestión de Kafka

 Kafka utiliza la clase DelayedOperationPurgatory (en lo sucesivo, purgatory) del paquete kafka.server para gestionar tareas asincrónicas (es decir, DelayedOperation). Cada vez que Kafka recibe una solicitud, iniciará una tarea asincrónica. Si no se puede completar de inmediato (por ejemplo, una solicitud Produce con acks configurada en todos), se enviará al purgatorio para su almacenamiento (es decir, se insertará en el rueda de tiempo interna). Purgatory ejecutará un subproceso en segundo plano ExpiredOperationReaper para detectar y procesar tareas asincrónicas caducadas. En la función del subproceso, el método advanceClock del objeto temporizador interno timeoutTimer se llamará repetidamente para avanzar. Si hay tareas caducadas, se eliminarán del temporizador . Eliminar y ejecutar la devolución de llamada:
  la clase privada ExpiredOperationReaper extiende ShutdownableThread (/ * ... * /) {// El método doWork se llamará cíclicamente en la función del hilo, es decir, el método de ejecución de la clase base Thread override def doWork () {advanceClock (200L) // 200 ms}}
Purgatory usa la clase SystemTimer en el paquete kafka.utils.timer como temporizador:
  def aplicar [T <: DelayedOperation] (purgatoryName: String, / * ... * /): DelayedOperationPurgatory [T] = {val timer = new SystemTimer (purgatoryName) new DelayedOperationPurgatory [T] (purgatoryName, timer, / * .. . * /)}
En SystemTimer, el campo clave es timingWheel:
  // La cola de retardo proporcionada por el paquete java.util.concurrent private [this] val delayQueue = new DelayQueue [TimerTaskList] () private [this] val taskCounter = new AtomicInteger (0) // Kafka está en kafka.utils. paquete de temporizador Rueda de tiempo autoimplementada privada [this] val timingWheel = new TimingWheel (tickMs = tickMs, wheelSize = wheelSize, startMs = startMs, taskCounter = taskCounter, delayQueue)
El método advanceClock realmente llama al método timingWheel.advanceClock:
  def advanceClock (timeoutMs: Long): Boolean = {// Espere milisegundos de tiempo de espera de la cola de demora, si hay un depósito caducado, saque var bucket = delayQueue.poll (timeoutMs, TimeUnit.MILLISECONDS) if (bucket! = null ) {/ / Hay un depósito caducado writeLock.lock () try {while (bucket! = Null) {// Avanza la rueda de tiempo actual, la interna puede avanzar recursivamente una rueda de tiempo superior, currentTime se modifica timingWheel.advanceClock (bucket .getExpiration ()) bucket.flush (reinsert) // El tiempo de espera predeterminado es 0, es decir, sin bloqueo, lo que significa que, en la medida de lo posible, elimine todos los depósitos caducados en el momento actual bucket = delayQueue.poll () }} finalmente {writeLock.unlock ()} true} else {false}}
Se puede ver que cuando el objeto SystemTimer llama a advancedClock para avanzar el tiempo, en realidad elimina todos los depósitos caducados durante el tiempo de avance de la cola de retraso y luego vacía:
  // Elimina todas las entradas de tareas y aplica la función proporcionada a cada una de ellas def flush (f: (TimerTaskEntry) => Unidad): Unidad = {sincronizada {// 遍历 整个 cubo (链表) , eliminar 删除 所有 节点 var head = root .next while (head ne root) {remove (head) f (head) head = root.next} expiration.set (-1L)}}
Tenga en cuenta que la función de reinsertar se pasa para vaciar:
privado [esto] val reinsert = (timerTaskEntry: TimerTaskEntry) => addTimerTaskEntry (timerTaskEntry)
La pregunta es, ¿por qué necesito volver a insertarlo después de eliminarlo? Porque si el cucharón extraído pertenece a la rueda de tiempo de alto nivel, es posible que el cucharón no haya caducado en este momento porque la precisión de la rueda de tiempo de alto nivel no es suficiente. Dé un ejemplo de una rueda de tiempo de dos capas (unidad: milisegundos):
nivel
barril
1
[0,1) [1,2)
2 [0,2) [2,4)
En el estado inicial, la tarea con un retraso de 3 se agrega a [2,4), después de llamar a advanceClock (2), la rueda de tiempo se convierte en:
nivel
barril
1 [2,3) [3,4) 
2 [2,4) [4,6) 
Se quita la capa 2 [2,4) y luego se quita la tarea con un retraso de 3. En este momento, llamar a reinsertar la agregará a la capa 1 [3,4) en lugar de juzgar que ha expirado inmediatamente. La bajada de la rueda de tiempo de alto nivel a la rueda de tiempo de bajo nivel está oculta en este discreto cubo.

5

para resumir

 Este artículo describe los conceptos de rueda de tiempo simple y rueda de tiempo jerárquica, y luego explica por qué y cómo implementar la rueda de tiempo jerárquica en Kafka a través de la lectura del código fuente. Para una gran cantidad de solicitudes, cada solicitud corresponde a una tarea de temporización, que requiere una gran cantidad de operaciones de inserción / eliminación. Por lo tanto, se utiliza una rueda de tiempo multicapa para reducir la complejidad temporal de la inserción / eliminación. duplicación de ruedas, Kafka todavía usa Java La cola de retardo de la biblioteca estándar avanza el tiempo. Además de aprender la rueda del tiempo, la implementación de Kafka de la rueda del tiempo también nos dio otra inspiración: la optimización debe optimizarse en áreas sensibles al rendimiento, y para operaciones que no son sensibles al rendimiento, si puede usar ruedas listas para usar, no se moleste para reinventar la rueda usted mismo.

Si tiene alguna sugerencia o pregunta, puede dejar un mensaje a continuación.


Supongo que te gusta

Origin blog.51cto.com/15127564/2666260
Recomendado
Clasificación