多应用+插件架构,代码干净,二开方便,首家独创一键云编译技术,文档视频完善,免费商用码云13.8K 广告
## 一、Golang中除了加Mutex锁以外还有哪些方式安全读写共享变量? Golang中Goroutine 可以通过 Channel 进行安全读写共享变量。 ## 二、#### 无缓冲 Chan 的发送和接收是否同步? ~~~go ch := make(chan int) 无缓冲的channel由于没有缓冲发送和接收需要同步. ch := make(chan int, 2) 有缓冲channel不要求发送和接收操作同步. ~~~ * channel无缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据。 * channel有缓冲时,当缓冲满时发送阻塞,当缓冲空时接收阻塞。 ## 三、 JSON 标准库对 nil slice 和 空 slice 的处理是一致的吗? 首先JSON 标准库对 nil slice 和 空 slice 的处理是不一致. 通常错误的用法,会报数组越界的错误,因为只是声明了slice,却没有给实例化的对象。 ~~~go var slice []int slice[1] = 0 ~~~ 此时slice的值是nil,这种情况可以用于需要返回slice的函数,当函数出现异常的时候,保证函数依然会有nil的返回值。 empty slice 是指slice不为nil,但是slice没有值,slice的底层的空间是空的,此时的定义如下: ~~~go slice := make([]int,0) slice := []int{} ~~~ 当我们查询或者处理一个空的列表的时候,这非常有用,它会告诉我们返回的是一个列表,但是列表内没有任何值。 总之,nil slice 和 empty slice是不同的东西,需要我们加以区分的. ## 四、互斥锁,读写锁,死锁问题是怎么解决 * 互斥锁 互斥锁就是互斥变量mutex,用来锁住临界区的. 条件锁就是条件变量,当进程的某些资源要求不满足时就进入休眠,也就是锁住了。当资源被分配到了,条件锁打开,进程继续运行;读写锁,也类似,用于缓冲区等临界资源能互斥访问的。 * 读写锁 通常有些公共数据修改的机会很少,但其读的机会很多。并且在读的过程中会伴随着查找,给这种代码加锁会降低我们的程序效率。读写锁可以解决这个问题。 [![](https://github.com/KeKe-Li/golang-interview-questions/raw/master/src/images/61.jpg)](https://github.com/KeKe-Li/golang-interview-questions/blob/master/src/images/61.jpg) 注意:写独占,读共享,写锁优先级高 * 死锁 一般情况下,如果同一个线程先后两次调用lock,在第二次调用时,由于锁已经被占用,该线程会挂起等待别的线程释放锁,然而锁正是被自己占用着的,该线程又被挂起而没有机会释放锁,因此就永远处于挂起等待状态了,这叫做死锁(Deadlock)。 另外一种情况是:若线程A获得了锁1,线程B获得了锁2,这时线程A调用lock试图获得锁2,结果是需要挂起等待线程B释放锁2,而这时线程B也调用lock试图获得锁1,结果是需要挂起等待线程A释放锁1,于是线程A和B都永远处于挂起状态了。 死锁产生的四个必要条件: 1. 互斥条件:一个资源每次只能被一个进程使用 2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。 3. 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。 4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。 这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。 a. 预防死锁 可以把资源一次性分配:(破坏请求和保持条件) 然后剥夺资源:即当某进程新的资源未满足时,释放已占有的资源(破坏不可剥夺条件) 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件) b. 避免死锁 预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得 较满意的系统性能。由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全状态,则将资源分配给进程;否则,进程等待。其中最具有代表性的避免死锁算法是银行家算法。 c. 检测死锁 首先为每个进程和每个资源指定一个唯一的号码,然后建立资源分配表和进程等待表. d. 解除死锁 当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有. e. 剥夺资源 从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态. f. 撤消进程 可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态.消除为止.所谓代价是指优先级、运行代价、进程的重要性和价值等。 ## 五、Data Race问题怎么解决?能不能不加锁解决这个问题? 同步访问共享数据是处理数据竞争的一种有效的方法.golang在1.1之后引入了竞争检测机制,可以使用 go run -race 或者 go build -race来进行静态检测。 其在内部的实现是,开启多个协程执行同一个命令, 并且记录下每个变量的状态. 竞争检测器基于C/C++的ThreadSanitizer 运行时库,该库在Google内部代码基地和Chromium找到许多错误。这个技术在2012年九月集成到Go中,从那时开始,它已经在标准库中检测到42个竞争条件。现在,它已经是我们持续构建过程的一部分,当竞争条件出现时,它会继续捕捉到这些错误。 竞争检测器已经完全集成到Go工具链中,仅仅添加-race标志到命令行就使用了检测器。 ~~~go $ go test -race mypkg // 测试包 $ go run -race mysrc.go // 编译和运行程序 $ go build -race mycmd // 构建程序 $ go install -race mypkg // 安装程序 ~~~ 要想解决数据竞争的问题可以使用互斥锁sync.Mutex,解决数据竞争(Data race),也可以使用管道解决,使用管道的效率要比互斥锁高. ## 六、什么是channel,为什么它可以做到线程安全? Channel是Go中的一个核心类型,可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯(communication),Channel也可以理解是一个先进先出的队列,通过管道进行通信。 Golang的Channel,发送一个数据到Channel 和 从Channel接收一个数据 都是 原子性的。而且Go的设计思想就是:不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。也就是说,设计Channel的主要目的就是在多任务间传递数据的,这当然是安全的。 ## 七、Golang GC 时会发生什么? 首先我们先来了解下垃圾回收.什么是垃圾回收? 内存管理是程序员开发应用的一大难题。传统的系统级编程语言(主要指C/C++)中,程序开发者必须对内存小心的进行管理操作,控制内存的申请及释放。因为稍有不慎,就可能产生内存泄露问题,这种问题不易发现并且难以定位,一直成为困扰程序开发者的噩梦。如何解决这个头疼的问题呢? 过去一般采用两种办法: * 内存泄露检测工具。这种工具的原理一般是静态代码扫描,通过扫描程序检测可能出现内存泄露的代码段。然而检测工具难免有疏漏和不足,只能起到辅助作用。 * 智能指针。这是 c++ 中引入的自动内存管理方法,通过拥有自动内存管理功能的指针对象来引用对象,是程序员不用太关注内存的释放,而达到内存自动释放的目的。这种方法是采用最广泛的做法,但是对程序开发者有一定的学习成本(并非语言层面的原生支持),而且一旦有忘记使用的场景依然无法避免内存泄露。 为了解决这个问题,后来开发出来的几乎所有新语言(java,python,php等等)都引入了语言层面的自动内存管理 – 也就是语言的使用者只用关注内存的申请而不必关心内存的释放,内存释放由虚拟机(virtual machine)或运行时(runtime)来自动进行管理。而这种对不再使用的内存资源进行自动回收的行为就被称为垃圾回收。 常用的垃圾回收的方法: * 引用计数(reference counting) 这是最简单的一种垃圾回收算法,和之前提到的智能指针异曲同工。对每个对象维护一个引用计数,当引用该对象的对象被销毁或更新时被引用对象的引用计数自动减一,当被引用对象被创建或被赋值给其他对象时引用计数自动加一。当引用计数为0时则立即回收对象。 这种方法的优点是实现简单,并且内存的回收很及时。这种算法在内存比较紧张和实时性比较高的系统中使用的比较广泛,如ios cocoa[框架](https://so.csdn.net/so/search?q=%E6%A1%86%E6%9E%B6&spm=1001.2101.3001.7020),php,python等。 但是简单引用计数算法也有明显的缺点: 1. 频繁更新引用计数降低了性能。 一种简单的解决方法就是编译器将相邻的引用计数更新操作合并到一次更新;还有一种方法是针对频繁发生的临时变量引用不进行计数,而是在引用达到0时通过扫描堆栈确认是否还有临时对象引用而决定是否释放。等等还有很多其他方法,具体可以参考这里。 2. 循环引用。 当对象间发生循环引用时引用链中的对象都无法得到释放。最明显的解决办法是避免产生循环引用,如cocoa引入了strong指针和weak指针两种指针类型。或者系统检测循环引用并主动打破循环链。当然这也增加了垃圾回收的复杂度。 * 标记-清除(mark and sweep) 标记-清除(mark and sweep)分为两步,标记从根变量开始迭代得遍历所有被引用的对象,对能够通过应用遍历访问到的对象都进行标记为“被引用”;标记完成后进行清除操作,对没有标记过的内存进行回收(回收同时可能伴有碎片整理操作)。这种方法解决了引用计数的不足,但是也有比较明显的问题:每次启动垃圾回收都会暂停当前所有的正常代码执行,回收是系统响应能力大大降低!当然后续也出现了很多mark&sweep算法的变种(如三色标记法)优化了这个问题。 * 分代搜集(generation) java的jvm 就使用的分代回收的思路。在面向对象编程语言中,绝大多数对象的生命周期都非常短。分代收集的基本思想是,将堆划分为两个或多个称为代(generation)的空间。新创建的对象存放在称为新生代(young generation)中(一般来说,新生代的大小会比 老年代小很多),随着垃圾回收的重复执行,生命周期较长的对象会被提升(promotion)到老年代中(这里用到了一个分类的思路,这个是也是科学思考的一个基本思路)。 因此,新生代垃圾回收和老年代垃圾回收两种不同的垃圾回收方式应运而生,分别用于对各自空间中的对象执行垃圾回收。新生代垃圾回收的速度非常快,比老年代快几个数量级,即使新生代垃圾回收的频率更高,执行效率也仍然比老年代垃圾回收强,这是因为大多数对象的生命周期都很短,根本无需提升到老年代。 Golang GC 时会发生什么? Golang 1.5后,采取的是“非分代的、非移动的、并发的、三色的”标记清除垃圾回收算法。 golang 中的 gc 基本上是标记清除的过程: [![](https://github.com/KeKe-Li/For-learning-Go-Tutorial/raw/master/src/images/2.jpg)](https://github.com/KeKe-Li/For-learning-Go-Tutorial/blob/master/src/images/2.jpg) gc的过程一共分为四个阶段: 1. 栈扫描(开始时STW) 2. 第一次标记(并发) 3. 第二次标记(STW) 4. 清除(并发) 整个进程空间里申请每个对象占据的内存可以视为一个图,初始状态下每个内存对象都是白色标记。 1. 先STW,做一些准备工作,比如 enable write barrier。然后取消STW,将扫描任务作为多个并发的goroutine立即入队给调度器,进而被CPU处理 2. 第一轮先扫描root对象,包括全局指针和 goroutine 栈上的指针,标记为灰色放入队列 3. 第二轮将第一步队列中的对象引用的对象置为灰色加入队列,一个对象引用的所有对象都置灰并加入队列后,这个对象才能置为黑色并从队列之中取出。循环往复,最后队列为空时,整个图剩下的白色内存空间即不可到达的对象,即没有被引用的对象; 4. 第三轮再次STW,将第二轮过程中新增对象申请的内存进行标记(灰色),这里使用了write barrier(写屏障)去记录 Golang gc 优化的核心就是尽量使得 STW(Stop The World) 的时间越来越短。 详细的Golang的GC介绍可以参看[Golang垃圾回收](https://github.com/KeKe-Li/For-learning-Go-Tutorial/blob/master/src/spec/02.0.md). ## 七、Golang 中 Goroutine 如何调度? goroutine是Golang语言中最经典的设计,也是其魅力所在,goroutine的本质是协程,是实现并行计算的核心。 goroutine使用方式非常的简单,只需使用go关键字即可启动一个协程,并且它是处于异步方式运行,你不需要等它运行完成以后在执行以后的代码。 ~~~go go func()//通过go关键字启动一个协程来运行函数 ~~~ 协程: 协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。 因此,协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。 线程和进程的操作是由程序触发系统接口,最后的执行者是系统;协程的操作执行者则是用户自身程序,goroutine也是协程。 groutine能拥有强大的并发实现是通过GPM调度模型实现. [![](https://github.com/KeKe-Li/golang-interview-questions/raw/master/src/images/59.jpg)](https://github.com/KeKe-Li/golang-interview-questions/blob/master/src/images/59.jpg) Go的调度器内部有四个重要的结构:M,P,S,Sched,如上图所示(Sched未给出). * M:M代表内核级线程,一个M就是一个线程,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息 * G:代表一个goroutine,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度。 * P:P全称是Processor,处理器,它的主要用途就是用来执行goroutine的,所以它也维护了一个goroutine队列,里面存储了所有需要它来执行的goroutine * Sched:代表调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。 调度实现: [![](https://github.com/KeKe-Li/golang-interview-questions/raw/master/src/images/65.jpg)](https://github.com/KeKe-Li/golang-interview-questions/blob/master/src/images/65.jpg) 从上图中可以看到,有2个物理线程M,每一个M都拥有一个处理器P,每一个也都有一个正在运行的goroutine。P的数量可以通过GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运行。 图中灰色的那些goroutine并没有运行,而是出于ready的就绪态,正在等待被调度。P维护着这个队列(称之为runqueue),Go语言里,启动一个goroutine很容易:go function 就行,所以每有一个go语句被执行,runqueue队列就在其末尾加入一个goroutine,在下一个调度点,就从runqueue中取出(如何决定取哪个goroutine?)一个goroutine执行。 当一个OS线程M0陷入阻塞时,P转而在运行M1,图中的M1可能是正被创建,或者从线程缓存中取出。 [![](https://github.com/KeKe-Li/golang-interview-questions/raw/master/src/images/60.jpg)](https://github.com/KeKe-Li/golang-interview-questions/blob/master/src/images/60.jpg) 当MO返回时,它必须尝试取得一个P来运行goroutine,一般情况下,它会从其他的OS线程那里拿一个P过来, 如果没有拿到的话,它就把goroutine放在一个global runqueue里,然后自己睡眠(放入线程缓存里)。所有的P也会周期性的检查global runqueue并运行其中的goroutine,否则global runqueue上的goroutine永远无法执行。 另一种情况是P所分配的任务G很快就执行完了(分配不均),这就导致了这个处理器P很忙,但是其他的P还有任务,此时如果global runqueue没有任务G了,那么P不得不从其他的P里拿一些G来执行。 [![](https://github.com/KeKe-Li/golang-interview-questions/raw/master/src/images/64.jpg)](https://github.com/KeKe-Li/golang-interview-questions/blob/master/src/images/64.jpg) 通常来说,如果P从其他的P那里要拿任务的话,一般就拿run queue的一半,这就确保了每个OS线程都能充分的使用。 ## 八、并发编程概念是什么? 并行是指两个或者多个事件在同一时刻发生;并发是指两个或多个事件在同一时间间隔发生。 并行是在不同实体上的多个事件,并发是在同一实体上的多个事件。在一台处理器上“同时”处理多个任务,在多台处理器上同时处理多个任务。如hadoop分布式集群 并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行是真正意义上的“同时执行”。 并发编程是指在一台处理器上“同时”处理多个任务。并发是在同一实体上的多个事件。多个事件在同一时间间隔发生。并发编程的目标是充分的利用处理器的每一个核,以达到最高的处理性能。 ## 九、微服务架构是什么样子的? 通常传统的项目体积庞大,需求、设计、开发、测试、部署流程固定。新功能需要在原项目上做修改。 但是微服务可以看做是对大项目的拆分,是在快速迭代更新上线的需求下产生的。新的功能模块会发布成新的服务组件,与其他已发布的服务组件一同协作。 服务内部有多个生产者和消费者,通常以http rest的方式调用,服务总体以一个(或几个)服务的形式呈现给客户使用。 微服务架构是一种思想对微服务架构我们没有一个明确的定义,但简单来说微服务架构是: 采用一组服务的方式来构建一个应用,服务独立部署在不同的进程中,不同服务通过一些轻量级交互机制来通信,例如 RPC、HTTP 等,服务可独立扩展伸缩,每个服务定义了明确的边界,不同的服务甚至可以采用不同的编程语言来实现,由独立的团队来维护。 Golang的微服务框架[kit](https://gokit.io/)中有详细的微服务的例子,可以参考学习. 微服务架构设计包括: 1. 服务熔断降级限流机制 熔断降级的概念(Rate Limiter 限流器,Circuit breaker 断路器). 2. 框架调用方式解耦方式 Kit 或 Istio 或 Micro 服务发现(consul zookeeper kubeneters etcd ) RPC调用框架. 3. 链路监控,zipkin和prometheus. 4. 多级缓存. 5. 网关 (kong gateway). 6. Docker部署管理 Kubenetters. 7. 自动集成部署 CI/CD 实践. 8. 自动扩容机制规则. 9. 压测 优化. 10. Trasport 数据传输(序列化和反序列化). 11. Logging 日志. 12. Metrics 指针对每个请求信息的仪表盘化. 微服务架构介绍详细的可以参考: * [Microservice Architectures](http://www.pst.ifi.lmu.de/Lehre/wise-14-15/mse/microservice-architectures.pdf) ## 十、分布式锁实现原理,用过吗? 在分析分布式锁的三种实现方式之前,先了解一下分布式锁应该具备哪些条件: 1. 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行; 2. 高可用的获取锁与释放锁; 3. 高性能的获取锁与释放锁; 4. 具备可重入特性; 5. 具备锁失效机制,防止死锁; 6. 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。 分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。 通常分布式锁以单独的服务方式实现,目前比较常用的分布式锁实现有三种: * 基于数据库实现分布式锁。 * 基于缓存(redis,memcached,tair)实现分布式锁。 * 基于Zookeeper实现分布式锁。 尽管有这三种方案,但是不同的业务也要根据自己的情况进行选型,他们之间没有最好只有更适合! * 基于数据库的实现方式 基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。 创建一个表: ~~~sql DROP TABLE IF EXISTS `method_lock`; CREATE TABLE `method_lock` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', `method_name` varchar(64) NOT NULL COMMENT '锁定的方法名', `desc` varchar(255) NOT NULL COMMENT '备注信息', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法'; ~~~ 想要执行某个方法,就使用这个方法名向表中插入数据: ~~~sql INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName'); ~~~ 因为我们对method\_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。 成功插入则获取锁,执行完成后删除对应的行数据释放锁: ~~~sql delete from method_lock where method_name ='methodName'; ~~~ 注意:这里只是使用基于数据库的一种方法,使用数据库实现分布式锁还有很多其他的用法可以实现! 使用基于数据库的这种实现方式很简单,但是对于分布式锁应该具备的条件来说,它有一些问题需要解决及优化: 1、因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换; 2、不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁; 3、没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据; 4、不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。 5、在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。 * 基于Redis的实现方式 选用Redis实现分布式锁原因: 1. Redis有很高的性能; 2. Redis命令对此支持较好,实现起来比较方便 主要实现方式: 1. SET lock currentTime+expireTime EX 600 NX,使用set设置lock值,并设置过期时间为600秒,如果成功,则获取锁; 2. 获取锁后,如果该节点掉线,则到过期时间ock值自动失效; 3. 释放锁时,使用del删除lock键值; 使用redis单机来做分布式锁服务,可能会出现单点问题,导致服务可用性差,因此在服务稳定性要求高的场合,官方建议使用redis集群(例如5台,成功请求锁超过3台就认为获取锁),来实现redis分布式锁。详见RedLock。 优点:性能高,redis可持久化,也能保证数据不易丢失,redis集群方式提高稳定性。 缺点:使用redis主从切换时可能丢失部分数据。 * 基于ZooKeeper的实现方式 ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。基于ZooKeeper实现分布式锁的步骤如下: 1. 创建一个目录mylock; 2. 线程A想获取锁就在mylock目录下创建临时顺序节点; 3. 获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁; 4. 线程B获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点; 5. 线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。 这里推荐一个Apache的开源库Curator,它是一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁。 优点:具备高可用、可重入、阻塞锁特性,可解决失效死锁问题。 缺点:因为需要频繁的创建和删除节点,性能上不如Redis方式。 上面的三种实现方式,没有在所有场合都是完美的,所以,应根据不同的应用场景选择最适合的实现方式。 在分布式环境中,对资源进行上锁有时候是很重要的,比如抢购某一资源,这时候使用分布式锁就可以很好地控制资源。 ## 十一、#### Etcd怎么实现分布式锁? 首先思考下Etcd是什么?可能很多人第一反应可能是一个键值存储仓库,却没有重视官方定义的后半句,用于配置共享和服务发现。 ~~~gfm A highly-available key value store for shared configuration and service discovery. ~~~ 实际上,etcd 作为一个受到 ZooKeeper 与 doozer 启发而催生的项目,除了拥有与之类似的功能外,更专注于以下四点。 * 简单:基于 HTTP+JSON 的 API 让你用 curl 就可以轻松使用。 * 安全:可选 SSL 客户认证机制。 * 快速:每个实例每秒支持一千次写操作。 * 可信:使用 Raft 算法充分实现了分布式。 但是这里我们主要讲述Etcd如何实现分布式锁? 因为 Etcd 使用 Raft 算法保持了数据的强一致性,某次操作存储到集群中的值必然是全局一致的,所以很容易实现分布式锁。锁服务有两种使用方式,一是保持独占,二是控制时序。 * 保持独占即所有获取锁的用户最终只有一个可以得到。etcd 为此提供了一套实现分布式锁原子操作 CAS(CompareAndSwap)的 API。通过设置prevExist值,可以保证在多个节点同时去创建某个目录时,只有一个成功。而创建成功的用户就可以认为是获得了锁。 * 控制时序,即所有想要获得锁的用户都会被安排执行,但是获得锁的顺序也是全局唯一的,同时决定了执行顺序。etcd 为此也提供了一套 API(自动创建有序键),对一个目录建值时指定为POST动作,这样 etcd 会自动在目录下生成一个当前最大的值为键,存储这个新的值(客户端编号)。同时还可以使用 API 按顺序列出所有当前目录下的键值。此时这些键的值就是客户端的时序,而这些键中存储的值可以是代表客户端的编号。 在这里Ectd实现分布式锁基本实现原理为: 1. 在ectd系统里创建一个key 2. 如果创建失败,key存在,则监听该key的变化事件,直到该key被删除,回到1 3. 如果创建成功,则认为我获得了锁