一、问题背景
我们在开发过程中,中间件的作用非常大,它能帮我们处理很多通用的操作,比如向 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 上下文的取消传播和值不可变特性。我们要理解这两个特性,然后选择正确的传参方式,比如传递最新上下文或者使用上下文传递工厂函数,来保证子协程能获取到正确的租户信息。同时,我们还要注意上下文的管理、传参时机和资源释放等问题。这样才能提高系统的性能和稳定性,让我们的代码更加健壮。
评论
围绕“中间件向Context写入追踪标识后,开启子协程时读不到租户信息,理解Hertz上下文取消传播与值不可变特性后选择正确的传参方式”参与讨论