问题发现
在工作中,运维对于安装这个服务存在阻碍,需要安装redis集群模式。我对此产生了疑问?
这个服务为什么需要使用到redis集群? 这个服务一直都是单例程序,有状态 信息无法横向扩展,这里就有个冲突点:使用了redis集群,但状态信息存在内存中而影响到无法横向扩展。 而进行代码review后,发现redis集群的用途只是为了加分布式锁,但是!!程序又不是分布式的。
释怀:优秀的设计往往让人摸不着头脑。
那么就有人问了,这跟主题有什么关系? 这个代码的分布式锁写的有大问题。
代码分析
这里将程序中的代码思想复现一波:
go
c, err := redis.Dial("tcp", ":6379", redis.DialPassword("123456"))
if err != nil {
panic(err)
}
defer c.Close()
timeoutMs := 60 * 1000 // 60s
// 执行SetNX 命令, 如果key存在 isset为0, key不存在 isset 为1
isset, err := redis.Int(c.Do("SETNX", key, value))
if err != nil {
fmt.Errorf("TryLock SETNX failed, err[%s]", err)
return false, err
}
// 如果插入成功
if isset == 1 {
if timeoutMs > 0 {
// 设置超时时间
c.Do("PEXPIRE", key, timeoutMs)
}
return true, nil
}
// 如果key存在, 超时时间存在
if timeoutMs > 0 {
// if key exists but ttl not set, reset ttl
if ttl, err := redis.Int64(c.Do("PTTL", key)); err == nil && ttl == -1 {
c.Do("PEXPIRE", key, timeoutMs)
}
}
return false, nil
粗心的读者能够发现,这个代码问题太多了,主要问题:
- 锁非原子操作,在高并发场景下,如果SetNX过程中程序挂掉了,那么这个key也就是永久有效。
- 为了解决#1的错误,让所有对这个key操作的进程对其进行续期操作。也就是说,你没加上TTL,我好心给你加上。注意是key存在,但是没有TTL的时候才会有效。
如果出现这个场景,也就意味着1分钟内,抢占进程1分钟内无法进行正确的执行。那么这个问题,目前还没有暴雷的原因:
- 当前这个程序从分布式进程的设计,被改成单进程模式,根本没有其他进程回去抢占,只有协程进行抢占,而同一时间内,不会超过10个左右的协程,并发度不高。
- 当前业务形态决定,由于该程序为后台程序, 能够允许1分钟以上的容错时间。
- redis采用集群的模式,提高了容错性。
精彩,到目前为止,我竟然觉得这个代码没有一点问题了。
(又不是又能用.罗老师.jpg)
分布式锁
锁(Lock):是一种用于同步并发访问共享资源的 机制。它通过确保在同一时刻只有一个线程或进程能够访问特定资源,从而避免数据竞争和不一致性 。锁通常用于多线程编程和分布式系统中。
锁的底层逻辑:Key的设置/删除。
分布式锁应用在分布式系统中,多进程并发访问共享资源的情况下,通常情况下,使用第三方中间件存储锁对象,如:zookeeper,redis,etcd等。
在编写分布式锁时,一般采取以下几个步骤:
-
加锁:
- 原子性操作:设置key,value,其中value为客户端唯一标识符,并同步设置超时时间;
- 续期操作。
- redis命令: SET KEY VALUE NX [EX (second) | PX (milliseconds)]
-
释放锁:
- 判断锁的值是否正确;
- 删除Key。
加锁
加锁分为:获得锁 和 没有获得锁。
没有获取锁的情况
根据业务的情况,可采用的方案:
-
阻塞直到获取到锁。
-
非阻塞锁:
- 直接跳过后续流程返回;
- 监听锁事件,释放后重新尝试获取;
获得锁的情况
为什么需要设置超时时间?
举例如下:
如果,在图中processA获取锁成功后,执行后续流程失败,没有正常释放锁。那么processB以及其他进程将会一直无法执行。所以processA需要对锁进行超时时间设置 。
那么问题来,设置时间多长?
简单的话,根据业务需求,可以通过业务监控来进行设置,如最长业务处理时间*2。
如果对于处理时间未知的情况,就需要另一个手段:续约 ,在执行过程中,TTL 快过期时候进行重置过期时间操作。
释放锁
释放锁不就很简单把删除就行了吗? NoNoNo, 设想一下,在程序执行过程中,出现异常情况,processA执行过程中,processB 也获取到了锁,processB执行的非常快,把processA的锁也释放了咋办。
所以在设置key的时候,需要给定value的唯一标识。释放锁的时候判断现在的锁是不是自己之前加的。
当然,这种情况如果中间件为集群的情况下,出现的话过于极端的。
以上都是redis单实例的情况下,如果是redis主从同步的情况下,考虑的可能更加多了,总而言之不够可靠。
其他方案
etcd分布式锁,etcd使用raft共识算法进行数据同步,大多数情况下,etcd的安装都是3个节点以上,可用性较高,并且原生提供了事务,租约(lease),watch等机制。
使用可以参考:github.com/etcd-io/etc...
如果程序只是想作为高可用,在k8s中可以直接使用client-go,进行leader election操作,比如 kube-scheduler, kube-controller,起了多份的情况下,只有一个节点会运行,可以使用:
sql
kubectl get leases -n kube-system
总结
分布式锁用好的话,知识点还是比较多的,毕竟引入了第三方组件进行控制,一定要做好故障分析。
以上。
感兴趣关注:小唐云原生