Bloqueo distribuido ligero basado en Redis

¡Continúe creando, acelere el crecimiento! Este es el primer día de mi participación en el "Nuggets Daily New Plan · June Update Challenge", haz clic para ver los detalles del evento

apertura

En la era de los microservicios, los bloqueos distribuidos son necesarios para proteger los recursos en muchos escenarios comerciales. Necesitamos asegurarnos de que la modificación de una determinada identidad (tal vez un usuario, inquilino, recurso) no provoque casos anormales causados ​​por la concurrencia. Especialmente en escenarios de comercio electrónico, puntos, transferencias, etc., los requisitos para los bloqueos son más altos. Hoy, echemos un vistazo a cómo se deben implementar los bloqueos distribuidos basados ​​en Redis.

ideas

Hablamos antes, si es un programa independiente, en Golang podemos usar sync.Mutex para bloquear y evitar el acceso concurrente en la sección crítica. Análisis del principio Golang Mutex

¿Cómo juzgamos si se obtiene el bloqueo? Recuerdo:


func (m *Mutex) Lock() {
  // Fast path: grab unlocked mutex.
  if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    if race.Enabled {
      race.Acquire(unsafe.Pointer(m))
    }
    return
  }
  // Slow path (outlined so that the fast path can be inlined)
  m.lockSlow()
}
复制代码

Como puede ver, aquí hay un CAS para ver si la rutina actual puede cambiar el estado en la estructura Mutex. Debido a que CAS en sí mismo es la capacidad atómica proporcionada por atomic, vienen múltiples corrutinas, solo una puede tener éxito.

func (m *Mutex) Unlock() {
  if race.Enabled {
    _ = m.state
    race.Release(unsafe.Pointer(m))
  }

  // Fast path: drop lock bit.
  new := atomic.AddInt32(&m.state, -mutexLocked)
  if new != 0 {
    // Outlined slow path to allow inlining the fast path.
    // To hide unlockSlow during tracing we skip one extra frame when tracing GoUnblock.
    m.unlockSlow(new)
  }
}
复制代码

En el proceso de desbloqueo, también ignoramos la ruta lenta. Podemos ver que también continuamos llamando a AddInt32 del paquete atómico y restando mutexLocked del estado.

Para resumir, necesitamos implementar atómicamente, la capacidad de establecer el valor de una clave y restablecer el valor. (Correspondiente a sync.Mutex, la clave es m.state y el valor es mutexLocked)

En un escenario distribuido, hay otro punto a considerar:

En el escenario de una sola máquina, si hay algunas situaciones anormales en la rutina que adquiere el bloqueo, como que el bloqueo no se libera durante mucho tiempo, el impacto se limita a la máquina local y el desarrollador puede depurarlo por sí mismo. . Sin embargo, en un escenario distribuido, el mismo bloqueo será codificado por varias instancias en línea, por lo que, naturalmente, el almacenamiento de este estado de bloqueo no debe estar en el servidor que ejecuta la lógica comercial, y debe ser un servidor de almacenamiento independiente (como una instancia de Redis, ZK o alguna tienda KV con restricciones de consistencia sólida).

Entonces surge el problema: si, como se mencionó anteriormente, una instancia que ya tiene el bloqueo cuelga, por alguna razón, el bloqueo no se libera. ¿Lo que sucederá?

这就会造成,其他实例后续无论怎样请求,由于锁已经被占用,都无法获取。而持有锁的实例已经出现异常,可能很长时间,甚至丢失了上下文,永远无法释放锁。那么针对这个被锁定的资源的后续操作就全部被 block 了,这是生产环境不能接受的。

所以,分布式场景一定要注意容错,锁的释放不能仅仅指望加锁的实例来执行,必须要有 TTL 过期的机制。

实现

利用 redis 实现分布式锁,在业内其实是非常常见的做法,并不完美,但对于大部分业务场景来说是足够的。大家有兴趣也可以搜索一下 zookeeper 实现分布式锁的方案,主要是基于了zk的临时有序节点特性。

这里插一句,其实 redis 支持分布式锁,官方提供过一个专门的算法 redlock,还由此引发了 Martin Kleppmann (DDIA 作者)和 antirez (redis 作者)的辩论,引发了很多讨论。redlock 算法不是本文的重点,大家有兴趣的话可以看看这篇博客:redis.io/docs/refere…

这里我们来看看,基于 redis 的基础特性,我们怎么做出一个基于单实例 redis 实现的分布式锁。

基础实现

单机 redis 锁的原理其实很简单,我们知道 redis 提供了 SetNX 的能力,即如果key不存在则设置,若存在则忽略。搭配上 TTL 即可满足加锁要求

  • 加锁
SET resource_name 1 NX PX 30000
复制代码
  • 解锁
Del resource_name
复制代码

解锁也很简单,直接把 key 删掉就 ok。此后如果还有需要加锁的实例,会直接通过 SetNX 上锁成功。

我们来看下 Golang 实现:

func (l *Lock) Acquire() error {
    retrySleep := 3
    var err error
    for l.RetryTimes > 0 {
            v := l.RedisClient.SetNX(l.Key, 1, l.Timeout)
            err = v.Err()
            if v.Err() == nil && v.Val() {
                    l.acquire = true
                    return nil
            }
            l.RetryTimes--
            time.Sleep(time.Millisecond * time.Duration(retrySleep))
            retrySleep = retrySleep * 2
    }
    if err != nil {
            return err
    }
    return ErrTimeout
}

func (l *Lock) Release() {
    if l.acquire {
            l.RedisClient.Del(l.Key)
    }
    l.acquire = false
}
复制代码

不谈单机性能,只从实用角度看,上面的实现在很多场景下都是 ok 的,如果已经上锁,其他实例再来请求时会无法获取锁,进而 sleep 一段时间后重试,若重试到最后还不成功,就返回【超时错误】。

但对于大型业务来说,这样的实现绝对是不保险的,下一节我们来看看怎么改进。

存在的问题

上一节给出的基础实现,核心问题在于【无法对超时场景兜底】。

比如,锁的 TTL 我们设置为 10秒钟。

时刻1:A 实例获取了锁,正常处理请求的时延 pct99 为 500ms; 时刻2:B 实例尝试获取锁失败,持续 sleep 一段时间后重试; 时刻3:问题出现,A 实例由于某种原因,一些业务流程卡住了,阻塞超过10秒,锁超时,A 还在执行业务流程; 时刻4:B 实例获取到了锁,开始执行业务流程; 时刻5:A 实例执行结束,走 defer 流程 Del,本意是释放自己持有的锁,但实际上自己的锁早已超时过期,此时释放了 B 的锁; 时刻6:由于A的误删,C 实例获取到锁, 和正在执行的 B 实例出现并发,锁失效了。

这里问题的根源在于,锁的解除有两种情况:

  1. 锁被显式地释放;
  2. 锁的 TTL 到了,过期。

我们希望,如果 2 发生了,1 就不能再执行了。

但上面的方案里,释放锁的时候,只是简单的一个 Del,任何一个持有者都可以去释放,这是有很大隐患的,我们怎么知道释放的是否是自己的锁呢?毕竟一个超时的实例是不会知道自己是否超时的。写死时间判断并不是一个好方法。

ok,那我们希望,任何一个锁持有者,只能删除自己加的锁,如果锁过期了,直接返回就行。怎么实现呢?

答案很简单,我们需要保证,加锁的值,只能加锁的实例知道。随后解锁时,需要校验,是不是当初自己持有的锁(也是通过校验加锁值来做到)。

(有些同学可能会觉得,那我随便搞个随机数,比如时间戳,还保持 Del 不行么?这里也是不 ok 的,因为如果你不校验加锁值,不管你用了什么随机数,最后一个 Del 上去就把锁释放了,没有做到【加解锁的绑定】)

当然,像我们上面这样直接用固定值是最不安全的,即便你做了加锁值比对,因为本身是固定值,两个并发实例的加锁值还是相同,绑定就没有意义了。

简言之,两点需要做到:

  1. 加锁值在所有客户端和所有锁定请求中必须是唯一的,不能用可能重复,甚至固定值来加锁,随机性要有保障;
  2. 解锁时校验当前是否和加锁值匹配,只有匹配才能解锁(意味着你解的是自己当时上的锁)。

What should this random string be? We assume it’s 20 bytes from /dev/urandom, but you can find cheaper ways to make it unique enough for your tasks. For example a safe pick is to seed RC4 with /dev/urandom, and generate a pseudo random stream from that. A simpler solution is to use a UNIX timestamp with microsecond precision, concatenating the timestamp with a client ID. It is not as safe, but probably sufficient for most environments.

随机性其实本身有比较大的灵活度,大家可以根据自身业务场景选择即可。参照上面,redis 官方推荐的一个轻量级随机数的方法是:使用具有微秒精度的 UNIX 时间戳,将时间戳与客户端 ID 绑定起来,组成一个随机数。

优化后解法

  • 加锁
SET resource_name my_random_value NX PX 30000
复制代码
  • 解锁
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
复制代码

此处使用 lua 脚本来解锁是比较常见的,因为我们的诉求是将以下操作包装成一个原子操作:

  1. 读取锁对应的 key 的当前值;
  2. 比对当前值和加锁值是否一致;
  3. 若一致,删掉锁对应的 key,若不一致,则直接返回,不能删。

简单说,本质上我们是需要一个 Compare And Delete 原子操作。

限制

注意,上面我们提供的解法,基于的前提是【单机redis】,其实这个前提是忽略了分布式场景下一些case的。

通常情况下,当我们的业务server都已经是分布式了,很难还强依赖一个单机redis做锁,容量和性能都是瓶颈。

Si se trata de una versión de clúster de redis para admitir bloqueos distribuidos, tendremos que enfrentar excepciones como el cambio maestro-esclavo, demoras y otros problemas. Debe juzgarse de acuerdo con la implementación y el uso específicos de redis.

De hecho, la implementación oficial de redlock también es para tratar el problema de cómo hacer bloqueos distribuidos en redis implementados en clústeres. Se recomienda echar un vistazo a la documentación de redlock cuando tenga tiempo para aprender: redis.io/ documentos/referir…

Supongo que te gusta

Origin juejin.im/post/7102339194252427300
Recomendado
Clasificación