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
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
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) |
nivel |
barril |
1 | [2,3) [3,4) |
2 | [2,4) [4,6) |
5
para resumir
Si tiene alguna sugerencia o pregunta, puede dejar un mensaje a continuación.