云原生 Go——云原生模式

251 阅读59分钟

进步只有在我们将程序视为抽象的逻辑,而不是可执行代码时,才是可能的

——Edsger W. Dijkstra, 1979年8月

1991年,L Peter Deutsch在仍在Sun Microsystems工作时,提出了“分布式计算的谬误”,列出了一些程序员在进行分布式应用开发时常常做出的错误假设,这些假设对新手(以及一些不太新的开发者)尤其常见:

  • 网络是可靠的:交换机可能会故障,路由器可能会配置错误
  • 延迟为零:数据在网络中传输需要时间
  • 带宽是无限的:网络一次只能处理有限量的数据
  • 网络是安全的:不要以明文形式分享机密信息;加密所有内容
  • 拓扑结构不变:服务器和服务会进进出出
  • 只有一个管理员:多个管理员会导致异质化的解决方案
  • 传输成本为零:数据的移动需要时间和金钱
  • 网络是同质的:每个网络(有时差异很大)都是不同的

如果我可以冒昧的话,我想再补充一个第九个:

  • 服务是可靠的:你依赖的服务随时可能失败

在本章中,我将介绍一些惯用模式——这些经过验证的开发范式——旨在解决Deutsch列出的分布式计算谬误之一或多个,并展示如何在Go中实现它们。本书讨论的所有模式并非原创——有些模式自分布式应用诞生以来就已经存在——但大多数模式之前并未以单一作品的形式发布。许多模式是Go特有的,或者相较于其他语言在Go中有着新颖的实现。

不幸的是,本书不会涵盖基础设施层面的模式,如“舱壁模式”(Bulkhead)或“守门员模式”(Gatekeeper)。主要是因为本书的重点是Go中的应用层开发,而这些模式虽然不可或缺,却是在完全不同的抽象层面上运作的。如果你有兴趣深入了解这些内容,我推荐阅读Justin Garrison和Kris Nova的《Cloud Native Infrastructure》(O'Reilly)和Brendan Burns的《Designing Distributed Systems》(O'Reilly)。

Context 包

本章中的大多数代码示例都使用了 context 包,它在 Go 1.7 中引入,提供了一种符合惯用法的方式,用于在进程之间传递截止日期、取消信号和请求范围的值。它包含一个接口 context.Context,该接口的方法如下所示:

type Context interface {
    // Deadline 返回该 Context 应该被取消的时间;
    // 如果没有设置截止日期,它返回 ok==false。
    Deadline() (deadline time.Time, ok bool)

    // Done 返回一个通道,当该 Context 被取消时关闭。
    Done() <-chan struct{}

    // Err 在 Done 通道关闭后,指示该 context 被取消的原因;
    // 如果 Done 尚未关闭,则 Err 返回 nil。
    Err() error

    // Value 返回与该 context 关联的键的值,如果没有关联任何值,则返回 nil。
    // 使用时需谨慎。
    Value(key any) any
}

这四个方法中的三个可以用来了解 Context 值的取消状态或行为。第四个方法 Value 可以用来检索与任意键关联的值。ContextValue 方法在 Go 语言社区中是一个有争议的话题,我们将在“定义请求范围的值”部分进一步讨论。

Context 能为你做什么

context.Context 值通过直接传递给服务请求来使用,服务请求可能会进一步将其传递给一个或多个子请求。它的有用之处在于,当一个 Context 被取消时,所有持有它(或派生的 Context;在“定义 Context 截止日期和超时”部分将进一步说明)的函数都会收到取消信号,从而使它们能够协调取消操作,减少无用的工作。

举个例子,假设用户向服务发出请求,服务进一步向数据库发出请求。在理想的情况下,用户请求、应用请求和数据库请求可以像图4-1所示那样进行示意图展示。

image.png

但如果用户在请求完成之前终止了请求怎么办?在大多数情况下,由于缺乏对请求整体上下文的意识,相关进程通常会继续运行(如图4-2所示),消耗资源来提供一个永远不会被使用的结果。

image.png

然而,通过将一个 Context 共享给每个后续请求,所有长时间运行的进程都可以收到一个同时的“完成”信号,从而使得取消信号在各个进程之间得到协调(如图4-3所示)。

image.png

重要的是,Context 值也是线程安全的:它们可以被多个并发执行的 goroutine 安全地使用,而不必担心出现意外行为。

创建 Context

一个全新的 context.Context 可以通过以下两个函数之一获得:

  • Background() Context
    返回一个空的 Context,它从不被取消,没有任何值,也没有截止日期。通常由主函数、初始化和测试使用,并作为传入请求的顶级 Context
  • TODO() Context
    也提供一个空的 Context,但它通常用作占位符,适用于不清楚使用哪个 Context 时,或者父 Context 尚不可用时。

定义 Context 截止日期和超时

context 包还包括一些方法,用于创建派生的 Context 值,允许你通过应用超时或使用函数钩子显式触发取消来控制取消行为:

  • WithDeadline(Context, time.Time) (Context, CancelFunc)
    接受一个具体的时间点,在该时间点,Context 将被取消,且 Done 通道将被关闭。
  • WithTimeout(Context, time.Duration) (Context, CancelFunc)
    接受一个持续时间,在该时间段后,Context 将被取消,且 Done 通道将被关闭。
  • WithCancel(Context) (Context, CancelFunc)
    与之前的函数不同,WithCancel 不接受任何额外参数,只返回一个可以显式取消 Context 的函数。

这三种函数都返回一个派生的 Context,包括任何请求的装饰,并且返回一个 context.CancelFunc,这是一个零参数函数,可以被调用以显式取消 Context 及其所有派生值。

小贴士

当一个 Context 被取消时,所有从该 Context 派生的 Context 也会被取消。它所派生的 Context 不会被取消。

取消原因

为了完整起见,值得一提的是,最近还引入了三个与上述三个函数类似的函数,它们还允许你指定一个特定的错误值作为取消原因:

  • WithDeadlineCause(Context, time.Time, error) (Context, CancelFunc)
    在 Go 1.21 中引入。行为与 WithDeadline 相同,但当截止日期超时时,它还设置返回的 Context 的取消原因。返回的 CancelFunc 不设置取消原因。
  • WithTimeoutCause(Context, time.Duration, error) (Context, CancelFunc)
    在 Go 1.21 中引入。行为与 WithTimeout 相同,但当超时到期时,它还设置返回的 Context 的取消原因。返回的 CancelFunc 不设置取消原因。
  • WithCancelCause(Context) (Context, CancelCauseFunc)
    在 Go 1.20 中引入。行为与 WithCancel 相同,但返回一个 CancelCauseFunc 而不是 CancelFunc。当使用非 nil 错误(“原因”)调用 cancel 时,会在 ctx 中记录该错误;然后可以通过 Cause(ctx) 获取该错误。

显式定义取消原因的能力可以为日志记录或其他决定适当响应提供有用的上下文信息。

定义请求范围的值

最后,context 包包括一个可以用来定义任意请求范围的键值对的函数,这些键值对可以通过 Value 方法从返回的 Context 以及所有从它派生的 Context 中访问:

  • WithValue(parent Context, key, val any) Context
    WithValue 返回一个派生自 parentContext,其中 key 与值 val 关联。

关于 Context 值

context.WithValuecontext.Value 函数提供了便捷的机制,用于设置和获取可以被消费进程和 API 使用的任意键值对。然而,有人认为,这种功能与 Context 用于协调长时间请求取消的功能是正交的,它可能会掩盖程序的流程,并且容易打破编译时的耦合。关于这一点的更深入讨论可以参考 Dave Cheney 的博客文章《Context Is for Cancellation》。

在本章(或本书)中的示例中并未使用此功能。如果你决定使用它,请确保所有的值仅限于请求范围,不会改变任何进程的功能,并且在缺失时不会破坏你的进程。

使用 Context

当服务请求被发起时,无论是通过传入的请求还是通过主函数触发,顶层进程将使用 Background 函数创建一个新的 Context 值,并可能使用一个或多个 context.With* 函数对其进行装饰,然后将其传递给任何子请求。这些子请求只需要监听 Done 通道来接收取消信号。

例如,看看以下的 Stream 函数:

func Stream(ctx context.Context, out chan<- Value) error {
    // 创建一个具有10秒超时的派生Context;dctx
    // 在超时后将被取消,但ctx不会。
    // cancel是一个显式取消dctx的函数。
    dctx, cancel := context.WithTimeout(ctx, 10 * time.Second)

    // 如果SlowOperation在超时前完成,释放资源
    defer cancel()

    res, err := SlowOperation(dctx)   // res是一个Value通道
    if err != nil {                   // 如果dctx超时,则为真
        return err
    }

    for {
        select {
        case out <- <-res:            // 从res读取;发送到out

        case <-ctx.Done():            // 如果ctx被取消,则触发
            return ctx.Err()          // 但不是dctx被取消时
        }
    }
}

Stream 函数接收一个 ctx Context 作为输入参数,并将其传递给 WithTimeout 以创建 dctx,一个具有 10 秒超时的派生 Context。由于这个装饰,SlowOperation(dctx) 调用可能会在 10 秒后超时并返回错误。然而,使用原始 ctx 的函数将没有这个超时装饰,不会超时。

接下来,原始的 ctx 值被用于 for 循环中的 select 语句,从 SlowOperation 函数提供的 res 通道中检索值。注意 case <-ctx.Done() 语句,它会在 ctx.Done 通道关闭时执行,以返回适当的错误值。

本章布局

本章中每个模式的一般展示形式 loosely 基于著名的《Gang of Four》设计模式书籍,但更简单且不那么正式。每个模式开头都会简要描述其目的和使用该模式的原因,接下来包括以下几个部分:

  • 适用性
    上下文和描述此模式可能应用的场景。
  • 参与者
    列出模式中的各个组件及其角色。
  • 实现
    讨论解决方案及其实现方式。
  • 示例代码
    演示如何在 Go 中实现该代码。

稳定性模式

本节介绍的稳定性模式解决了分布式计算的谬误中提到的一个或多个假设。这些模式通常旨在被分布式应用程序应用,以提高它们自身的稳定性以及它们所在更大系统的稳定性。

熔断器模式 (Circuit Breaker)

熔断器模式通过自动降级服务功能,响应可能发生的故障,从而防止更大或连锁的故障,避免重复错误,并提供合理的错误响应。

适用性

如果将分布式计算的谬误归结为一个要点,那就是错误和故障是分布式云原生系统无法回避的事实。服务会被误配置,数据库可能崩溃,网络会发生分区。我们无法避免这些问题;我们只能接受并为之做出预案。

如果未能做到这一点,后果往往相当严重。我们都见过这些问题,它们并不美好。有些服务可能会徒劳地尝试执行任务,返回无意义的结果;其他服务可能会发生灾难性的故障,甚至进入崩溃/重启的死循环。无论如何,它们都会浪费资源,掩盖最初的故障源,并使得连锁故障更加可能。

相反,假设其依赖项可能随时失败的服务,可以在依赖项失败时合理响应。熔断器模式使得服务能够检测到这种故障,并通过暂时停止请求的执行来“断开电路”,而是提供一个符合服务通信合同的错误信息给客户端。

例如,设想一个理想的服务,它从客户端接收请求,执行数据库查询,并返回响应。如果数据库失败了怎么办?该服务可能会继续徒劳地尝试查询数据库,淹没日志,最终超时或返回无用的错误。这样的服务可以使用熔断器,在数据库失败时“断开电路”,防止服务继续向数据库发出请求(至少一段时间内),并立即向客户端返回有意义的通知。

参与者

此模式包括以下参与者:

  • Circuit(电路)
    与服务交互的函数。
  • Breaker(熔断器)
    与 Circuit 具有相同函数签名的闭包。

实现

从本质上讲,熔断器模式只是一个特化的适配器模式,其中 Breaker 包装了 Circuit,以添加额外的错误处理逻辑。

像电气开关一样,熔断器有两个可能的状态:关闭和打开。在关闭状态下,一切正常运行。所有从客户端收到的请求都会被转发到 Circuit,并且从 Circuit 返回的响应会转发回客户端。在打开状态下,Breaker 不会将请求转发到 Circuit,而是通过返回一个信息丰富的错误消息来“快速失败”。

Breaker 会跟踪 Circuit 返回的错误;如果 Circuit 返回的连续错误超过了预定义的阈值,Breaker 就会触发并将状态切换为打开。

大多数熔断器模式的实现包括一些逻辑,用于在一段时间后自动关闭电路。然而,值得注意的是,对已经发生故障的服务进行大量重试可能会引发自己的问题,因此通常会包含某种回退逻辑,在一段时间内减少重试的频率。回退的主题实际上非常复杂,但我们将在《Play It Again: 重试请求》部分详细讨论。

在一个多节点服务中,可以通过共享存储机制(如 Memcached 或 Redis 网络缓存)扩展此实现,以跟踪电路状态。

示例代码

我们从创建一个 Circuit 类型开始,指定与数据库或其他上游服务交互的函数签名。在实践中,这可以采用适合功能的任何形式。然而,它应该包括一个返回错误:

type Circuit func(context.Context) (string, error)

在这个例子中,Circuit 是一个接受 Context 值的函数,在《The Context Package》中已详细描述。你的实现可能会有所不同。

Breaker 函数接受任何符合 Circuit 类型定义的函数,以及一个表示在电路自动打开之前允许的连续故障次数的整数。作为返回,它提供另一个函数,该函数也符合 Circuit 类型定义:

func Breaker(circuit Circuit, threshold int) Circuit {
    var failures int
    var last = time.Now()
    var m sync.RWMutex

    return func(ctx context.Context) (string, error) {
        m.RLock()                       // Establish a "read lock"

        d := failures - threshold

        if d >= 0 {
            shouldRetryAt := last.Add((2 << d) * time.Second)

            if !time.Now().After(shouldRetryAt) {
                m.RUnlock()
                return "", errors.New("service unavailable")
            }
        }

        m.RUnlock()                     // Release read `lock`

        response, err := circuit(ctx)   // Issue the request proper

        m.Lock()                        // Lock around shared resources
        defer m.Unlock()

        last = time.Now()               // Record time of attempt

        if err != nil {                 // Circuit returned an error,
            failures++                  // so we count the failure
            return response, err        // and return
        }

        failures = 0                    // Reset failures counter

        return response, nil
    }
}

Breaker 函数构造了另一个同样属于 Circuit 类型的函数,它包装了 circuit,以提供所需的功能。你可以将其视为《匿名函数与闭包》中的闭包:一个具有访问父函数变量的嵌套函数。如你所见,本章中所有的“稳定性”功能都以这种方式实现。

该闭包通过计算 circuit 返回的连续错误次数来工作。如果该值达到故障阈值,则返回“服务不可达”错误,而不实际调用 circuit。对 circuit 的任何成功调用都将使故障计数器重置为 0,然后重新开始。

闭包甚至包含一个自动重置机制,允许请求在几秒钟后再次调用 circuit,并且具有指数回退,即每次重试的延迟时间大致翻倍。虽然这种回退方式简单且常见,但这实际上并不是理想的回退算法。我们将在《回退算法》部分详细回顾这一点。

这个函数还包括我们首次使用的互斥锁(mutex)。互斥锁是并发编程中的常见模式,本章中我们将大量使用它们,因此如果你对 Go 中的互斥锁不太了解,请参考《互斥锁》部分。

互斥锁(MUTEXES)

互斥锁("mutual exclusion")是一种并发构造,防止多个进程同时访问相同的共享资源。在Go中,sync.Mutex 类型允许进程建立一个“锁”,从而使得后续的锁请求会被阻塞,直到第一个锁被释放。

这一概念的一个常见扩展是读写互斥锁,它由 Go 中的 sync.RWMutex 实现,提供了方法来建立读锁和写锁。只要没有打开的写锁,任意数量的进程可以同时建立读锁;而一个进程只有在没有现有读锁或写锁时,才能建立写锁。尝试建立其他锁的操作会被阻塞,直到它之前的锁被释放。

在这里,我们使用 sync.RWMutex 来允许对一个映射(map)进行线程安全的读写操作:

var items = struct{                             // 包含map和
    sync.RWMutex                                // 组合的sync.RWMutex的结构体
    m map[string]int
}{m: make(map[string]int)}

func ThreadSafeRead(key string) int {
    items.RLock()                               // 建立读锁
    defer items.RUnlock()                       // 释放读锁
    return items.m[key]
}

func ThreadSafeWrite(key string, value int) {
    items.Lock()                                // 建立写锁
    defer items.Unlock()                        // 释放写锁
    items.m[key] = value
}

在这里,我们可以看到 RWMutex 提供的两种不同的锁方法:RLockRUnlock 用于建立和释放读锁,而 LockUnlock 用于建立和释放写锁。

防抖(Debounce)

防抖限制函数调用的频率,使得只有一组调用中的第一个或最后一个会实际执行。

适用性

防抖是我们第二个带有电路主题的模式。具体来说,它的名称来源于一个现象,在这个现象中,开关的接触点在打开或关闭时会“弹跳”,导致电路在稳定之前有短暂的波动。通常这不是什么大问题,但在逻辑电路中,这种“接触弹跳”可能会导致一系列开/关脉冲被解读为数据流。在这种情况下,消除接触弹跳,使得每次开关操作只传递一个信号的做法叫做去抖动(debouncing)。

在服务的世界中,我们有时会发现自己在执行一系列可能慢或昂贵的操作,而实际上只需要其中一个操作。使用防抖模式时,一系列在时间上非常接近的类似调用会被限制为只执行一次,通常是批次中的第一个或最后一个调用。

这种技术在JavaScript领域已经使用多年,目的是限制操作的次数,防止浏览器因多个用户事件的操作而变慢,或者在用户准备好之前延迟调用。你可能以前就见过这种技术的应用。我们都熟悉这样一种体验:使用一个搜索框,直到你暂停输入,自动完成的弹出窗口才会显示,或者你多次点击一个按钮,但只有第一次点击被忽略。

对于我们这些专注于后端服务的人来说,我们可以向前端的同事们学习很多东西,他们已经多年来在分布式系统中处理可靠性、延迟和带宽问题。例如,这种方法可以用来检索一些更新缓慢的远程资源,而不会因为浪费请求而让客户端和服务器的时间都被拖慢。

这个模式类似于“节流”(Throttle),因为它限制了函数的调用频率。但不同的是,防抖限制的是一组调用,而节流则是按照时间段来限制调用次数。关于防抖和节流模式的区别,详见“防抖和节流的区别是什么?”。

参与者

此模式包括以下参与者:

  • Circuit(电路)
    需要被调节的函数。
  • Debounce(防抖)
    一个与 Circuit 具有相同函数签名的闭包。

实现

防抖的实现实际上与熔断器(Circuit Breaker)模式非常相似,它包装了 Circuit 来提供速率限制逻辑。该逻辑非常简单:每次调用外部函数时——无论结果如何——都会设置一个时间间隔。任何在该时间间隔到期之前进行的后续调用都会被忽略;而任何在该时间间隔之后进行的调用都会传递给内部函数。这个实现中,内部函数只会被调用一次,之后的调用会被忽略,这种方法称为 函数优先,它的优势是允许缓存并返回内部函数的初始响应。

函数后置(Function-last)实现会在一系列调用后等待一段暂停时间,然后再调用内部函数。这种变体在JavaScript领域很常见,尤其是在程序员希望在调用函数之前获得一定量的输入时,比如在搜索框等待用户暂停输入后再进行自动补全。在后端服务中,函数后置的使用较少,因为它无法立即响应,但如果你的函数不需要立即得到结果,这种方法是有用的。

示例代码

就像熔断器模式的实现一样,我们首先通过定义一个派生函数类型来限制我们希望控制的函数调用频率。与熔断器一样,我们将其命名为 Circuit,它与该示例中声明的 Circuit 完全相同。再次强调,Circuit 可以采用适合你功能的任何形式,但它应该包括一个错误作为返回值:

type Circuit func(context.Context) (string, error)

与熔断器实现的相似性是故意的:它们的兼容性使它们可以“链式”调用,示例如下:

func myFunction(ctx context.Context) (string, error) { /* ... */ }

wrapped := Breaker(Debounce(myFunction))
response, err := wrapped(ctx)

防抖模式的 函数优先 实现——DebounceFirst——相比于 函数后置 实现更加直接,因为它只需要跟踪最后一次调用的时间,并且如果在 d 持续时间内再次调用,则返回缓存的结果:

func DebounceFirst(circuit Circuit, d time.Duration) Circuit {
    var threshold time.Time
    var result string
    var err error
    var m sync.Mutex

    return func(ctx context.Context) (string, error) {
        m.Lock()
        defer m.Unlock()

        if time.Now().Before(threshold) {
            return result, err
        }

        result, err = circuit(ctx)
        threshold = time.Now().Add(d)

        return result, err
    }
}

这个 DebounceFirst 的实现通过将整个函数包装在互斥锁中来确保线程安全。尽管这会强制要求集群开始时的重叠调用必须等待,直到结果被缓存,它也保证了 circuit 只会在集群开始时调用一次。defer 确保每次调用时,表示集群结束时间(如果没有进一步的调用)的 threshold 值会被重置。

然而,这种方法可能会存在一个问题:它基本上只是缓存函数的结果,并在再次调用时返回它。但如果 circuit 函数有重要的副作用怎么办?以下变体 DebounceFirstContext 更加复杂,因为每次调用都会执行 circuit,但每次新的上下文都会取消前一个上下文:

func DebounceFirstContext(circuit Circuit, d time.Duration) Circuit {
    var threshold time.Time
    var m sync.Mutex
    var lastCtx context.Context
    var lastCancel context.CancelFunc

    return func(ctx context.Context) (string, error) {
        m.Lock()

        if time.Now().Before(threshold) {
            lastCancel()
        }

        lastCtx, lastCancel = context.WithCancel(ctx)
        threshold = time.Now().Add(d)

        m.Unlock()

        result, err := circuit(lastCtx)

        return result, err
    }
}

DebounceFirstContext 中,我们使用与 DebounceFirst 相同的结构和锁定方案,但这次我们为 circuit 设置了一个上下文和 CancelFunc,这使我们能够在每次调用之前显式地取消之前的 circuit 调用。通过这种方式,每次调用 circuit(并触发任何预期的副作用)时,都会显式取消任何先前的调用。

如果我们希望在一系列调用的末尾调用 Circuit 函数怎么办?为此,我们将使用 函数后置 实现。遗憾的是,它有些麻烦,因为它需要使用计时器函数来判断自上次调用以来是否已过去足够的时间,并且只有在时间已过后才调用 circuit

type Circuit func(context.Context) (string, error)

func DebounceLast(circuit Circuit, d time.Duration) Circuit {
    var m sync.Mutex
    var timer *time.Timer
    var cctx context.Context
    var cancel context.CancelFunc

    return func(ctx context.Context) (string, error) {
        m.Lock()

        if timer != nil {
            timer.Stop()
            cancel()
        }

        cctx, cancel = context.WithCancel(ctx)
        ch := make(chan struct {
            result string
            err    error
        }, 1)

        timer = time.AfterFunc(d, func() {
            r, e := circuit(cctx)
            ch <- struct {
                result string
                err    error
            }{r, e}
        })

        m.Unlock()

        select {
        case res := <-ch:
            return res.result, res.err
        case <-cctx.Done():
            return "", cctx.Err()
        }
    }
}

在这个实现中,DebounceLast 调用使用 time.AfterFunc 在指定的持续时间后执行 circuit 函数。这个有用的函数允许我们在特定持续时间后调用任意函数。它还提供了一个 time.Timer 值,可以用来取消计时器。这正是我们需要的:你会注意到,在启动新计时器之前,任何已经存在的计时器都被停止(通过 Stop 方法),确保 circuit 只会被调用一次。

你可能注意到,我们还使用了一个通道来发送一个匿名结构(是的,你可以这么做!)。这不仅允许我们传输 circuit 函数的两个返回值,还便捷地让我们使用 select 语句来适当地响应上下文取消事件。

重试(Retry)

重试通过透明地重试失败的操作来处理分布式系统中的可能的瞬时故障。

适用性

在处理复杂的分布式系统时,瞬时错误是不可避免的事实。这些错误可能是由许多(希望是临时的)条件引起的,特别是如果下游服务或网络资源采取了保护策略,如在高负载时临时拒绝请求的限流策略,或者像自动扩展等自适应策略,当需要时能够增加容量。

这些故障通常在一段时间后自行解决,因此在合理延迟后重试请求可能(但不能保证)会成功。如果不考虑瞬时故障,可能导致系统过于脆弱。另一方面,实施自动重试策略可以显著提高服务的稳定性,从而使其本身及其上游消费者受益。

警告

重试应仅用于幂等操作。如果你不熟悉幂等性的概念,我们将在《什么是幂等性,为什么它很重要?》中详细讲解。

参与者

此模式包括以下参与者:

  • Effector
    与服务交互的函数。
  • Retry
    一个接受 Effector 并返回一个具有与 Effector 相同函数签名的闭包的函数。

实现

该模式与熔断器模式(Circuit Breaker)或防抖模式(Debounce)类似,采用了派生函数类型 Effector 来定义函数签名。这个签名可以采取适合你实现的任何形式,但当执行潜在失败操作的函数实现时,它必须匹配 Effector 定义的签名。

Retry 函数接受用户定义的 Effector 函数,并返回一个包装用户定义函数的 Effector 函数,以提供重试逻辑。除了用户定义的函数,Retry 还接受一个整数,描述它将尝试的最大重试次数,以及一个 time.Duration,描述每次重试之间等待的时间。如果重试参数为 0,则重试逻辑实际上将成为一个空操作(no-op)。

注意

尽管这里没有包括,大多数重试实现会包含某种回退(backoff)逻辑。

示例代码

Retry 函数的函数参数签名是 Effector。它与之前模式中的函数类型完全相同:

type Effector func(context.Context) (string, error)

Retry 函数本身相对简单,至少相比本章前面看到的函数来说是这样的:

func Retry(effector Effector, maxRetries int, delay time.Duration) Effector {
    return func(ctx context.Context) (string, error) {
        for r := 0; ; r++ {
            response, err := effector(ctx)
            if err == nil || r >= maxRetries {
                return response, err
            }

            log.Printf("Attempt %d failed; retrying in %v", r + 1, delay)

            select {
            case <-time.After(delay):
            case <-ctx.Done():
                return "", ctx.Err()
            }
        }
    }
}

你可能已经注意到,Retry 函数保持简洁的原因:虽然它返回一个函数,但该函数没有任何外部状态。这意味着我们不需要复杂的机制来管理并发。

注意 select 语句块的内容,它展示了在Go中实现基于 time.After 函数的通道读取超时的常见习惯用法,类似于《实现通道超时》中的示例。这个非常有用的函数返回一个通道,在指定时间过去后发送一个消息,这会激活它的 case 并结束当前的重试循环。

为了使用 Retry,我们可以实现执行可能失败的操作的函数,且其签名必须与 Effector 类型匹配;在以下示例中,EmulateTransientError 扮演了这个角色:

var count int

func EmulateTransientError(ctx context.Context) (string, error) {
    count++

    if count <= 3 {
        return "intentional fail", errors.New("error")
    } else {
        return "success", nil
    }
}

func main() {
    r := Retry(EmulateTransientError, 5, 2 * time.Second)

    res, err := r(context.Background())

    fmt.Println(res, err)
}

main 函数中,EmulateTransientError 函数被传递给 Retry,并提供了一个函数变量 r。当 r 被调用时,EmulateTransientError 会被调用,如果它返回一个错误,重试逻辑会在延迟后再次调用它。最终,在第四次尝试之后,EmulateTransientError 返回 nil 错误,Retry 函数退出。

节流(Throttle)

节流限制函数调用的频率,将每单位时间内的调用次数限制在某个最大值。

适用性

节流模式的名称来源于一种用于管理流体流动的装置,例如控制汽车引擎中燃料流量的节流阀。与其名字所示的机制类似,节流限制了函数在一段时间内可以被调用的次数。例如:

  • 用户每秒只能发起 10 次服务请求。
  • 客户端可能会限制自己每 500 毫秒调用一次特定函数。
  • 一个账户在 24 小时内只能尝试登录失败 3 次。

应用节流的最常见原因是为了应对可能会使系统饱和的剧烈活动波动,这些波动可能导致系统收到不合理数量的请求,可能会非常昂贵,或导致服务降级,最终甚至崩溃。虽然系统可能可以通过扩展增加足够的容量来满足用户需求,但这需要时间,系统可能无法及时做出反应。

参与者

此模式包括以下参与者:

  • Effector
    需要被调节的函数。
  • Throttle
    一个接受 Effector 并返回一个具有与 Effector 相同函数签名的闭包的函数。

节流和防抖的区别是什么

从概念上看,节流(Throttle)和防抖(Debounce)似乎非常相似。毕竟,它们都是关于减少单位时间内的调用次数。然而,正如图4-4所示,它们的精确时机有很大的不同:

  • 节流 像汽车中的节流阀,限制进入发动机的燃料量,通过将燃料流量限制在某个最大速率来实现。图4-4中展示了这一点:无论输入函数被调用多少次,节流都只允许每单位时间内进行固定次数的调用。
  • 防抖 则关注于一组活动,确保在一组请求中函数只会被调用一次,通常是在请求的开始或结束时。图4-4中展示了一个函数优先的防抖实现:对于输入函数的两组调用,防抖只允许在每组的开始(或结束)时执行一次调用。

image.png

实现

节流模式与本章描述的许多其他模式相似:它被实现为一个接受效应函数(effector function)并返回一个节流闭包的函数,闭包具有相同的签名,并提供速率限制逻辑。

实现速率限制行为的最常见算法是令牌桶(token bucket),它使用一个可以容纳最大令牌数量的桶作为类比。当函数被调用时,桶中的一个令牌会被取出,桶以固定速率重新填充。

当令牌桶中没有足够的令牌来支付请求时,节流处理请求的方式可以根据开发者的需求有所不同。一些常见的策略如下:

  • 返回错误
    这是最基本的策略,通常用于限制不合理或可能滥用的客户端请求数量。采用此策略的 RESTful 服务可能会返回状态码 429(请求过多)。
  • 重放最后一次成功的函数调用响应
    当服务或昂贵的函数调用如果过早调用可能提供相同的结果时,这种策略非常有用。它在 JavaScript 世界中常见。
  • 将请求排队,等待足够的令牌后再执行
    当你希望最终处理所有请求时,这种方法非常有用,但它也更复杂,可能需要特别注意以确保内存不会被耗尽。

示例代码

以下示例实现了一个基本的“令牌桶”算法,采用了“错误”策略:

type Effector func(context.Context) (string, error)

func Throttle(e Effector, max uint, refill uint, d time.Duration) Effector {
    var tokens = max
    var once sync.Once
    var m sync.Mutex

    return func(ctx context.Context) (string, error) {
        if ctx.Err() != nil {
            return "", ctx.Err()
        }

        once.Do(func() {
            ticker := time.NewTicker(d)

            go func() {
                defer ticker.Stop()

                for {
                    select {
                    case <-ctx.Done():
                        return

                    case <-ticker.C:
                        m.Lock()
                        t := tokens + refill
                        if t > max {
                            t = max
                        }
                        tokens = t
                        m.Unlock()
                    }
                }
            }()
        })

        m.Lock()
        defer m.Unlock()

        if tokens <= 0 {
            return "", fmt.Errorf("too many calls")
        }

        tokens--

        return e(ctx)
    }
}

这个 Throttle 实现与我们之前的示例相似,它通过一个闭包包装了效应函数 e,并包含了速率限制逻辑。桶最初分配了 max 个令牌;每次触发闭包时,它会检查是否还有剩余的令牌。如果有令牌,则令牌计数减一并触发效应函数。如果没有,则返回错误。令牌以每个持续时间 d 填充 refill 个令牌。

超时(Timeout)

超时允许一个进程在确认没有答案时停止等待。

适用性

分布式计算的第一个谬误是“网络是可靠的”,并且它之所以排在第一位是有原因的。交换机故障、路由器和防火墙配置错误、数据包丢失。即使网络正常工作,并非每个服务都足够周到,能够在故障发生时保证及时且有意义的响应——甚至是任何响应。

超时是解决这一困境的常见方法,它的简单性如此美妙,以至于几乎不算作一个模式:对于一个运行时间超过预期的服务请求或函数调用,调用方只需要……停止等待。

然而,不要把“简单”或“常见”误解为“无用”。相反,超时策略的普遍应用证明了它的有效性。合理使用超时可以提供一定程度的故障隔离,防止连锁故障,并减少下游资源出现问题时,问题蔓延到你的系统中的机会。

参与者

此模式包括以下参与者:

  • Client(客户端)
    希望执行 SlowFunction 的客户端。
  • SlowFunction(长时间运行的函数)
    实现客户端所需功能的长时间运行函数。
  • Timeout(超时)
    包装 SlowFunction 的函数,执行超时逻辑。

实现

在 Go 中实现超时有多种方式,但最符合惯用法的方法是使用 context 包提供的功能。更多信息请参考《The Context Package》。

在理想的情况下,任何可能需要长时间运行的函数都会直接接受一个 context.Context 参数。如果是这样,你的工作就相当简单:你只需要传递一个通过 context.WithTimeout 装饰的 Context 值:

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

result, err := SomeFunction(ctx)

然而,情况并不总是如此,尤其是对于第三方库,你并不总能选择重构使其接受 Context 值。在这种情况下,最好的做法可能是以某种方式包装函数调用,使其能够遵循你的 Context

例如,假设你有一个可能长时间运行的函数,它不仅不接受 Context 值,还来自一个你无法控制的包。如果客户端直接调用 SlowFunction,它将被迫等待直到函数完成,或者可能永远不会完成。那该怎么办?

你可以选择不直接调用 SlowFunction,而是在一个 goroutine 中调用它。这样,你可以在函数在合理时间内返回结果时捕获它,如果没有返回结果,你也可以继续进行其他操作。

警告

超时并不会真正取消 SlowFunction。如果它最终没有结束,结果将是一个 goroutine 泄漏。有关此现象的更多信息,请参阅《Goroutines》。

为了实现这一点,我们可以利用一些已经介绍过的工具:context.Context 用于超时,通道(channels)用于通信结果,select 用于捕获哪个先发生。

示例代码

以下示例假设存在一个虚构的函数 Slow,其执行可能会或可能不会在合理时间内完成,其签名符合以下类型定义:

type SlowFunction func(string) (string, error)

我们不直接调用 Slow,而是提供一个 Timeout 函数,该函数将提供的 SlowFunction 包装在一个闭包中,并返回一个 WithContext 函数,这个函数将 context.Context 添加到 SlowFunction 的参数列表中:

type WithContext func(context.Context, string) (string, error)

func Timeout(f SlowFunction) WithContext {
    return func(ctx context.Context, arg string) (string, error) {
        ch := make(chan struct {
            result string
            err    error
        }, 1)

        go func() {
            res, err := f(arg)
            ch <- struct {
                result string
                err    error
            }{res, err}
        }()

        select {
        case res := <-ch:
            return res.result, res.err
        case <-ctx.Done():
            return "", ctx.Err()
        }
    }
}

Timeout 构造的函数中,SlowFunction 在一个 goroutine 中运行,假设它在规定时间内完成,并且其返回值被包装在一个结构体中,发送到为此目的构造的通道中。

接下来的 select 语句监听两个通道:SlowFunction 的响应通道和 Context 值的 Done 通道。如果前者先完成,闭包将返回 SlowFunction 的返回值;否则,它将返回 Context 提供的错误。

小贴士

如果 SlowFunction 很慢是因为它很昂贵,改进方法之一是,在调用 goroutine 之前检查 ctx.Err() 是否返回非 nil 值。

使用 Timeout 函数并不比直接调用 Slow 复杂,只是我们有两个函数调用:一个是调用 Timeout 来获取闭包,另一个是调用闭包本身:

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()

    timeout := Timeout(Slow)
    res, err := timeout(ctx, "some input")

    fmt.Println(res, err)
}

最后,虽然通常建议使用 context.Context 来实现服务超时,但也可以使用 time.After 函数提供的通道来实现通道超时。有关如何实现这一点的示例,请参见《实现通道超时》。

并发模式(Concurrency Patterns)

云原生服务通常需要高效地处理多个进程并应对高负载(并且负载可能高度变化),理想情况下无需扩展系统。为此,服务需要具备高度并发的能力,能够处理来自多个客户端的多个同时请求。尽管 Go 以其并发支持著称,但瓶颈仍然可能发生。为了防止瓶颈,许多模式已经被开发出来,本文将介绍其中的一些。

为了简化,本节中的代码示例未实现上下文取消功能。通常,你应该能够轻松地自行添加此功能。

风扇合并(Fan-In)

风扇合并(Fan-In)将多个输入通道复用到一个输出通道。

适用性

有一些服务可能有多个工作进程,每个进程都生成输出,这时将所有工作进程的输出合并成一个统一的流进行处理可能会很有用。对于这些场景,我们使用风扇合并模式,通过将多个输入通道复用到一个目标通道上来读取多个输入通道的内容。

参与者

此模式包括以下参与者:

  • Sources(源)
    一组具有相同类型的输入通道,由 Funnel 接受。
  • Destination(目标)
    与 Sources 相同类型的输出通道,由 Funnel 创建和提供。
  • Funnel(汇合器)
    接受 Sources,并立即返回 Destination。任何来自 Sources 的输入都会被输出到 Destination。

实现

Funnel 是一个函数,接收零个到多个输入通道(Sources)。对于每个输入通道,Funnel 函数启动一个独立的 goroutine,从分配的通道中读取值并将其转发到所有 goroutine 共享的单个输出通道(Destination)。

示例代码

Funnel 函数是一个变参函数,接收零个到多个某种类型的通道(在以下示例中为 int 类型):

func Funnel(sources ...<-chan int) <-chan int {
    dest := make(chan int)         // 共享输出通道

    wg := sync.WaitGroup{}         // 用于在所有源通道关闭时自动关闭 dest

    wg.Add(len(sources))           // 设置 WaitGroup 的大小

    for _, ch := range sources {   // 为每个源通道启动一个 goroutine
        go func(ch <-chan int) {
            defer wg.Done()        // 当 ch 关闭时通知 WaitGroup

            for n := range ch {
                dest <- n
            }
        }(ch)
    }

    go func() {                    // 启动一个 goroutine 来关闭 dest
        wg.Wait()                  // 在所有源通道关闭后关闭 dest
        close(dest)
    }()

    return dest
}

Funnel 函数中,每个源通道都启动一个专门的 goroutine,读取源通道中的值,并将其转发到 dest,这是所有 goroutine 共享的单个输出通道。

注意 sync.WaitGroup 的使用,它确保目标通道在适当的时候关闭。最初,创建一个 WaitGroup 并设置为源通道的总数。如果一个通道关闭,相关的 goroutine 会退出并调用 wg.Done。当所有通道关闭时,WaitGroup 的计数器达到零,wg.Wait 施加的锁被释放,dest 通道被关闭。

使用 Funnel 非常简单:给定 N 个源通道(或一个 N 个通道的切片),将这些通道传递给 Funnel。返回的目标通道可以像往常一样读取,并且在所有源通道关闭时会自动关闭:

func main() {
    var sources []<-chan int            // 声明一个空的通道切片

    for i := 0; i < 3; i++ {
        ch := make(chan int)
        sources = append(sources, ch)   // 创建一个通道,并添加到 sources 中

        go func() {                     // 为每个源启动一个 goroutine
            defer close(ch)             // 当 goroutine 结束时关闭通道

            for i := 1; i <= 5; i++ {
                ch <- i
                time.Sleep(time.Second)
            }
        }()
    }

    dest := Funnel(sources...)
    for d := range dest {
        fmt.Println(d)
    }
}

此示例创建了三个 int 类型的通道切片,每个通道发送值 1 到 5 后关闭。在一个独立的 goroutine 中,打印单一的目标通道 dest 输出的结果。运行此程序时,最终将打印出 15 行结果,之后 dest 被关闭,函数结束。

风扇分发(Fan-Out)

风扇分发将输入通道中的消息均匀分配到多个输出通道。

适用性

风扇分发从一个输入通道接收消息,并将它们均匀地分配到多个输出通道。这是一个在并行化 CPU 和 I/O 使用时非常有用的模式。

例如,假设你有一个输入源,比如输入流上的 Reader 或者消息代理上的监听器,这些输入为一些资源密集型的工作单元提供数据。与其将输入和计算过程耦合在一起(这会将工作限制为一个单一的串行过程),你可能更希望通过将工作负载分配给多个并发的工作进程来并行化工作负载。

参与者

此模式包括以下参与者:

  • Source(源)
    一个输入通道,由 Split 接受。
  • Destinations(目标)
    Source 相同类型的输出通道,由 Split 创建和提供。
  • Split(分割器)
    接受 Source 并立即返回 Destinations。任何来自 Source 的输入都会被输出到一个 Destination

实现

风扇分发在概念上可能比较直接,但实现的细节需要注意。

通常,风扇分发被实现为一个 Split 函数,它接受一个单一的 Source 通道和一个表示期望的目标通道数量的整数。Split 函数创建目标通道并启动一些后台进程,从 Source 通道中读取值并将其转发到其中一个目标通道。

转发逻辑的实现可以通过两种方式进行:

  1. 使用一个单独的 goroutine 从 Source 读取并以轮询方式将数据转发到目标通道。这种方式的优点是只需要一个主 goroutine,但如果下一个通道还没有准备好读取,整个过程会变慢。
  2. 为每个目标通道使用独立的 goroutine,这些 goroutine 竞争从 Source 读取下一个值,并将其转发到各自的目标通道。虽然这种方式需要更多的资源,但它不容易被单个运行缓慢的工作进程拖慢。

以下示例使用了第二种方法。

示例代码

在这个例子中,Split 函数接受一个接收-only 通道 source 和一个整数 n,描述将输入分割成的通道数量。它返回一个包含 n 个与 source 相同类型的发送-only 通道的切片。

内部,Split 创建目标通道。对于每个创建的通道,它会启动一个 goroutine,循环从 source 中读取值并将其转发到分配给的输出通道。实际上,每个 goroutine 都会竞争从 source 中读取数据;如果多个 goroutine 试图读取,"赢家" 会被随机确定。如果 source 被关闭,所有 goroutine 会终止,并且所有目标通道会被关闭:

func Split(source <-chan int, n int) []<-chan int {
    var dests []<-chan int              // 声明 dests 切片

    for i := 0; i < n; i++ {            // 创建 n 个目标通道
        ch := make(chan int)
        dests = append(dests, ch)

        go func() {                     // 为每个通道启动一个专用的 goroutine
            defer close(ch)             // 每个 goroutine 竞争读取

            for val := range source {
                ch <- val
            }
        }()
    }

    return dests
}

给定某种类型的通道,Split 函数会返回多个目标通道。通常,每个目标通道会被传递给一个独立的 goroutine,如下例所示:

func main() {
    source := make(chan int)         // 输入通道
    dests := Split(source, 5)        // 获取 5 个输出通道

    go func() {                      // 将数字 1..10 发送到 source
        for i := 1; i <= 10; i++ {   // 并在完成后关闭它
            source <- i
        }

        close(source)
    }()

    var wg sync.WaitGroup            // 使用 WaitGroup 等待直到
    wg.Add(len(dests))               // 所有输出通道关闭

    for i, d := range dests {
        go func(i int, d <-chan int) {
            defer wg.Done()

            for val := range d {
                fmt.Printf("#%d got %d\n", i, val)
            }
        }(i, d)
    }

    wg.Wait()
}

这个示例创建了一个输入通道 source,并将其传递给 Split 获取输出通道。并发地,它将值 1 到 10 发送到 source 中,而其他 5 个 goroutine 从 dests 中接收值并打印。当输入完成后,source 通道关闭,这触发了目标通道的关闭,结束了读取循环,调用每个读取 goroutine 的 wg.Done,释放 wg.Wait 上的锁,最终结束函数。

Future 模式

Future 模式为尚未确定的值提供一个占位符。

适用性

Futures(也称为 Promises 或 Delays)是一个同步构造,提供一个占位符,用于表示仍由异步进程生成的值。

在 Go 中,Future 模式并不像在一些其他语言中那样常用,因为通道(channels)通常可以以类似的方式使用。例如,长时间运行的阻塞函数 BlockingInverse(未显示)可以在一个 goroutine 中执行,当结果到达时,通过通道返回结果。接下来的 ConcurrentInverse 函数就是实现这一功能的,它返回一个通道,结果可以在通道上读取:

func ConcurrentInverse(m Matrix) <-chan Matrix {
    out := make(chan Matrix)

    go func() {
        out <- BlockingInverse(m)
        close(out)
    }()

    return out
}

使用 ConcurrentInverse,你可以构建一个函数来计算两个矩阵的逆积:

func InverseProduct(a, b Matrix) Matrix {
    inva := ConcurrentInverse(a)
    invb := ConcurrentInverse(b)

    return Product(<-inva, <-invb)
}

这看起来并不复杂,但它有一些缺点,使得它不适合像公共 API 这样的场景。首先,调用方必须小心何时调用 ConcurrentInverse。为了说明这一点,仔细看看以下代码:

return Product(<-ConcurrentInverse(a), <-ConcurrentInverse(b))

你看到了问题吗?由于计算直到 ConcurrentInverse 被调用时才开始,这种构造会实际上串行执行,导致运行时间增加一倍。

此外,当以这种方式使用通道时,返回多个值的函数通常会为返回列表中的每个成员分配一个专用通道,这在返回列表增长或需要多个 goroutine 读取值时可能会变得复杂。

Future 模式通过将复杂性封装在一个 API 中,向消费者提供一个简单的接口,该接口的方法可以像普通方法一样调用,直到所有结果解决,阻塞所有调用的 goroutine。返回的值的接口不必专门为此目的构造;任何方便消费者使用的接口都可以。

参与者

此模式包括以下参与者:

  • Future
    由消费者接收的接口,用于检索最终的结果。
  • SlowFunction
    一个包装函数,用于异步执行某些功能,提供 Future
  • InnerFuture
    满足 Future 接口的结构体,包含一个方法,处理结果访问逻辑。

实现

提供给消费者的 API 非常简单:程序员调用 SlowFunction,它返回一个满足 Future 接口的值。Future 可以是一个定制接口,如下面的例子,或者像 io.Reader 这样的接口,也可以传递给它自己的函数。

实际上,当调用 SlowFunction 时,它在一个 goroutine 中执行核心函数。在此过程中,它定义通道以捕获核心函数的输出,并将其包装在 InnerFuture 中。

InnerFuture 有一个或多个方法,这些方法满足 Future 接口,检索通过通道返回的值,将其缓存,并返回它们。如果通道中的值不可用,请求将被阻塞。如果它们已经被检索,缓存的值将被返回。

示例代码

在这个例子中,我们使用一个 Future 接口,InnerFuture 将满足这个接口:

type Future interface {
    Result() (string, error)
}

InnerFuture 结构体用于提供并发功能。在此示例中,它满足 Future 接口,但如果你愿意,也可以通过附加一个 Read 方法来选择满足像 io.Reader 这样的接口:

type InnerFuture struct {
    once sync.Once
    wg   sync.WaitGroup

    res   string
    err   error
    resCh <-chan string
    errCh <-chan error
}

func (f *InnerFuture) Result() (string, error) {
    f.once.Do(func() {
        f.wg.Add(1)
        defer f.wg.Done()
        f.res = <-f.resCh
        f.err = <-f.errCh
    })

    f.wg.Wait()

    return f.res, f.err
}

在此实现中,结构体本身包含一个通道和一个用于存储每个返回值的变量。当第一次调用 Result 时,它会尝试从通道读取结果并将它们返回到 InnerFuture 结构体,以便后续调用 Result 可以立即返回缓存的值。

注意 sync.Oncesync.WaitGroup 的使用。sync.Once 确保传递给它的函数仅调用一次。WaitGroup 用于使该函数调用线程安全:第一次调用之后的任何调用都会在 wg.Wait 上被阻塞,直到通道读取完成。

SlowFunction 是一个包装函数,用于并发地执行核心功能。它的任务是创建结果通道,启动核心函数的 goroutine,并创建并返回 Future 实现(在此示例中为 InnerFuture):

func SlowFunction(ctx context.Context) Future {
    resCh := make(chan string)
    errCh := make(chan error)

    go func() {
        select {
        case <-time.After(2 * time.Second):
            resCh <- "I slept for 2 seconds"
            errCh <- nil
        case <-ctx.Done():
            resCh <- ""
            errCh <- ctx.Err()
        }
    }()

    return &InnerFuture{resCh: resCh, errCh: errCh}
}

要使用此模式,你只需要调用 SlowFunction 并像使用任何其他值一样使用返回的 Future

func main() {
    ctx := context.Background()
    future := SlowFunction(ctx)

    // 在 SlowFunction 在后台进行处理时做其他事情。

    res, err := future.Result()
    if err != nil {
        fmt.Println("error:", err)
        return
    }

    fmt.Println(res)
}

这种方法提供了一个合理的用户体验。程序员可以创建一个 Future 并按需访问它,甚至可以使用 Context 应用超时或截止日期。

分片(Sharding)

分片将一个大的数据结构拆分成多个分区,以局部化读写锁的影响。

适用性

“分片”这个术语通常用于描述分布式状态中的数据,将数据分区到多个服务器实例中。这种水平分片(Horizontal Sharding)通常被数据库和其他数据存储系统使用,用来分散负载并提供冗余。

在一些情况下,高并发服务可能面临共享数据结构的问题,这些数据结构具有锁机制,用于保护它们免受冲突写操作的影响。在这种情况下,虽然锁是为了确保数据的完整性,但当进程花费更多时间等待锁而非执行任务时,锁可能会成为瓶颈。这种不幸的现象称为锁竞争(lock contention)。

虽然在某些情况下可以通过扩展实例数量来解决这一问题,但这也增加了复杂性和延迟,因为需要建立分布式锁并确保写操作的一致性。减少共享数据结构上的锁竞争的一种替代策略是垂直分片(Vertical Sharding),其中将一个大的数据结构分割成两个或更多的结构,每个结构表示整个数据的一部分。使用这种策略,每次只需要锁定整体结构的一部分,从而减少整体的锁竞争。

水平分片与垂直分片

大数据结构可以通过两种方式进行分片或分区:

  • 水平分片(Horizontal Sharding)
    将数据分布在服务实例之间。它可以提供数据冗余并允许在实例之间平衡负载,但也会增加与分布式数据相关的延迟和复杂性。
  • 垂直分片(Vertical Sharding)
    在单一实例内部对数据进行分区。这可以减少并发进程之间的读写竞争,但也无法扩展或提供冗余。

参与者

此模式包括以下参与者:

  • ShardedMap
    一个抽象层,围绕一个或多个分片提供读写访问,就像这些分片是一个单一的映射(map)。
  • Shard
    一个可单独锁定的集合,表示一个数据分区。

实现

虽然惯用 Go 的方法通常倾向于通过通道共享内存,而不是使用锁来保护共享资源,但这并非总是可能的。映射(map)特别不安全,不能并发使用,因此使用锁作为同步机制是不可避免的恶性做法。幸运的是,Go 提供了 sync.RWMutex 来解决这个问题。你可能还记得在《互斥锁(Mutexes)》中提到过 sync.RWMutex

这种策略通常可以很好地工作。然而,由于锁每次只允许一个进程访问,读写密集型应用程序中,等待锁释放的时间随着并发进程数量的增加可能会显著增加。最终,锁竞争可能会成为关键功能的瓶颈。

垂直分片通过将底层数据结构(通常是一个映射)拆分成多个可以单独锁定的映射来减少锁竞争。一个抽象层提供对底层分片的访问,就像它们是一个单一结构一样(参见图 4-5)。

image.png

内部实现通过创建一个抽象层来封装本质上是“映射的映射”的数据结构。每当读取或写入该映射抽象时,会为键计算一个哈希值,并将该值对分片数进行取模操作,以生成分片索引。这使得映射抽象能够将必要的锁定仅限于该索引对应的分片。

示例代码

在以下示例中,我们使用标准的 synchash/fnv 包来实现一个基本的分片映射:ShardedMap
内部实现中,ShardedMap 只是一个指向若干个 Shard 值的指针切片,但我们将其定义为一个类型,以便为其附加方法。每个 Shard 包含一个 map[string]any,用于存储该分片的数据,并且包含一个复合的 sync.RWMutex,使其可以被单独锁定:

type Shard[K comparable, V any] struct {
    sync.RWMutex    // 从 sync.RWMutex 复合而来
    items map[K]V    // m 包含分片的数据
}

type ShardedMap[K comparable, V any] []*Shard[K, V]

Go 没有类似 Java 的构造函数,因此我们提供了一个 NewShardedMap 函数来获取一个新的 ShardedMap

func NewShardedMap[K comparable, V any](nshards int) ShardedMap[K, V] {
    shards := make([]*Shard[K, V], nshards)  // 初始化一个 *Shards 切片

    for i := 0; i < nshards; i++ {
        shard := make(map[K]V)
        shards[i] = &Shard[K, V]{items: shard}  // ShardedMap 就是一个切片!
    }

    return shards
}

ShardedMap 有两个未公开的方法 getShardIndexgetShard,分别用于计算键的分片索引和获取键对应的分片。这两个方法可以轻松合并成一个,但这样分开使得它们更易于测试:

func (m ShardedMap[K, V]) getShardIndex(key K) int {
    str := reflect.ValueOf(key).String()  // 获取键的字符串表示
    hash := fnv.New32a()                  // 获取哈希实现
    hash.Write([]byte(str))               // 将字节写入哈希
    sum := int(hash.Sum32())              // 获取结果校验和
    return sum % len(m)                   // 对 len(m) 取模得到索引
}

func (m ShardedMap[K, V]) getShard(key K) *Shard[K, V] {
    index := m.getShardIndex(key)
    return m[index]
}

为了获取索引,我们首先计算值的字符串表示的哈希值,然后通过对分片数取模得到最终结果。虽然这种方式并不是最有效的计算哈希的方法,但它在概念上简单,并且对于示例来说足够好。
具体的哈希算法其实并不重要——我们在这个例子中使用了 FNV-1a 哈希函数——只要它是确定性的,并且提供合理均匀的值分布。

最后,我们为 ShardedMap 添加了方法,以允许用户读取和写入值。显然,这些方法没有展示映射所需的所有功能。此示例的源代码可以在与本书相关的 GitHub 仓库中找到,您可以将其作为练习来实现其他功能,比如 DeleteContains 方法:

func (m ShardedMap[K, V]) Get(key K) V {
    shard := m.getShard(key)
    shard.RLock()
    defer shard.RUnlock()

    return shard.items[key]
}

func (m ShardedMap[K, V]) Set(key K, value V) {
    shard := m.getShard(key)
    shard.Lock()
    defer shard.Unlock()

    shard.items[key] = value
}

当您需要为所有表格建立锁时,通常最好是并发进行。以下是一个使用 goroutine 和我们老朋友 sync.WaitGroup 实现的 Keys 函数:

func (m ShardedMap[K, V]) Keys() []K {
    var keys []K                           // 声明一个空的 keys 切片
    var mutex sync.Mutex                   // 锁定写入操作的键

    var wg sync.WaitGroup                  // 创建一个等待组,并为每个切片添加一个等待值
    wg.Add(len(m))                         // 为每个切片添加等待值

    for _, shard := range m {              // 为 m 中的每个切片运行一个 goroutine
        go func(s *Shard[K, V]) {
            s.RLock()                      // 为 s 建立一个读锁

            defer wg.Done()                // 释放读锁
            defer s.RUnlock()              // 告诉 WaitGroup 操作完成

            for key, _ := range s.items {  // 获取切片的所有键
                mutex.Lock()
                keys = append(keys, key)
                mutex.Unlock()
            }
        }(shard)
    }

    wg.Wait()                              // 阻塞,直到所有 goroutine 完成

    return keys                            // 返回合并后的键切片
}

使用 ShardedMap 并不像使用标准映射那么直接,但虽然它有所不同,却并不比标准映射复杂:

func main() {
    m := NewShardedMapstring{"alpha", "beta", "gamma"}

    for i, k := range keys {
        m.Set(k, i+1)

        fmt.Printf("%5s: shard=%d value=%d\n",
            k, m.getShardIndex(k), m.Get(k))
    }

    fmt.Println(m.Keys())
}

输出:

alpha: shard=3 value=1
 beta: shard=2 value=2
gamma: shard=0 value=3
[gamma beta alpha]

在 Go 1.21 之前(以及本书之前的版本),ShardedMap 必须构造为任意类型的值,至少在它需要重用时是这样做的。然而,这会失去类型安全性,且需要进行类型断言。幸运的是,随着 Go 泛型的发布,这个问题已经得到解决。

工作池(Worker Pool)

工作池是一个模式,它通过多个进程并发地执行一组输入的工作。

适用性

工作池(在其他语言中通常称为“线程池”)可能是本章中最常用的模式之一。它是一个在许多语言中使用的模式,通过使用固定数量的工作进程高效地管理多个并发任务的执行。
工作池非常适合管理那些希望并发执行的任务,但也有其限制。常见的应用场景包括处理多个传入请求、处理任务队列、数据管道中的步骤处理,以及执行长时间运行的批处理作业。

参与者

该模式包括以下参与者:

  • Worker:执行某些任务的函数,处理来自 Jobs 的项并将结果发送到 Results
  • Jobs:Worker 从中接收待处理的原始数据的通道。
  • Results:Worker 将其工作结果发送到的通道。

实现

来自其他语言的程序员可能熟悉“线程池”这一概念:一组待命的线程,准备执行任务。这是任何语言中都非常有用的并发模式,但线程池往往很繁琐,有时使用起来也很复杂。
幸运的是,Go 的并发特性——goroutines 和 channels——使得构建类似的东西特别适合。因此,我们的实现相对于许多其他语言来说非常简单:我们所谓的“工作进程”实际上只是一个 goroutine(Worker),它从输入通道(Jobs)接收输入数据,并通过输出通道(Results)返回处理结果。
这两个通道在所有工作进程之间共享,因此通过 jobs 通道发送的任何工作将由第一个可用的工作进程处理。重要的是,每个工作进程只在 jobs 通道打开时存在:一旦任务完成,它们会自动终止。

示例代码

如果你习惯于在其他语言中使用线程池,你可能会觉得我们这里的工作池示例非常简洁。个人认为,这个模式特别能展示 Go 并发特性设计的优雅。

这个模式的核心是 worker,它(顾名思义)执行任务。以下是单个 worker 函数的蓝图:

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        fmt.Println("Worker", id, "started job", j)
        time.Sleep(time.Second)
        results <- j * 2
    }
}

如你所见,这个示例中的 worker 是一个相当标准的函数,它接收一个工作通道用于接收任务,并接收一个结果通道用于返回工作结果。这个实现还接受一个 id 整数,仅用于演示目的。
这个 worker 的功能相当简单,它从 jobs 通道读取一个整数值(经过短暂的延时来模拟工作),然后通过结果通道写出该值的两倍。注意,当 jobs 通道关闭时,它会自动退出。

警告
你应该始终知道你创建的任何 goroutine 会如何以及何时终止,否则你可能会引入 goroutine 泄漏。

现在,如果我们只使用一个 worker,那么这和逐个遍历输入并进行计算并没有太大区别。但当然,正如你可能已经猜到的,我们不会只使用一个 worker:

func main() {
    jobs := make(chan int, 10)
    results := make(chan int)
    wg := sync.WaitGroup{}

    for w := 1; w <= 3; w++ {          // 启动 3 个 worker 进程
        go worker(w, jobs, results)
    }

    for j := 1; j <= 10; j++ {         // 将任务发送给 worker
        wg.Add(1)
        jobs <- j
    }

    go func() {
        wg.Wait()
        close(jobs)
        close(results)
    }()

    for r := range results {
        fmt.Println("Got result:", r)
        wg.Done()
    }
}

在这个示例中,我们首先创建了两个通道:jobs 用于放入工作,results 用于接收完成的工作结果。我们还创建了一个 sync.WaitGroup,它可以让我们在所有工作完成之前暂停。

接下来是有趣的部分:我们使用 go 关键字启动三个工作进程,并将两个通道传递给每个进程。此时我们还没有给它们分配任务,因此它们都会被阻塞,等待任务。

现在我们已经有了工作进程,我们通过 jobs 通道向它们发送一些工作单元,每个工作单元会被三个工作进程中的一个接收并处理。最后,我们从结果通道中读取结果,直到它被 goroutine 关闭。

这个模式可能看起来很简单,没关系。但它展示了如何仅通过几个通道和 goroutines 在 Go 中创建一个功能完善的工作池。

Chord模式

Chord(或Join)模式执行从每个通道组成员的消息的原子消费。

适用性

在音乐中,和弦是多个音符一起发声以产生和谐的组合。与其同名的音乐概念类似,Chord模式由多个信号组成,这些信号一起通过通道发送,以产生单一的输出。
更具体地说,Chord模式从多个通道接收输入,但只有当所有通道都有输出时,它才会发出一个值。
如果你需要在采取行动之前从多个监控数据源接收输入,这个模式可能会非常有用。

参与者

该模式包括以下参与者:

  • Sources:一组类型相同的输入通道,由Chord接受。
  • Destination:与Sources相同类型的输出通道,由Chord创建并提供。
  • Chord:接受Sources并立即返回Destination。来自任何Sources的输入都会通过Destination输出。

实现

Chord模式与“Fan-In”非常相似,因为它并行地从多个Sources通道读取输入,并将它们输出到Destination通道,但相似性仅限于此。Fan-In会立即转发所有输入,而Chord模式会等待,直到它从每个源通道收到至少一个值,然后将所有值打包到一个切片中,并通过Destination通道发送出去。

实际上,这个模式的前半部分,获取来自Sources的值并将它们发送到一个中间通道进行处理,完全看起来像是Fan-In,甚至可以认为Chord是Fan-In的扩展。

不过,正是后半部分使这个模式变得有趣。它负责跟踪哪些通道在上次输出之后发送了消息,并将每个通道的最新输入打包成一个切片,发送到Destination。

示例代码

Chord模式可能是本章中概念上最复杂的模式(这也是我把它留到最后的原因)。然而,正如前面提到的,以下示例的前半部分直接取自“Fan-In”模式。
由于你(希望)已经成为Fan-In的专家,你可能会看到以下Chord函数的前半部分与Fan-In模式非常相似:

func Chord(sources ...<-chan int) <-chan []int {
    type input struct {                    // 用于在 goroutines 之间发送输入
        idx, input int                     // 从通道传递
    }

    dest := make(chan []int)                // 输出通道

    inputs := make(chan input)              // 中间通道

    wg := sync.WaitGroup{}                  // 用于在所有源通道关闭时关闭通道
    wg.Add(len(sources))                    // 添加源通道的数量

    for i, ch := range sources {            // 为每个源通道启动一个 goroutine
        go func(i int, ch <-chan int) {
            defer wg.Done()                 // 当 ch 关闭时通知 WaitGroup

            for n := range ch {
                inputs <- input{i, n}       // 将输入转移到下一个 goroutine
            }
        }(i, ch)
    }

    go func() {
        wg.Wait()                           // 等待所有源通道关闭
        close(inputs)                       // 然后关闭 inputs 通道
    }()

    go func() {
        res := make([]int, len(sources))    // 用于存储输入的切片
        sent := make([]bool, len(sources))  // 用于跟踪每个通道的发送状态
        count := len(sources)               // 通道数量计数器

        for r := range inputs {
            res[r.idx] = r.input            // 更新输入

            if !sent[r.idx] {               // 是该通道的第一次输入吗?
                sent[r.idx] = true
                count--
            }

            if count == 0 {
                c := make([]int, len(res))  // 复制并发送输入的切片
                copy(c, res)
                dest <- c

                count = len(sources)        // 重置计数器
                clear(sent)                 // 清除状态跟踪器
            }
        }

        close(dest)
    }()

    return dest
}

仔细观察,你会注意到Chord模式由三个关键部分组成。
第一部分是一个for循环,它为每个源通道启动一个goroutine。当任何一个通道接收到来自其通道的值时,它会将该通道的索引和接收到的值一起发送到中间的inputs通道,供另一个goroutine使用。当一个源通道关闭时,监视该通道的goroutine也会结束,并在过程中递减WaitGroup计数器。
接下来的部分是一个goroutine,它的唯一任务是等待WaitGroup。当所有源通道关闭时,锁被释放,中间的inputs通道被关闭。
最后,最后一个goroutine的任务是从inputs通道读取输入值,并确定所有源通道是否都已发送了值。每当从一个输入通道接收到值时,res切片会在适当的索引位置更新该值。如果这是第一次从某个通道接收到值,count计数器会递减。
count计数器为零时,发生以下几件事:res切片(它包含所有源通道的最新输入值)被复制并发送到目标通道,count计数器被重置,sent切片被清除。这样,Chord模式就准备好进入下一个读取周期。

注意,我们在发送res切片之前先复制它。这是一个安全特性,原因在于切片是指针类型,如果我们不以这种方式进行复制,后续的输入可能会导致切片在其他地方使用时被修改。

现在,让我们看看Chord模式的实际效果:

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    ch3 := make(chan int)

    go func() {
        for n := 1; n <= 4; n++ {
            ch1 <- n
            ch1 <- n * 2              // 向 ch1 发送两次数据!
            ch2 <- n
            ch3 <- n
            time.Sleep(time.Second)
        }

        close(ch1)                    // 关闭所有输入通道
        close(ch2)                    // 这会导致 res 被关闭
        close(ch3)                    // 同样地,res也会关闭
    }()

    res := Chord(ch1, ch2, ch3)

    for s := range res {              // 读取结果
        fmt.Println(s)
    }
}

此函数首先创建了一些输入通道,我们将向这些通道发送数据,看看Chord模式如何工作。
然后它启动了一个goroutine,向这些通道发送数据,发送完成后关闭所有通道。请注意,向ch1通道发送了两次数据。
运行此代码将产生一个一致的输出并正常终止:

[2 1 1]
[4 2 2]
[6 3 3]
[8 4 4]

这个输出告诉我们两件事。首先,程序正常终止,因此我们知道当所有输入通道关闭时,res通道确实关闭,最终的for循环如预期被打破。其次,输出切片中位置0的值反映了第二次发送到ch1的值,突出了这个Chord实现响应的是最新发送的值。

总结

本章介绍了几个非常有趣且有用的编程惯用法。可能还有许多其他的惯用法,但这些是我认为最重要的,或者因为它们在实际应用中非常有用,或者因为它们展示了Go语言的一些有趣特性,通常是两者兼具。

在第5章,我们将进入下一个层次,结合第3章和第4章中讨论的内容,通过从零开始构建一个简单的键值存储系统,将这些知识付诸实践!