一、问题背景

我们在开发过程中,中间件的作用非常大,它能帮我们处理很多通用的操作,比如向 Context 里写入追踪标识。追踪标识就像是一个“令牌”,可以让我们在整个系统流程里,把某个请求的所有操作串起来,方便追踪和调试。通常,我们还会开启子协程来并发处理一些任务,提高系统的性能。

但是,这里就出现问题了。有时候在开启子协程之后,发现读不到租户信息。租户信息一般就是和某个用户或者客户相关的一些数据,像用户 ID、权限信息之类的。这可就麻烦了,因为子协程可能需要这些租户信息才能正确地完成任务。后来发现,这其实和 Hertz 上下文的两个特性有关,一个是取消传播,另一个是值不可变。

二、Hertz 上下文特性介绍

2.1 取消传播

Hertz 里的上下文取消传播,简单来说就是当一个上下文被取消的时候,它下面派生出来的所有子上下文也会被取消。打个比方,就像一个将军下了撤退命令,他手下的所有士兵都得跟着撤退。

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // 创建一个可取消的上下文
    ctx, cancel := context.WithCancel(context.Background())
    // 启动一个子协程
    go func(ctx context.Context) {
        for {
            select {
            case <-ctx.Done():
                fmt.Println("子协程收到取消信号,退出")
                return
            default:
                fmt.Println("子协程正在工作...")
                time.Sleep(1 * time.Second)
            }
        }
    }(ctx)

    // 等待 3 秒后取消上下文
    time.Sleep(3 * time.Second)
    cancel()

    // 再等待 1 秒,确保子协程已经退出
    time.Sleep(1 * time.Second)
}

在这个例子里,我们创建了一个可取消的上下文 ctx,然后启动了一个子协程。子协程会不断地检查 ctx.Done() 这个通道,一旦收到取消信号,就会退出。在主函数里,我们等待 3 秒后调用 cancel() 函数取消上下文,子协程就会收到信号并退出。

2.2 值不可变

Hertz 上下文里的值是不可变的。也就是说,一旦我们在上下文里设置了一个值,就不能再修改它了。要是你想改变这个值,只能创建一个新的上下文。这就好比你在一个本子上写了一句话,不能直接把这句话改了,只能重新拿一个本子写。

package main

import (
    "context"
    "fmt"
)

func main() {
    // 创建一个基础上下文
    ctx := context.Background()
    // 在上下文中设置一个值
    ctx = context.WithValue(ctx, "key", "value1")

    // 尝试修改上下文中的值
    newCtx := context.WithValue(ctx, "key", "value2")

    // 打印原始上下文的值
    fmt.Println("原始上下文的值:", ctx.Value("key"))
    // 打印新上下文的值
    fmt.Println("新上下文的值:", newCtx.Value("key"))
}

在这个例子里,我们先创建了一个基础上下文 ctx,然后用 context.WithValue 函数设置了一个值 "value1"。接着我们又创建了一个新的上下文 newCtx,并设置了一个新的值 "value2"。最后打印两个上下文的值,可以看到原始上下文的值并没有改变。

三、问题分析

3.1 读不到租户信息的原因

因为中间件向 Context 写入追踪标识后,开启子协程时,子协程拿到的上下文是之前创建的那个,而租户信息可能是在之后才写入上下文的。又因为上下文的值不可变,子协程没办法获取到最新的租户信息。而且如果上下文被取消了,子协程也会受到影响。

3.2 错误示例

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // 创建基础上下文
    ctx := context.Background()
    // 中间件写入追踪标识
    ctx = context.WithValue(ctx, "traceID", "12345")
    // 启动子协程
    go func(ctx context.Context) {
        // 尝试读取租户信息
        tenantID := ctx.Value("tenantID")
        if tenantID != nil {
            fmt.Println("子协程读取到租户信息:", tenantID)
        } else {
            fmt.Println("子协程未读取到租户信息")
        }
    }(ctx)

    // 模拟后续写入租户信息
    time.Sleep(1 * time.Second)
    ctx = context.WithValue(ctx, "tenantID", "67890")

    // 等待子协程执行完毕
    time.Sleep(2 * time.Second)
}

在这个例子中,我们先创建了一个基础上下文 ctx,然后向里面写入了追踪标识。接着启动了一个子协程,在子协程里尝试读取租户信息。之后我们模拟后续写入租户信息,但是子协程还是读不到,因为它拿到的还是之前的上下文。

四、正确的传参方式

4.1 传递最新上下文

我们要保证子协程拿到的是包含租户信息的最新上下文。可以在写入租户信息之后再启动子协程。

package main

import (
    "context"
    "fmt"
)

func main() {
    // 创建基础上下文
    ctx := context.Background()
    // 中间件写入追踪标识
    ctx = context.WithValue(ctx, "traceID", "12345")
    // 写入租户信息
    ctx = context.WithValue(ctx, "tenantID", "67890")

    // 启动子协程
    go func(ctx context.Context) {
        // 尝试读取租户信息
        tenantID := ctx.Value("tenantID")
        if tenantID != nil {
            fmt.Println("子协程读取到租户信息:", tenantID)
        } else {
            fmt.Println("子协程未读取到租户信息")
        }
    }(ctx)

    // 等待子协程执行
    fmt.Scanln()
}

在这个例子里,我们先写入追踪标识,再写入租户信息,然后才启动子协程。这样子协程就能读取到租户信息了。

4.2 使用上下文传递工厂函数

如果我们需要在不同的地方创建子协程,并且要保证传递正确的上下文,可以使用上下文传递工厂函数。

package main

import (
    "context"
    "fmt"
)

// 创建子协程的工厂函数
func createWorker(ctx context.Context) func() {
    return func() {
        // 尝试读取租户信息
        tenantID := ctx.Value("tenantID")
        if tenantID != nil {
            fmt.Println("子协程读取到租户信息:", tenantID)
        } else {
            fmt.Println("子协程未读取到租户信息")
        }
    }
}

func main() {
    // 创建基础上下文
    ctx := context.Background()
    // 中间件写入追踪标识
    ctx = context.WithValue(ctx, "traceID", "12345")
    // 写入租户信息
    ctx = context.WithValue(ctx, "tenantID", "67890")

    // 使用工厂函数创建子协程
    worker := createWorker(ctx)
    go worker()

    // 等待子协程执行
    fmt.Scanln()
}

在这个例子中,我们定义了一个 createWorker 函数,它返回一个闭包函数。这个闭包函数会保留传入的上下文,这样在启动子协程时,就能一直使用正确的上下文。

五、应用场景

5.1 微服务架构

在微服务架构里,一个请求可能会经过多个服务。中间件会在请求开始时向 Context 写入追踪标识,方便追踪请求的整个流程。同时,每个服务可能会需要根据租户信息来做不同的处理,比如权限验证。开启子协程来并行处理任务可以提高性能,但是必须保证子协程能拿到正确的租户信息。

5.2 并发处理任务

当我们需要并发处理大量任务时,会开启很多子协程。每个任务可能都需要租户信息来完成相应的操作。如果子协程读不到租户信息,就会导致任务处理出错。

六、技术优缺点

6.1 优点

  • 上下文取消传播:可以方便地管理子协程的生命周期。当一个请求被取消或者超时,所有相关的子协程都能及时退出,避免资源浪费。
  • 值不可变:保证了上下文数据的安全性和一致性。不会出现多个协程同时修改上下文值的情况,减少了竞态条件。
  • 正确传参:确保子协程能拿到正确的信息,提高了系统的稳定性和可靠性。

6.2 缺点

  • 上下文取消传播:如果处理不当,可能会导致子协程过早退出,影响任务的正常执行。
  • 值不可变:每次修改上下文值都要创建新的上下文,会消耗一定的资源。
  • 正确传参:需要开发者对上下文有深入的理解,否则容易出错。

七、注意事项

7.1 上下文管理

要合理管理上下文的生命周期,避免出现上下文被提前取消的情况。比如在创建上下文时,要根据实际情况设置合适的超时时间。

7.2 传参时机

要确保在正确的时机传递上下文,保证子协程能拿到包含最新信息的上下文。不要在写入关键信息之前就启动子协程。

7.3 资源释放

在使用完上下文之后,要及时释放相关的资源,避免内存泄漏。

八、文章总结

在开发过程中,中间件向 Context 写入追踪标识后,开启子协程读不到租户信息的问题,主要是因为 Hertz 上下文的取消传播和值不可变特性。我们要理解这两个特性,然后选择正确的传参方式,比如传递最新上下文或者使用上下文传递工厂函数,来保证子协程能获取到正确的租户信息。同时,我们还要注意上下文的管理、传参时机和资源释放等问题。这样才能提高系统的性能和稳定性,让我们的代码更加健壮。