Golang中如何正确使用context来跟踪连接状态

Golang中如何正确使用context来跟踪连接状态 对于服务器接收到的每个HTTP请求,我都会创建一个上下文,并将一个结构体的指针作为值添加进去:

type BaseKey string
type Tracker struct{ 
	Log []string
}
[...]
ctx := context.WithValue(r.Context(), BaseKey("someUniqueKey"), *Tracker{})
r = r.WithContext(ctx)

// (实际上我使用的是 http.Server.BaseContext,
// 但为了简单起见,我们这样表述)

然后,每次我运行一个函数时,它都会读取上下文,获取 Tracker 结构体,并在其 Tracker.Log 切片中写入要执行的操作。也就是说,在完成请求所需的整个调用树中,都会添加一个有用的字符串用于调试。当出现错误时,整个 Tracker.Log 切片会被连接起来,与错误信息拼接,并保存在服务器的本地日志中。

这听起来很棒,但我一直在想,这是否会为每个请求消耗大量内存? 假设我有1000个请求,并且它们都正常工作,那么 Tracker.Log 切片将完全被忽略,因为它只在出现错误时才需要。这就意味着产生了无用的内存消耗。

我应该担心 Tracker.Log 切片将要使用的内存量吗?要实现这个目标,我还有其他什么替代方案吗?

我想到的一个选项(但老实说,我觉得实现起来很复杂)是,Tracker.Log 切片应该只在出现错误时才被写入。也就是说,我们将从下到上回溯,当到达起点时,再记录错误。

如果我没有解释清楚,想象一下这个调用树:

-origin
	-somefunction
		-anotherfunction
		-childfunction

如果 childfunction 失败,它将在 Tracker.Log 切片中记录错误。然后 anotherfunction 将知道发生了错误,因此它会在切片中记录它试图执行的操作,并再次向 somefunction 返回一个错误,somefunction 将重复这个过程,依此类推,直到我们到达 origin,在那里我们最终关闭连接并在服务器中记录错误。这样,切片就只在出现错误时才会被填充。

这两种方法中哪一种更常见?实际上,我最初做的第一种方案甚至有效吗?你认为第一种方案的内存消耗不是很严重吗?


更多关于Golang中如何正确使用context来跟踪连接状态的实战教程也可以访问 https://www.itying.com/category-94-b0.html

16 回复

已经有日志库可以做到这一点

你使用哪些库?

更多关于Golang中如何正确使用context来跟踪连接状态的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


我很感兴趣,能否请你展示一个包含代码的示例?这是我第一次读到你所提及的内容。

不,传递一个空结构体并不会让我担心,我担心的是填充这个结构体的切片。

我刚刚了解到,runtime 包可以告诉我错误发生前进行的调用队列,我可以将这个队列连同错误信息一起写入日志。

我认为这是一个不错的方法,你怎么看?

但是将日志记录到磁盘会很慢,因为会有很多 goroutine 尝试写入日志文件。你对第二个选项有什么看法?从底层向上记录日志?另外请注意,我只想记录失败请求的轨迹,而不是所有内容。

那是我工作中的一个私有库,它是在 zap 解封装的基础上实现的(这并不重要)。我不知道是否有公开的实现,但这个带调用栈的日志仓库非常容易实现,不必过于担心走偏。

// 代码示例

不,我认为实际上会产生大量开销,包括字符串切片和存储大量日志,这预计会带来巨大的内存开销,而且这个功能并非服务业务的核心特性。

我认为将内存压力转移到磁盘上会更好一些。例如,在打印日志时,可以携带请求的UUID,并在查询日志时进行过滤。

为什么不尝试使用日志中间件呢?这样你就可以简单地在调用链中包装错误,直到它们传递到中间件。在中间件里,你可以解包这些错误并进行相应处理,或者直接记录它们。在这种情况下,即使是原子操作或锁也足以将它们写入文件。

事实上,已经有日志库实现了这个功能。看来你之前并不知道如何获取之前的调用栈。这个解决方案没有太多问题,因为获取调用栈是一个常见的操作。(我也有使用这类日志库的经验。它的优点是你可以轻松地看到日志是在哪个文件生成的。)

正如前面的人所说,他提到的日志中间件似乎让你传递错误信息。这是一个非常常见的解决方案。然而,这与你获取调用栈并不冲突,例如将其封装到抛出的错误中。

如果按日志级别分类,这种跟踪类型的日志级别非常低,这意味着它变成了一个日志垃圾桶。实际上,大多数开发者要么不写日志,要么把它们当作垃圾,自己添加一个调试日志,过一段时间就忘了。项目经理也经常要求高级日志不要产生太多垃圾信息。(更糟糕的项目,可能需要在垃圾中挖掘自己的调试日志) 出于这个原因,我认为将这种类型的日志放在内存中是一个错误。

为什么你不想着抛出错误信息呢?相反,将错误日志记录在上下文中?如果你只是担心调用栈,那么你应该查询调用链并将其放入错误中。将日志保存在内存中显然不是一个好的解决方案,日志应该是生成和消费的,而不是堆积起来。

就像一些RPC框架一样,在发生panic时,它会在最外层恢复并打印调用栈以进行调试。

无论哪种方式,如果你只考虑日志记录,即使你在临时文件中为每个请求写入一个日志文件,也比将其放在内存中要好得多。

当然,如果你开始填充切片,那么使用的存储空间将与日志消息所需的一样多。但问题在于传递一个空的 Tracker 结构体,该结构体仅在少数情况下会接收日志输入:

假设我有1000个请求,它们都正常工作,如果是这样,Tracker.Log 切片将被完全忽略,因为它只在出现错误时才需要。

因此,这1000个正常工作的请求,在没有 Tracker 结构体的情况下,初始栈分配了2048字节;而在有一个空的 Tracker 结构体的情况下,分配了2072字节。差别不大。

我完全同意,可能存在比填充切片更好的日志记录方法。我是从一个纯粹的技术角度来回答这个问题的,省略了任何设计上的考虑。

一个空的 Tracker占用 24 字节内存

每个 goroutine 初始栈大小为 2KB

因此,与启动一个 goroutine 不可避免的内存开销相比,这 24 字节可以忽略不计。如果你的请求处理程序确实执行了一些实际工作,这些工作本身肯定会消耗相当多的内存,那么这 24 字节就更微不足道了。

提示:如果你在 Linux 系统上,并且对实际内存使用情况感到好奇,可以使用 top -p <PID> 来监控一个正在运行的进程,或者使用 /usr/bin/time -v <command> 来获取某个最终会退出的命令的一些统计信息(包括内存统计,尽管它叫“time”)。

要进行更详细的分析,你可能需要使用 Go 的 诊断功能

这听起来像是一连串的panic和recover触发机制。这种实现方式非常繁琐。让我们设想一下: 有5个任务a、b、c、d和e。当执行e时发生错误,开发者需要记录前4条记录,这意味着每个任务都必须插入一个断点。一个简单的实现如下:

var err error
err=aFunc()
defer func() {
	if err!=nil{
        // todo log...
    }
}()

Defer会产生一定的开销,虽然很小,但这种写法可能会导致大量的defer。显然我不希望看到这样的代码。对于复杂的业务,这只会让阅读者体验更差。(也许有其他更好的实现方式)

还有一个重要的前提。这种做法会导致调用者不喜欢这种疯狂的写法。如果你正在为开发者提供一个框架,那么开发者将不得不在每一步都使用这种繁琐的步骤。这显然不符合简单的开发理念。(我认为调用者更希望框架为他们处理这些)

让我们回过头来思考,为什么需要记录跟踪错误日志?用户在乎吗?不,只有开发者在乎。与上述方法相比,他们更喜欢这样:

var err error
err=aFunc()
if err!=nil{
    // todo log...
}

这非常简单,就像一些日志记录框架一样,允许你传递一些上下文,然后以统一的格式输出,便于日志存储或过滤查询。

然而,如果你想实现任务行为记录(注意,我说的是任务行为记录而不是跟踪日志),那么通过defer封装实现并消耗内存是在业务范围内的,这是可以接受的。但如果是为了记录和调试,就得不偿失了。

这取决于你是将此功能归类为业务还是调试。如果是业务,那么一切都是合理的;但如果是调试,你应该优先保证业务的性能。

类似这样。

func logger(fn http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if err := somefunc(); err != nil {
            // 在此处加锁并记录日志
        }
    }
}

func somefunc() error {
    if err := anotherfunc(); err != nil {
        return fmt.Errorf(“another function failed doing something: %w”, err)
    }

    return nil
}

func anotherfunc() error {
    if err := childfunc(); err != nil {
        return fmt.Errorf(“childfunc failed doing something: %w”, err)
    }

    return nil
}

func childfunc() error {
    return errors.New(“oh, I failed”)
}

在这种方法中,你也可以添加自定义错误。此外,如果你需要在返回之前在 somefuncanotherfunc 内部收集多个错误,可以使用 errors.Join(…err) 来收集它们。这样,你就可以在 logger 函数中通过解包来检查它们,并相应地处理错误。例如,使用 errors.Is / errors.As 进行判断。或者简单地将它们全部记录到文件中。由于这也支持自定义错误,你可以使用一个结构体来保存关于错误的更多有用信息,例如:

type myError struct {
    funcName string
    err error // 也可以是 errs []err
    trace string
    // 任何你需要的其他字段…
}

func (e *myError) Error() string {
    return fmt.Sprintf(“function %q failed with: %s\nTrace: %s”, e.funcName, e.err, e.trace)
}

func (e *myError) Unwrap() error { return e.err } 
// 或者如果你收集了多个错误:
// func (e *myError) Unwrap() []error { return e.errs }

在我看来,使用这种方法你可以更有效地处理自定义错误。根据需要添加任意多的信息,同时在你的包内创建简单的 errors.New,这样使用它的人就可以在他们那一端处理这些错误。

在Go中,使用context存储请求级数据是常见做法,但需要谨慎处理内存使用。你的方案存在内存浪费问题,因为每个请求都会分配Tracker结构,即使没有错误也会占用内存。

以下是改进方案:

方案1:延迟分配(推荐)

type trackerKey struct{}

type Tracker struct {
    mu   sync.RWMutex
    logs []string
    err  bool
}

func WithTracker(ctx context.Context) context.Context {
    return context.WithValue(ctx, trackerKey{}, &Tracker{})
}

func Track(ctx context.Context, msg string) {
    if tracker, ok := ctx.Value(trackerKey{}).(*Tracker); ok {
        tracker.mu.Lock()
        if tracker.err {
            tracker.logs = append(tracker.logs, msg)
        }
        tracker.mu.Unlock()
    }
}

func TrackError(ctx context.Context, msg string) {
    if tracker, ok := ctx.Value(trackerKey{}).(*Tracker); ok {
        tracker.mu.Lock()
        tracker.err = true
        tracker.logs = append(tracker.logs, msg)
        tracker.mu.Unlock()
    }
}

func GetTrackerLogs(ctx context.Context) []string {
    if tracker, ok := ctx.Value(trackerKey{}).(*Tracker); ok {
        tracker.mu.RLock()
        defer tracker.mu.RUnlock()
        return append([]string(nil), tracker.logs...)
    }
    return nil
}

方案2:错误时回溯收集

type traceKey struct{}

type TraceFunc func() string

func WithTrace(ctx context.Context) context.Context {
    traces := make([]TraceFunc, 0)
    return context.WithValue(ctx, traceKey{}, &traces)
}

func AddTrace(ctx context.Context, fn TraceFunc) {
    if traces, ok := ctx.Value(traceKey{}).(*[]TraceFunc); ok {
        *traces = append(*traces, fn)
    }
}

func CollectTracesOnError(ctx context.Context, err error) ([]string, error) {
    if err == nil {
        return nil, nil
    }
    
    var logs []string
    if traces, ok := ctx.Value(traceKey{}).(*[]TraceFunc); ok {
        for _, fn := range *traces {
            logs = append(logs, fn())
        }
    }
    
    return logs, err
}

// 使用示例
func someFunction(ctx context.Context) error {
    operation := "someFunction called"
    AddTrace(ctx, func() string { return operation })
    
    if err := anotherFunction(ctx); err != nil {
        return fmt.Errorf("%s: %w", operation, err)
    }
    return nil
}

方案3:条件分配

type trackerKey struct{}

type Tracker struct {
    logs []string
}

func getOrCreateTracker(ctx context.Context) *Tracker {
    if tracker, ok := ctx.Value(trackerKey{}).(*Tracker); ok {
        return tracker
    }
    return nil
}

func Track(ctx context.Context, msg string) {
    tracker := getOrCreateTracker(ctx)
    if tracker != nil {
        tracker.logs = append(tracker.logs, msg)
    }
}

func StartTrackingOnError(ctx context.Context) context.Context {
    tracker := &Tracker{}
    return context.WithValue(ctx, trackerKey{}, tracker)
}

// 中间件示例
func TrackingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        
        // 只在需要时启用跟踪
        if shouldTrack(r) {
            ctx = context.WithValue(ctx, trackerKey{}, &Tracker{})
        }
        
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

性能对比

对于1000个并发请求:

  • 你的原始方案:每个请求分配Tracker + 字符串切片 ≈ 24-48字节基础开销
  • 延迟分配方案:仅分配空结构体 ≈ 8字节,错误时才分配切片
  • 条件分配方案:大部分请求无开销

建议

使用方案1的延迟分配方法,它平衡了内存使用和代码简洁性。关键点:

  1. 使用指针存储Tracker,避免结构体复制
  2. 使用sync.RWMutex保证并发安全
  3. 错误时才记录日志,避免无谓内存分配
  4. 使用私有key类型避免上下文键冲突

这种模式在大型Go应用中很常见,能有效减少内存压力,同时保持调试能力。

回到顶部