Implementación de bloqueo distribuido en Redis

Las cerraduras distribuidas deberían ser familiares para todos y a los entrevistadores les gusta hacer esta pregunta durante las entrevistas en muchas empresas importantes.

Cuando modificamos datos existentes en el sistema, primero debemos leerlos, luego modificarlos y guardarlos, en este momento es fácil encontrar problemas de concurrencia. Dado que la modificación y el guardado no son operaciones atómicas, algunas operaciones sobre los datos pueden perderse en escenarios concurrentes. En los sistemas de un solo servidor, a menudo utilizamos bloqueos locales para evitar problemas causados ​​por la concurrencia. Sin embargo, cuando los servicios se implementan en un clúster, los bloqueos locales no pueden tener efecto entre varios servidores. En este momento, se necesitan bloqueos distribuidos para garantizar la coherencia de los datos. lograr.

lograr

El bloqueo de Redis utiliza principalmente  el comando setnx  de Redis.

  • Comando de bloqueo: SETNX key valuecuando la clave no existe, configure la clave y devuelva el éxito; de lo contrario, devuelve el error. KEY es el identificador único de la cerradura y el nombre generalmente se determina según la empresa.
  • Comando de desbloqueo: DEL keylibera el bloqueo eliminando el par clave-valor para que otros subprocesos puedan adquirir el bloqueo a través del comando SETNX.
  • Tiempo de espera de bloqueo: EXPIRE key timeoutestablezca el período de tiempo de espera de la clave para garantizar que incluso si el bloqueo no se libera explícitamente, el bloqueo se pueda liberar automáticamente después de un cierto período de tiempo para evitar que los recursos se bloqueen para siempre.

El pseudocódigo de bloqueo/desbloqueo es el siguiente:

if (setnx(key, 1) == 1){
    expire(key, 30)
    try {
        //TODO 业务逻辑
    } finally {
        del(key)
    }
}

Existen algunos problemas con la implementación del bloqueo anterior:

1. No atomicidad de SETNX y EXPIRE

Si SETNX tiene éxito, después de configurar el tiempo de espera de bloqueo, el servidor se bloquea, se reinicia o tiene problemas de red, etc., lo que provoca que el comando EXPIRE no se ejecute y el bloqueo se bloquee sin establecer el tiempo de espera.

Puedes usar el script lua para resolver este problema, ejemplo:

if (redis.call('setnx', KEYS[1], ARGV[1]) < 1)
then return 0;
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2]));
return 1;

// 使用实例
EVAL "if (redis.call('setnx',KEYS[1],ARGV[1]) < 1) then return 0; end; redis.call('expire',KEYS[1],tonumber(ARGV[2])); return 1;" 1 key value 100

2. Bloquear la liberación de malentendidos

Si el subproceso A adquiere exitosamente el bloqueo y establece el tiempo de vencimiento en 30 segundos, pero el tiempo de ejecución del subproceso A excede los 30 segundos, el bloqueo se liberará automáticamente al expirar. En este momento, el subproceso B adquiere el bloqueo; luego, después de la ejecución de A se completa, el subproceso A usa el comando DEL para liberar el bloqueo, pero en este momento el bloqueo agregado por el subproceso B aún no se ha completado, y el subproceso A en realidad libera el bloqueo agregado por el subproceso B.

Al establecer el identificador de bloqueo del hilo actual en valor, verifique el valor correspondiente a la clave antes de eliminarla para determinar si el hilo actual mantiene el bloqueo. Se puede generar un UUID para identificar el hilo actual y se puede usar un script lua para verificar la identificación y desbloquear la operación.

// 加锁
String uuid = UUID.randomUUID().toString().replaceAll("-","");
SET key uuid NX EX 30
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
    then return redis.call('del', KEYS[1])
else return 0
end

3. El desbloqueo por tiempo de espera conduce a la simultaneidad

Si el subproceso A adquiere con éxito el bloqueo y establece el tiempo de vencimiento en 30 segundos, pero el tiempo de ejecución del subproceso A excede los 30 segundos, el bloqueo se liberará automáticamente cuando expire. En este momento, el subproceso B adquiere el bloqueo y el subproceso A y El hilo B se ejecuta simultáneamente.

Obviamente no se permite la concurrencia entre los subprocesos A y B. Generalmente hay dos formas de resolver este problema:

  • Establezca un tiempo de vencimiento lo suficientemente largo para garantizar que la lógica del código se pueda ejecutar antes de que se libere el bloqueo.
  • Agregue un subproceso de demonio para el subproceso que adquiere el bloqueo y agregue un tiempo válido para el bloqueo que está a punto de caducar pero que no se ha liberado. ( Renovación automática )

4. Sin reentrada

Cuando un subproceso solicita un candado nuevamente mientras mantiene el candado, si un candado admite múltiples bloqueos por parte de un subproceso, entonces el candado es reentrante. Si se vuelve a bloquear un candado no reentrante, el nuevo bloqueo fallará porque el candado ya está retenido. Redis puede contar los bloqueos por reentrada, agregando 1 al bloquear, disminuyendo en 1 al desbloquear y liberando el bloqueo cuando el conteo llega a 0.

Registre el número de reingresos localmente. Por ejemplo, use ThreadLocal en Java para contar el número de reingresos. Código de ejemplo simple:

private static ThreadLocal<Map<String, Integer>> LOCKERS = ThreadLocal.withInitial(HashMap::new);
// 加锁
public boolean lock(String key) {
  Map<String, Integer> lockers = LOCKERS.get();
  if (lockers.containsKey(key)) {
    lockers.put(key, lockers.get(key) + 1);
    return true;
  } else {
    if (SET key uuid NX EX 30) {
      lockers.put(key, 1);
      return true;
    }
  }
  return false;
}
// 解锁
public void unlock(String key) {
  Map<String, Integer> lockers = LOCKERS.get();
  if (lockers.getOrDefault(key, 0) <= 1) {
    lockers.remove(key);
    DEL key
  } else {
    lockers.put(key, lockers.get(key) - 1);
  }
}

Aunque es eficaz registrar el número de reingresos localmente, aumentará la complejidad del código si se tienen en cuenta el tiempo de caducidad y los problemas de coherencia local y de Redis. Otra forma es utilizar la estructura de datos de Redis Map para implementar bloqueos distribuidos, que no solo almacena la identificación del bloqueo sino que también cuenta el número de reingresos. Ejemplo de bloqueo de redistribución:

// 如果 lock_key 不存在
if (redis.call('exists', KEYS[1]) == 0)
then
    // 设置 lock_key 线程标识 1 进行加锁
    redis.call('hset', KEYS[1], ARGV[2], 1);
    // 设置过期时间
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
    end;
// 如果 lock_key 存在且线程标识是当前欲加锁的线程标识
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
    // 自增
    then redis.call('hincrby', KEYS[1], ARGV[2], 1);
    // 重置过期时间
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
    end;
// 如果加锁失败,返回锁剩余时间
return redis.call('pttl', KEYS[1]);

5. No se puede esperar a que se libere el bloqueo.

La ejecución del comando anterior regresa inmediatamente. Si el cliente puede esperar a que se libere el bloqueo, no se puede utilizar.

  • Este problema se puede resolver mediante el sondeo del cliente. Cuando no se adquiere el bloqueo, espere un período de tiempo para volver a adquirir el bloqueo hasta que se adquiera con éxito o se agote el tiempo de espera. Este método consume más recursos del servidor y afectará la eficiencia del servidor cuando la cantidad de concurrencia sea grande.
  • Otra forma es utilizar la función de publicación y suscripción de Redis. Cuando falla la adquisición del bloqueo, suscríbase al mensaje de liberación del bloqueo y, cuando el bloqueo se adquiere y libera con éxito, envía el mensaje de liberación del bloqueo. como sigue:

Redis también tiene cerraduras distribuidas como Redlock.

Dado que el uso de este método es controvertido (hay problemas en casos extremos), ¡este artículo no lo presentará por el momento!

grupo

1. Conmutación activo/en espera

Para garantizar la disponibilidad de Redis, generalmente se implementa en modo maestro-esclavo. Hay dos métodos de sincronización de datos maestro-esclavo, asíncrono y sincrónico. Redis registra las instrucciones en el búfer de memoria local y luego sincroniza asincrónicamente las instrucciones en el búfer con el nodo esclavo. El nodo esclavo ejecuta el flujo de instrucciones sincrónico para lograr el mismo estado que el nodo maestro. , mientras devuelve el estado de sincronización al nodo maestro.

En un método de implementación de clúster que incluye el modo maestro-esclavo, cuando el nodo maestro falla, el nodo esclavo ocupará su lugar, pero el cliente no lo notará. Cuando el cliente A se bloquea exitosamente, las instrucciones aún no se han sincronizado. En este momento, el nodo maestro cuelga y el nodo esclavo es promovido al nodo maestro. El nuevo nodo maestro no tiene datos bloqueados. Cuando el cliente B se bloquea, se bloqueará. tener éxito.

2. Cerebro dividido en racimos

El cerebro dividido en clúster significa que debido a problemas de red, el nodo maestro de Redis, el nodo esclavo y el clúster centinela están en diferentes particiones de red. Debido a que el clúster centinela no puede detectar la existencia del maestro, promueve el nodo esclavo al nodo maestro. En este momento, hay dos nodos maestros diferentes. El método de implementación del clúster de Redis Cluster es el mismo.

Cuando diferentes clientes se conectan a diferentes nodos maestros, dos clientes pueden mantener el mismo bloqueo al mismo tiempo. como sigue:

Conclusión

Redis es conocido por su alto rendimiento, pero todavía existen algunas dificultades a la hora de usarlo para implementar bloqueos distribuidos para resolver la concurrencia. Los bloqueos distribuidos de Redis solo se pueden utilizar como un medio para aliviar la concurrencia. Si desea resolver completamente el problema de concurrencia, aún necesita medios anti-concurrencia en la base de datos.

Supongo que te gusta

Origin blog.csdn.net/qq_41221596/article/details/132919906
Recomendado
Clasificación