This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,491 @@
<audio id="audio" title="19 | 在分布式环境中Leader选举、互斥锁和读写锁该如何实现" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/ca/4d1621ca63edb968d07a78681262e2ca.mp3"></audio>
你好,我是鸟窝。
在前面的课程里,我们学习的并发原语都是在进程内使用的,也就是我们常见的一个运行程序为了控制共享资源、实现任务编排和进行消息传递而提供的控制类型。在接下来的这两节课里,我要讲的是几个分布式的并发原语,它们控制的资源或编排的任务分布在不同进程、不同机器上。
分布式的并发原语实现更加复杂,因为在分布式环境中,网络状况、服务状态都是不可控的。不过还好有相应的软件系统去做这些事情。这些软件系统会专门去处理这些节点之间的协调和异常情况,并且保证数据的一致性。我们要做的就是在它们的基础上实现我们的业务。
常用来做协调工作的软件系统是Zookeeper、etcd、Consul之类的软件Zookeeper为Java生态群提供了丰富的分布式并发原语通过Curator库但是缺少Go相关的并发原语库。Consul在提供分布式并发原语这件事儿上不是很积极而etcd就提供了非常好的分布式并发原语比如分布式互斥锁、分布式读写锁、Leader选举等等。所以今天我就以etcd为基础给你介绍几种分布式并发原语。
既然我们依赖etcd那么在生产环境中要有一个etcd集群而且应该保证这个etcd集群是7*24工作的。在学习过程中你可以使用一个etcd节点进行测试。
这节课我要介绍的就是Leader选举、互斥锁和读写锁。
# Leader选举
Leader选举常常用在主从架构的系统中。主从架构中的服务节点分为主Leader、Master和从Follower、Slave两种角色实际节点包括1主n从一共是n+1个节点。
主节点常常执行写操作,从节点常常执行读操作,如果读写都在主节点,从节点只是提供一个备份功能的话,那么,主从架构就会退化成主备模式架构。
主从架构中最重要的是如何确定节点的角色,也就是,到底哪个节点是主,哪个节点是从?
**在同一时刻系统中不能有两个主节点否则如果两个节点都是主都执行写操作的话就有可能出现数据不一致的情况所以我们需要一个选主机制选择一个节点作为主节点这个过程就是Leader选举**
当主节点宕机或者是不可用时,就需要新一轮的选举,从其它的从节点中选择出一个节点,让它作为新主节点,宕机的原主节点恢复后,可以变为从节点,或者被摘掉。
我们可以通过etcd基础服务来实现leader选举。具体点说我们可以将Leader选举的逻辑交给etcd基础服务这样我们只需要把重心放在业务开发上。etcd基础服务可以通过多节点的方式保证7*24服务所以我们也不用担心Leader选举不可用的问题。如下图所示
<img src="https://static001.geekbang.org/resource/image/78/47/78010df8677171d9bf29c64d346d9647.jpg" alt="">
接下来我会给你介绍业务开发中跟Leader选举相关的选举、查询、Leader变动监控等功能。
我要先提醒你一句如果你想运行我下面讲到的测试代码就要先部署一个etcd的集群或者部署一个etcd节点做测试。
首先,我们来实现一个测试分布式程序的框架:它会先从命令行中读取命令,然后再执行相应的命令。你可以打开两个窗口,模拟不同的节点,分别执行不同的命令。
这个测试程序如下:
```
package main
// 导入所需的库
import (
&quot;bufio&quot;
&quot;context&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;os&quot;
&quot;strconv&quot;
&quot;strings&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
&quot;github.com/coreos/etcd/clientv3/concurrency&quot;
)
// 可以设置一些参数比如节点ID
var (
nodeID = flag.Int(&quot;id&quot;, 0, &quot;node ID&quot;)
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
electName = flag.String(&quot;name&quot;, &quot;my-test-elect&quot;, &quot;election name&quot;)
)
func main() {
flag.Parse()
// 将etcd的地址解析成slice of string
endpoints := strings.Split(*addr, &quot;,&quot;)
// 生成一个etcd的clien
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建session,如果程序宕机导致session断掉etcd能检测到
session, err := concurrency.NewSession(cli)
defer session.Close()
// 生成一个选举对象。下面主要使用它进行选举和查询等操作
// 另一个方法ResumeElection可以使用既有的leader初始化Election
e1 := concurrency.NewElection(session, *electName)
// 从命令行读取命令
consolescanner := bufio.NewScanner(os.Stdin)
for consolescanner.Scan() {
action := consolescanner.Text()
switch action {
case &quot;elect&quot;: // 选举命令
go elect(e1, *electName)
case &quot;proclaim&quot;: // 只更新leader的value
proclaim(e1, *electName)
case &quot;resign&quot;: // 辞去leader,重新选举
resign(e1, *electName)
case &quot;watch&quot;: // 监控leader的变动
go watch(e1, *electName)
case &quot;query&quot;: // 查询当前的leader
query(e1, *electName)
case &quot;rev&quot;:
rev(e1, *electName)
default:
fmt.Println(&quot;unknown action&quot;)
}
}
}
```
部署完以后,我们就可以开始选举了。
## 选举
如果你的业务集群还没有主节点,或者主节点宕机了,你就需要发起新一轮的选主操作,主要会用到**Campaign和Proclaim**。如果你需要主节点放弃主的角色,让其它从节点有机会成为主节点,就可以调用**Resign**方法。
这里我提到了三个和选主相关的方法,下面我来介绍下它们的用法。
**第一个方法是Campaign**。它的作用是,把一个节点选举为主节点,并且会设置一个值。它的签名如下所示:
```
func (e *Election) Campaign(ctx context.Context, val string) error
```
需要注意的是,这是一个阻塞方法,在调用它的时候会被阻塞,直到满足下面的三个条件之一,才会取消阻塞。
1. 成功当选为主;
1. 此方法返回错误;
1. ctx被取消。
**第二个方法是Proclaim**。它的作用是重新设置Leader的值但是不会重新选主这个方法会返回新值设置成功或者失败的信息。方法签名如下所示
```
func (e *Election) Proclaim(ctx context.Context, val string) error
```
**第三个方法是Resign**:开始新一次选举。这个方法会返回新的选举成功或者失败的信息。它的签名如下所示:
```
func (e *Election) Resign(ctx context.Context) (err error)
```
这三个方法的测试代码如下。你可以使用测试程序进行测试,具体做法是,启动两个节点,执行和这三个方法相关的命令。
```
var count int
// 选主
func elect(e1 *concurrency.Election, electName string) {
log.Println(&quot;acampaigning for ID:&quot;, *nodeID)
// 调用Campaign方法选主,主的值为value-&lt;主节点ID&gt;-&lt;count&gt;
if err := e1.Campaign(context.Background(), fmt.Sprintf(&quot;value-%d-%d&quot;, *nodeID, count)); err != nil {
log.Println(err)
}
log.Println(&quot;campaigned for ID:&quot;, *nodeID)
count++
}
// 为主设置新值
func proclaim(e1 *concurrency.Election, electName string) {
log.Println(&quot;proclaiming for ID:&quot;, *nodeID)
// 调用Proclaim方法设置新值,新值为value-&lt;主节点ID&gt;-&lt;count&gt;
if err := e1.Proclaim(context.Background(), fmt.Sprintf(&quot;value-%d-%d&quot;, *nodeID, count)); err != nil {
log.Println(err)
}
log.Println(&quot;proclaimed for ID:&quot;, *nodeID)
count++
}
// 重新选主,有可能另外一个节点被选为了主
func resign(e1 *concurrency.Election, electName string) {
log.Println(&quot;resigning for ID:&quot;, *nodeID)
// 调用Resign重新选主
if err := e1.Resign(context.TODO()); err != nil {
log.Println(err)
}
log.Println(&quot;resigned for ID:&quot;, *nodeID)
}
```
## 查询
除了选举Leader程序在启动的过程中或者在运行的时候还有可能需要查询当前的主节点是哪一个节点主节点的值是什么版本是多少不光是主从节点需要查询和知道哪一个节点在分布式系统中还有其它一些节点也需要知道集群中的哪一个节点是主节点哪一个节点是从节点这样它们才能把读写请求分别发往相应的主从节点上。
etcd提供了查询当前Leader的方法**Leader**如果当前还没有Leader就返回一个错误你可以使用这个方法来查询主节点信息。这个方法的签名如下
```
func (e *Election) Leader(ctx context.Context) (*v3.GetResponse, error)
```
每次主节点的变动都会生成一个新的版本号,你还可以查询版本号信息(**Rev**方法),了解主节点变动情况:
```
func (e *Election) Rev() int64
```
你可以在测试完选主命令后测试查询命令query、rev代码如下
```
// 查询主的信息
func query(e1 *concurrency.Election, electName string) {
// 调用Leader返回主的信息包括key和value等信息
resp, err := e1.Leader(context.Background())
if err != nil {
log.Printf(&quot;failed to get the current leader: %v&quot;, err)
}
log.Println(&quot;current leader:&quot;, string(resp.Kvs[0].Key), string(resp.Kvs[0].Value))
}
// 可以直接查询主的rev信息
func rev(e1 *concurrency.Election, electName string) {
rev := e1.Rev()
log.Println(&quot;current rev:&quot;, rev)
}
```
## 监控
有了选举和查询方法,我们还需要一个监控方法。毕竟,如果主节点变化了,我们需要得到最新的主节点信息。
我们可以通过Observe来监控主的变化它的签名如下
```
func (e *Election) Observe(ctx context.Context) &lt;-chan v3.GetResponse
```
它会返回一个chan显示主节点的变动信息。需要注意的是它不会返回主节点的全部历史变动信息而是只返回最近的一条变动信息以及之后的变动信息。
它的测试代码如下:
```
func watch(e1 *concurrency.Election, electName string) {
ch := e1.Observe(context.TODO())
log.Println(&quot;start to watch for ID:&quot;, *nodeID)
for i := 0; i &lt; 10; i++ {
resp := &lt;-ch
log.Println(&quot;leader changed to&quot;, string(resp.Kvs[0].Key), string(resp.Kvs[0].Value))
}
}
```
etcd提供了选主的逻辑而你要做的就是利用这些方法让它们为你的业务服务。在使用的过程中你还需要做一些额外的设置比如查询当前的主节点、启动一个goroutine阻塞调用Campaign方法等等。虽然你需要做一些额外的工作但是跟自己实现一个分布式的选主逻辑相比大大地减少了工作量。
接下来我们继续看etcd提供的分布式并发原语互斥锁。
# 互斥锁
互斥锁是非常常用的一种并发原语我专门花了4讲的时间重点介绍了互斥锁的功能、原理和易错场景。
不过,前面说的互斥锁都是用来保护同一进程内的共享资源的,今天,我们要掌握的是分布式环境中的互斥锁。**我们要重点学习下分布在不同机器中的不同进程内的goroutine如何利用分布式互斥锁来保护共享资源。**
互斥锁的应用场景和主从架构的应用场景不太一样。**使用互斥锁的不同节点是没有主从这样的角色的,所有的节点都是一样的,只不过在同一时刻,只允许其中的一个节点持有锁**。
下面我们就来学习下互斥锁相关的两个原语即Locker和Mutex。
## Locker
etcd提供了一个简单的Locker原语它类似于Go标准库中的sync.Locker接口也提供了Lock/UnLock的机制
```
func NewLocker(s *Session, pfx string) sync.Locker
```
可以看到它的返回值是一个sync.Locker因为你对标准库的Locker已经非常了解了而且它只有Lock/Unlock两个方法所以接下来使用这个锁就非常容易了。下面的代码是一个使用Locker并发原语的例子
```
package main
import (
&quot;flag&quot;
&quot;log&quot;
&quot;math/rand&quot;
&quot;strings&quot;
&quot;time&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
&quot;github.com/coreos/etcd/clientv3/concurrency&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
lockName = flag.String(&quot;name&quot;, &quot;my-test-lock&quot;, &quot;lock name&quot;)
)
func main() {
flag.Parse()
rand.Seed(time.Now().UnixNano())
// etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
// 生成一个etcd client
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
useLock(cli) // 测试锁
}
func useLock(cli *clientv3.Client) {
// 为锁生成session
s1, err := concurrency.NewSession(cli)
if err != nil {
log.Fatal(err)
}
defer s1.Close()
//得到一个分布式锁
locker := concurrency.NewLocker(s1, *lockName)
// 请求锁
log.Println(&quot;acquiring lock&quot;)
locker.Lock()
log.Println(&quot;acquired lock&quot;)
// 等待一段时间
time.Sleep(time.Duration(rand.Intn(30)) * time.Second)
locker.Unlock() // 释放锁
log.Println(&quot;released lock&quot;)
}
```
你可以同时在两个终端中运行这个测试程序。可以看到,它们获得锁是有先后顺序的,一个节点释放了锁之后,另外一个节点才能获取到这个分布式锁。
## Mutex
事实上刚刚说的Locker是基于Mutex实现的只不过Mutex提供了查询Mutex的key的信息的功能。测试代码也类似
```
func useMutex(cli *clientv3.Client) {
// 为锁生成session
s1, err := concurrency.NewSession(cli)
if err != nil {
log.Fatal(err)
}
defer s1.Close()
m1 := concurrency.NewMutex(s1, *lockName)
//在请求锁之前查询key
log.Printf(&quot;before acquiring. key: %s&quot;, m1.Key())
// 请求锁
log.Println(&quot;acquiring lock&quot;)
if err := m1.Lock(context.TODO()); err != nil {
log.Fatal(err)
}
log.Printf(&quot;acquired lock. key: %s&quot;, m1.Key())
//等待一段时间
time.Sleep(time.Duration(rand.Intn(30)) * time.Second)
// 释放锁
if err := m1.Unlock(context.TODO()); err != nil {
log.Fatal(err)
}
log.Println(&quot;released lock&quot;)
}
```
可以看到Mutex并没有实现sync.Locker接口它的Lock/Unlock方法需要提供一个context.Context实例做参数这也就意味着在请求锁的时候你可以设置超时时间或者主动取消请求。
# 读写锁
学完了分布式Locker和互斥锁Mutex你肯定会联想到读写锁RWMutex。是的etcd也提供了分布式的读写锁。不过互斥锁Mutex是在github.com/coreos/etcd/clientv3/concurrency包中提供的读写锁RWMutex却是在github.com/coreos/etcd/contrib/recipes包中提供的。
etcd提供的分布式读写锁的功能和标准库的读写锁的功能是一样的。只不过**etcd提供的读写锁可以在分布式环境中的不同的节点使用**。它提供的方法也和标准库中的读写锁的方法一致分别提供了RLock/RUnlock、Lock/Unlock方法。下面的代码是使用读写锁的例子它从命令行中读取命令执行读写锁的操作
```
package main
import (
&quot;bufio&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;math/rand&quot;
&quot;os&quot;
&quot;strings&quot;
&quot;time&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
&quot;github.com/coreos/etcd/clientv3/concurrency&quot;
recipe &quot;github.com/coreos/etcd/contrib/recipes&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
lockName = flag.String(&quot;name&quot;, &quot;my-test-lock&quot;, &quot;lock name&quot;)
action = flag.String(&quot;rw&quot;, &quot;w&quot;, &quot;r means acquiring read lock, w means acquiring write lock&quot;)
)
func main() {
flag.Parse()
rand.Seed(time.Now().UnixNano())
// 解析etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
// 创建etcd的client
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建session
s1, err := concurrency.NewSession(cli)
if err != nil {
log.Fatal(err)
}
defer s1.Close()
m1 := recipe.NewRWMutex(s1, *lockName)
// 从命令行读取命令
consolescanner := bufio.NewScanner(os.Stdin)
for consolescanner.Scan() {
action := consolescanner.Text()
switch action {
case &quot;w&quot;: // 请求写锁
testWriteLocker(m1)
case &quot;r&quot;: // 请求读锁
testReadLocker(m1)
default:
fmt.Println(&quot;unknown action&quot;)
}
}
}
func testWriteLocker(m1 *recipe.RWMutex) {
// 请求写锁
log.Println(&quot;acquiring write lock&quot;)
if err := m1.Lock(); err != nil {
log.Fatal(err)
}
log.Println(&quot;acquired write lock&quot;)
// 等待一段时间
time.Sleep(time.Duration(rand.Intn(10)) * time.Second)
// 释放写锁
if err := m1.Unlock(); err != nil {
log.Fatal(err)
}
log.Println(&quot;released write lock&quot;)
}
func testReadLocker(m1 *recipe.RWMutex) {
// 请求读锁
log.Println(&quot;acquiring read lock&quot;)
if err := m1.RLock(); err != nil {
log.Fatal(err)
}
log.Println(&quot;acquired read lock&quot;)
// 等待一段时间
time.Sleep(time.Duration(rand.Intn(10)) * time.Second)
// 释放写锁
if err := m1.RUnlock(); err != nil {
log.Fatal(err)
}
log.Println(&quot;released read lock&quot;)
}
```
# 总结
自己实现分布式环境的并发原语,是相当困难的一件事,因为你需要考虑网络的延迟和异常、节点的可用性、数据的一致性等多种情况。
所以我们可以借助etcd这样成熟的框架基于它提供的分布式并发原语处理分布式的场景。需要注意的是在使用这些分布式并发原语的时候你需要考虑异常的情况比如网络断掉等。同时分布式并发原语需要网络之间的通讯所以会比使用标准库中的并发原语耗时更长。
<img src="https://static001.geekbang.org/resource/image/a1/23/a18a98aa9ac5de17373c953484ee4c23.jpg" alt="">
好了这节课就到这里下节课我会带你继续学习其它的分布式并发原语包括队列、栅栏和STM敬请期待。
# 思考题
1. 如果持有互斥锁或者读写锁的节点意外宕机了,它持有的锁会不会被释放?
1. etcd提供的读写锁中的读和写有没有优先级
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。

View File

@@ -0,0 +1,634 @@
<audio id="audio" title="20 | 在分布式环境中队列、栅栏和STM该如何实现" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/26/cd/26a60acb79429791ddc4f63fc485fdcd.mp3"></audio>
你好,我是鸟窝。
上一讲我已经带你认识了基于etcd实现的Leader选举、互斥锁和读写锁今天我们来学习下基于etcd的分布式队列、栅栏和STM。
只要你学过计算机算法和数据结构相关的知识, 队列这种数据结构你一定不陌生它是一种先进先出的类型有出队dequeue和入队enqueue两种操作。在[第12讲](https://time.geekbang.org/column/article/304127)中我专门讲到了一种叫做lock-free的队列。队列在单机的应用程序中常常使用但是在分布式环境中多节点如何并发地执行入队和出队的操作呢这一讲我会带你认识一下基于etcd实现的分布式队列。
除此之外我还会讲用分布式栅栏编排一组分布式节点同时执行的方法以及简化多个key的操作并且提供事务功能的STMSoftware Transactional Memory软件事务内存
# 分布式队列和优先级队列
前一讲我也讲到我们并不是从零开始实现一个分布式队列而是站在etcd的肩膀上利用etcd提供的功能实现分布式队列。
etcd集群的可用性由etcd集群的维护者来保证我们不用担心网络分区、节点宕机等问题。我们可以把这些通通交给etcd的运维人员把我们自己的关注点放在使用上。
下面我们就来了解下etcd提供的分布式队列。etcd通过github.com/coreos/etcd/contrib/recipes包提供了分布式队列这种数据结构。
创建分布式队列的方法非常简单只有一个即NewQueue你只需要传入etcd的client和这个队列的名字就可以了。代码如下
```
func NewQueue(client *v3.Client, keyPrefix string) *Queue
```
**这个队列只有两个方法,分别是出队和入队,队列中的元素是字符串类型**。这两个方法的签名如下所示:
```
// 入队
func (q *Queue) Enqueue(val string) error
//出队
func (q *Queue) Dequeue() (string, error)
```
需要注意的是如果这个分布式队列当前为空调用Dequeue方法的话会被阻塞直到有元素可以出队才返回。
既然是分布式的队列,那就意味着,我们可以在一个节点将元素放入队列,在另外一个节点把它取出。
在我接下来讲的例子中你就可以启动两个节点一个节点往队列中放入元素一个节点从队列中取出元素看看是否能正常取出来。etcd的分布式队列是一种多读多写的队列所以你也可以启动多个写节点和多个读节点。
下面我们来借助代码,看一下如何实现分布式队列。
首先,我们启动一个程序,它会从命令行读取你的命令,然后执行。你可以输入`push &lt;value&gt;`,将一个元素入队,输入`pop`,将一个元素弹出。另外,你还可以使用这个程序启动多个实例,用来模拟分布式的环境:
```
package main
import (
&quot;bufio&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;os&quot;
&quot;strings&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
recipe &quot;github.com/coreos/etcd/contrib/recipes&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
queueName = flag.String(&quot;name&quot;, &quot;my-test-queue&quot;, &quot;queue name&quot;)
)
func main() {
flag.Parse()
// 解析etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
// 创建etcd的client
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建/获取队列
q := recipe.NewQueue(cli, *queueName)
// 从命令行读取命令
consolescanner := bufio.NewScanner(os.Stdin)
for consolescanner.Scan() {
action := consolescanner.Text()
items := strings.Split(action, &quot; &quot;)
switch items[0] {
case &quot;push&quot;: // 加入队列
if len(items) != 2 {
fmt.Println(&quot;must set value to push&quot;)
continue
}
q.Enqueue(items[1]) // 入队
case &quot;pop&quot;: // 从队列弹出
v, err := q.Dequeue() // 出队
if err != nil {
log.Fatal(err)
}
fmt.Println(v) // 输出出队的元素
case &quot;quit&quot;, &quot;exit&quot;: //退出
return
default:
fmt.Println(&quot;unknown action&quot;)
}
}
}
```
我们可以打开两个终端,分别执行这个程序。在第一个终端中执行入队操作,在第二个终端中执行出队操作,并且观察一下出队、入队是否正常。
除了刚刚说的分布式队列etcd还提供了优先级队列PriorityQueue
它的用法和队列类似也提供了出队和入队的操作只不过在入队的时候除了需要把一个值加入到队列我们还需要提供uint16类型的一个整数作为此值的优先级优先级高的元素会优先出队。
优先级队列的测试程序如下,你可以在一个节点输入一些不同优先级的元素,在另外一个节点读取出来,看看它们是不是按照优先级顺序弹出的:
```
package main
import (
&quot;bufio&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;os&quot;
&quot;strconv&quot;
&quot;strings&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
recipe &quot;github.com/coreos/etcd/contrib/recipes&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
queueName = flag.String(&quot;name&quot;, &quot;my-test-queue&quot;, &quot;queue name&quot;)
)
func main() {
flag.Parse()
// 解析etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
// 创建etcd的client
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建/获取队列
q := recipe.NewPriorityQueue(cli, *queueName)
// 从命令行读取命令
consolescanner := bufio.NewScanner(os.Stdin)
for consolescanner.Scan() {
action := consolescanner.Text()
items := strings.Split(action, &quot; &quot;)
switch items[0] {
case &quot;push&quot;: // 加入队列
if len(items) != 3 {
fmt.Println(&quot;must set value and priority to push&quot;)
continue
}
pr, err := strconv.Atoi(items[2]) // 读取优先级
if err != nil {
fmt.Println(&quot;must set uint16 as priority&quot;)
continue
}
q.Enqueue(items[1], uint16(pr)) // 入队
case &quot;pop&quot;: // 从队列弹出
v, err := q.Dequeue() // 出队
if err != nil {
log.Fatal(err)
}
fmt.Println(v) // 输出出队的元素
case &quot;quit&quot;, &quot;exit&quot;: //退出
return
default:
fmt.Println(&quot;unknown action&quot;)
}
}
}
```
你看利用etcd实现分布式队列和分布式优先队列就是这么简单。所以在实际项目中如果有这类需求的话你就可以选择用etcd实现。
不过,在使用分布式并发原语时,除了需要考虑可用性和数据一致性,还需要考虑分布式设计带来的性能损耗问题。所以,在使用之前,你一定要做好性能的评估。
# 分布式栅栏
在[第17讲](https://time.geekbang.org/column/article/309098)中我们学习了循环栅栏CyclicBarrier它和[第6讲](https://time.geekbang.org/column/article/298516)的标准库中的WaitGroup本质上是同一类并发原语都是等待同一组goroutine同时执行或者是等待同一组goroutine都完成。
在分布式环境中,我们也会遇到这样的场景:一组节点协同工作,共同等待一个信号,在信号未出现前,这些节点会被阻塞住,而一旦信号出现,这些阻塞的节点就会同时开始继续执行下一步的任务。
etcd也提供了相应的分布式并发原语。
- **Barrier分布式栅栏**。如果持有Barrier的节点释放了它所有等待这个Barrier的节点就不会被阻塞而是会继续执行。
- **DoubleBarrier计数型栅栏**。在初始化计数型栅栏的时候我们就必须提供参与节点的数量当这些数量的节点都Enter或者Leave的时候这个栅栏就会放开。所以我们把它称为计数型栅栏。
## Barrier分布式栅栏
我们先来学习下分布式Barrier。
分布式Barrier的创建很简单你只需要提供etcd的Client和Barrier的名字就可以了如下所示
```
func NewBarrier(client *v3.Client, key string) *Barrier
```
Barrier提供了三个方法分别是Hold、**Release和Wait**代码如下:
```
func (b *Barrier) Hold() error
func (b *Barrier) Release() error
func (b *Barrier) Wait() error
```
- **Hold方法**是创建一个Barrier。如果Barrier已经创建好了有节点调用它的Wait方法就会被阻塞。
- **Release方法**是释放这个Barrier也就是打开栅栏。如果使用了这个方法所有被阻塞的节点都会被放行继续执行。
- **Wait方法**会阻塞当前的调用者直到这个Barrier被release。如果这个栅栏不存在调用者不会被阻塞而是会继续执行。
**学习并发原语最好的方式就是使用它**。下面我们就来借助一个例子来看看Barrier该怎么用。
你可以在一个终端中运行这个程序,执行"hold""release"命令,模拟栅栏的持有和释放。在另外一个终端中运行这个程序,不断调用"wait"方法,看看是否能正常地跳出阻塞继续执行:
```
package main
import (
&quot;bufio&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;os&quot;
&quot;strings&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
recipe &quot;github.com/coreos/etcd/contrib/recipes&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
barrierName = flag.String(&quot;name&quot;, &quot;my-test-queue&quot;, &quot;barrier name&quot;)
)
func main() {
flag.Parse()
// 解析etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
// 创建etcd的client
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建/获取栅栏
b := recipe.NewBarrier(cli, *barrierName)
// 从命令行读取命令
consolescanner := bufio.NewScanner(os.Stdin)
for consolescanner.Scan() {
action := consolescanner.Text()
items := strings.Split(action, &quot; &quot;)
switch items[0] {
case &quot;hold&quot;: // 持有这个barrier
b.Hold()
fmt.Println(&quot;hold&quot;)
case &quot;release&quot;: // 释放这个barrier
b.Release()
fmt.Println(&quot;released&quot;)
case &quot;wait&quot;: // 等待barrier被释放
b.Wait()
fmt.Println(&quot;after wait&quot;)
case &quot;quit&quot;, &quot;exit&quot;: //退出
return
default:
fmt.Println(&quot;unknown action&quot;)
}
}
}
```
## DoubleBarrier计数型栅栏
etcd还提供了另外一种栅栏叫做DoubleBarrier这也是一种非常有用的栅栏。这个栅栏初始化的时候需要提供一个计数count如下所示
```
func NewDoubleBarrier(s *concurrency.Session, key string, count int) *DoubleBarrier
```
同时它还提供了两个方法分别是Enter和Leave代码如下
```
func (b *DoubleBarrier) Enter() error
func (b *DoubleBarrier) Leave() error
```
我来解释下这两个方法的作用。
当调用者调用Enter时会被阻塞住直到一共有count初始化这个栅栏的时候设定的值个节点调用了Enter这count个被阻塞的节点才能继续执行。所以你可以利用它编排一组节点让这些节点在同一个时刻开始执行任务。
同理如果你想让一组节点在同一个时刻完成任务就可以调用Leave方法。节点调用Leave方法的时候会被阻塞直到有count个节点都调用了Leave方法这些节点才能继续执行。
我们再来看一下DoubleBarrier的使用例子。你可以起两个节点同时执行Enter方法看看这两个节点是不是先阻塞之后才继续执行。然后你再执行Leave方法也观察一下是不是先阻塞又继续执行的。
```
package main
import (
&quot;bufio&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;os&quot;
&quot;strings&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
&quot;github.com/coreos/etcd/clientv3/concurrency&quot;
recipe &quot;github.com/coreos/etcd/contrib/recipes&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
barrierName = flag.String(&quot;name&quot;, &quot;my-test-doublebarrier&quot;, &quot;barrier name&quot;)
count = flag.Int(&quot;c&quot;, 2, &quot;&quot;)
)
func main() {
flag.Parse()
// 解析etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
// 创建etcd的client
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 创建session
s1, err := concurrency.NewSession(cli)
if err != nil {
log.Fatal(err)
}
defer s1.Close()
// 创建/获取栅栏
b := recipe.NewDoubleBarrier(s1, *barrierName, *count)
// 从命令行读取命令
consolescanner := bufio.NewScanner(os.Stdin)
for consolescanner.Scan() {
action := consolescanner.Text()
items := strings.Split(action, &quot; &quot;)
switch items[0] {
case &quot;enter&quot;: // 持有这个barrier
b.Enter()
fmt.Println(&quot;enter&quot;)
case &quot;leave&quot;: // 释放这个barrier
b.Leave()
fmt.Println(&quot;leave&quot;)
case &quot;quit&quot;, &quot;exit&quot;: //退出
return
default:
fmt.Println(&quot;unknown action&quot;)
}
}
}
```
好了我们先来简单总结一下。我们在第17讲学习的循环栅栏控制的是同一个进程中的不同goroutine的执行而**分布式栅栏和计数型栅栏控制的是不同节点、不同进程的执行**。当你需要协调一组分布式节点在某个时间点同时运行的时候可以考虑etcd提供的这组并发原语。
# STM
提到事务,你肯定不陌生。在开发基于数据库的应用程序的时候,我们经常用到事务。事务就是要保证一组操作要么全部成功,要么全部失败。
在学习STM之前我们要先了解一下etcd的事务以及它的问题。
etcd提供了在一个事务中对多个key的更新功能这一组key的操作要么全部成功要么全部失败。etcd的事务实现方式是基于CAS方式实现的融合了Get、Put和Delete操作。
etcd的事务操作如下分为条件块、成功块和失败块条件块用来检测事务是否成功如果成功就执行Then(...)如果失败就执行Else(...)
```
Txn().If(cond1, cond2, ...).Then(op1, op2, ...,).Else(op1, op2, …)
```
我们来看一个利用etcd的事务实现转账的小例子。我们从账户from 向账户to转账 amount代码如下
```
func doTxnXfer(etcd *v3.Client, from, to string, amount uint) (bool, error) {
// 一个查询事务
getresp, err := etcd.Txn(ctx.TODO()).Then(OpGet(from), OpGet(to)).Commit()
if err != nil {
return false, err
}
// 获取转账账户的值
fromKV := getresp.Responses[0].GetRangeResponse().Kvs[0]
toKV := getresp.Responses[1].GetRangeResponse().Kvs[1]
fromV, toV := toUInt64(fromKV.Value), toUint64(toKV.Value)
if fromV &lt; amount {
return false, fmt.Errorf(“insufficient value”)
}
// 转账事务
// 条件块
txn := etcd.Txn(ctx.TODO()).If(
v3.Compare(v3.ModRevision(from), “=”, fromKV.ModRevision),
v3.Compare(v3.ModRevision(to), “=”, toKV.ModRevision))
// 成功块
txn = txn.Then(
OpPut(from, fromUint64(fromV - amount)),
OpPut(to, fromUint64(toV + amount))
//提交事务
putresp, err := txn.Commit()
// 检查事务的执行结果
if err != nil {
return false, err
}
return putresp.Succeeded, nil
}
```
从刚刚的这段代码中我们可以看到虽然可以利用etcd实现事务操作但是逻辑还是比较复杂的。
因为事务使用起来非常麻烦所以etcd又在这些基础API上进行了封装新增了一种叫做STM的操作提供了更加便利的方法。
下面我们来看一看STM怎么用。
要使用STM你需要先编写一个apply函数这个函数的执行是在一个事务之中的
```
apply func(STM) error
```
这个方法包含一个STM类型的参数它提供了对key值的读写操作。
STM提供了4个方法分别是Get、Put、Receive和Delete代码如下
```
type STM interface {
Get(key ...string) string
Put(key, val string, opts ...v3.OpOption)
Rev(key string) int64
Del(key string)
}
```
使用etcd STM的时候我们只需要定义一个apply方法比如说转账方法exchange然后通过concurrency.NewSTM(cli, exchange),就可以完成转账事务的执行了。
STM咋用呢我们还是借助一个例子来学习下。
下面这个例子创建了5个银行账号然后随机选择一些账号两两转账。在转账的时候要把源账号一半的钱要转给目标账号。这个例子启动了10个goroutine去执行这些事务每个goroutine要完成100个事务。
为了确认事务是否出错了,我们最后要校验每个账号的钱数和总钱数。总钱数不变,就代表执行成功了。这个例子的代码如下:
```
package main
import (
&quot;context&quot;
&quot;flag&quot;
&quot;fmt&quot;
&quot;log&quot;
&quot;math/rand&quot;
&quot;strings&quot;
&quot;sync&quot;
&quot;github.com/coreos/etcd/clientv3&quot;
&quot;github.com/coreos/etcd/clientv3/concurrency&quot;
)
var (
addr = flag.String(&quot;addr&quot;, &quot;http://127.0.0.1:2379&quot;, &quot;etcd addresses&quot;)
)
func main() {
flag.Parse()
// 解析etcd地址
endpoints := strings.Split(*addr, &quot;,&quot;)
cli, err := clientv3.New(clientv3.Config{Endpoints: endpoints})
if err != nil {
log.Fatal(err)
}
defer cli.Close()
// 设置5个账户每个账号都有100元总共500元
totalAccounts := 5
for i := 0; i &lt; totalAccounts; i++ {
k := fmt.Sprintf(&quot;accts/%d&quot;, i)
if _, err = cli.Put(context.TODO(), k, &quot;100&quot;); err != nil {
log.Fatal(err)
}
}
// STM的应用函数主要的事务逻辑
exchange := func(stm concurrency.STM) error {
// 随机得到两个转账账号
from, to := rand.Intn(totalAccounts), rand.Intn(totalAccounts)
if from == to {
// 自己不和自己转账
return nil
}
// 读取账号的值
fromK, toK := fmt.Sprintf(&quot;accts/%d&quot;, from), fmt.Sprintf(&quot;accts/%d&quot;, to)
fromV, toV := stm.Get(fromK), stm.Get(toK)
fromInt, toInt := 0, 0
fmt.Sscanf(fromV, &quot;%d&quot;, &amp;fromInt)
fmt.Sscanf(toV, &quot;%d&quot;, &amp;toInt)
// 把源账号一半的钱转账给目标账号
xfer := fromInt / 2
fromInt, toInt = fromInt-xfer, toInt+xfer
// 把转账后的值写回
stm.Put(fromK, fmt.Sprintf(&quot;%d&quot;, fromInt))
stm.Put(toK, fmt.Sprintf(&quot;%d&quot;, toInt))
return nil
}
// 启动10个goroutine进行转账操作
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i &lt; 10; i++ {
go func() {
defer wg.Done()
for j := 0; j &lt; 100; j++ {
if _, serr := concurrency.NewSTM(cli, exchange); serr != nil {
log.Fatal(serr)
}
}
}()
}
wg.Wait()
// 检查账号最后的数目
sum := 0
accts, err := cli.Get(context.TODO(), &quot;accts/&quot;, clientv3.WithPrefix()) // 得到所有账号
if err != nil {
log.Fatal(err)
}
for _, kv := range accts.Kvs { // 遍历账号的值
v := 0
fmt.Sscanf(string(kv.Value), &quot;%d&quot;, &amp;v)
sum += v
log.Printf(&quot;account %s: %d&quot;, kv.Key, v)
}
log.Println(&quot;account sum is&quot;, sum) // 总数
}
```
总结一下当你利用etcd做存储时是可以利用STM实现事务操作的一个事务可以包含多个账号的数据更改操作事务能够保证这些更改要么全成功要么全失败。
# 总结
如果我们把眼光放得更宽广一些其实并不只是etcd提供了这些并发原语比如我上节课一开始就提到了Zookeeper很早也提供了类似的并发原语只不过只提供了Java的库并没有提供合适的Go库。另外根据Consul官方的反馈他们并没有开发这些并发原语的计划所以从目前来看etcd是个不错的选择。
当然,也有一些其它不太知名的分布式原语库,但是活跃度不高,可用性低,所以我们也不需要去了解了。
其实你也可以使用Redis实现分布式锁或者是基于MySQL实现分布式锁这也是常用的选择。对于大厂来说选择起来是非常简单的只需要看看厂内提供了哪个基础服务哪个更稳定些。对于没有etcd、Redis这些基础服务的公司来说很重要的一点就是自己搭建一套这样的基础服务并且运维好这就需要考察你们对etcd、Redis、MySQL的技术把控能力了哪个用得更顺手就用哪个。
一般来说我不建议你自己去实现分布式原语最好是直接使用etcd、Redis这些成熟的软件提供的功能这也意味着我们将程序的风险转嫁到了这些基础服务上这些基础服务必须要能够提供足够的服务保障。
<img src="https://static001.geekbang.org/resource/image/c0/1d/c0d48fd09b91685c836829570fdc7b1d.jpg" alt="">
# 思考题
1. 部署一个3节点的etcd集群测试一下分布式队列的性能。
1. etcd提供的STM是分布式事务吗
欢迎在留言区写下你的思考和答案,我们一起交流讨论。如果你觉得有所收获,也欢迎你把今天的内容分享给你的朋友或同事。