一、为什么需要优雅关闭

想象一下,你开了一家小饭店,生意正火。突然接到通知说马上要关门装修,你直接把大门一锁,正在吃饭的客人碗筷一扔就被赶出去,下次谁还敢来?线上服务也是一样的道理。当我们对后台服务进行升级、重启或者扩缩容时,如果直接 kill 掉进程,正在处理中的请求就会突然中断,用户可能看到空白页面、提交的订单丢失、数据写入一半。这种事故在金融、电商、支付等场景下代价极高。

Gin 框架是 Go 语言中非常流行的 Web 框架,它本身提供了基本的关闭能力,但如果我们不加以配合,依然会出现请求丢失。关键点在于:收到关闭信号后,既要阻止新请求进入,又要耐心等待当前正在处理的请求全部完成。这就是“优雅关闭”的核心。而“请求等待队列”在这里并不是一个真正的队列数据结构,而是一种协同机制:通过计数器或 WaitGroup 来追踪当前正在处理的请求数,直到所有请求结束才真正退出。

二、Gin 服务的基本关闭方式

Go 标准库中的 http.Server 提供了 Shutdown 方法,它会停止接收新连接,并等待活跃连接关闭后再返回。Gin 本身构建在 net/http 之上,所以我们可以直接使用服务器的 Shutdown

先看一个最简单的例子(不带任何信号监听):

package main

import (
    "context"
    "net/http"
    "time"

    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()
    r.GET("/hello", func(c *gin.Context) {
        // 模拟一个耗时5秒的请求
        time.Sleep(5 * time.Second)
        c.String(200, "Hello, World!")
    })

    srv := &http.Server{
        Addr:    ":8080",
        Handler: r,
    }

    // 这里我们手动让服务器在10秒后关闭(演示用)
    go func() {
        time.Sleep(10 * time.Second)
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        if err := srv.Shutdown(ctx); err != nil {
            panic(err) // 打印错误
        }
    }()

    // 开始服务
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        panic(err)
    }
}

这个例子中有个问题:如果我们直接 Ctrl+C 终止程序,Shutdown 还没来得及执行,进程就强制退出了。所以我们需要监听系统信号,在收到信号后触发 Shutdown

三、信号监听:捕获系统信号

操作系统中,当我们用 kill 命令或者按 Ctrl+C 时,会给进程发送信号。常见的有 SIGINT(中断)、SIGTERM(终止)。在 Kubernetes 等容器环境中,还会发送 SIGTERM 然后等一段时间强行 SIGKILL。我们需要捕获这些信号,然后执行清理逻辑。

Go 的 os/signal 包可以很方便地监听信号。以下是一个基本的信号监听示例:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()
    r.GET("/ping", func(c *gin.Context) {
        time.Sleep(2 * time.Second) // 模拟处理
        c.String(200, "pong")
    })

    srv := &http.Server{
        Addr:    ":8080",
        Handler: r,
    }

    // 开启一个goroutine来监听信号
    go func() {
        quit := make(chan os.Signal, 1)
        // 监听SIGINT和SIGTERM
        signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
        sig := <-quit // 阻塞等待信号
        log.Printf("收到信号: %v,开始优雅关闭...", sig)

        // 设置超时时间,防止一直等待
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()

        if err := srv.Shutdown(ctx); err != nil {
            log.Fatalf("关闭服务出错: %v", err)
        }
        log.Println("服务已平滑关闭")
    }()

    log.Println("服务启动,监听 :8080")
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("服务启动失败: %v", err)
    }
}

这个版本已经能实现优雅关闭:收到信号后,Shutdown 会等待所有活跃连接结束。但有一个隐藏问题:如果在关闭开始前一瞬间,有新的请求进入,Shutdown 会拒绝吗?其实 Shutdown 在开始后就不再接受新连接,但如果新的请求是在关闭开始前就已经到达请求队列(比如已经被内核 TCP 连接接受到服务端),那么它会被处理。然而,对于 Gin 来说,我们可能需要更细粒度的控制:例如,我们希望在关闭信号到达后,立即停止接受新请求,但正在处理中的请求一个都不能少。

官方 Shutdown 的行为是:停止监听,但已有的连接允许完成。这基本上就是“不再接新客,等桌上客吃完”。不过,如果你的业务代码中有很长的请求处理(比如大文件上传、流式响应),或者你使用了连接池(比如数据库连接池)在请求结束后需要释放资源,你还需要确保在关闭过程中这些资源也被正确清理。

四、请求等待队列的协同机制

4.1 什么是“请求等待队列”

严格来说,我们并没有一个物理的队列结构。这里的“协同机制”是指:通过一个共享计数器(或 sync.WaitGroup)来追踪当前正在处理的请求数量。在中间件中,每个请求开始时加一,结束时减一。关闭信号触发后,我们设置一个标志位(比如 shuttingDown),然后等待计数器归零,最后才调用 Shutdown

这样做的好处是:即使 Shutdown 本身会等待活跃连接,但这个等待是基于 TCP 连接级别的,对于 HTTP 长连接可能会有很多请求复用同一个连接。而我们通过中间件计算的是实际的 HTTP 请求处理,更精确。另外,我们可以在关闭信号到达后立即拒绝新请求,返回特定的状态码(例如 503 Service Unavailable),让客户端知道服务正在关闭,可以重试。

4.2 完整实现示例

下面给出一个完整的 Gin 服务 + 信号监听 + WaitGroup 的示例。代码中包含了注释,方便理解。

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"

    "github.com/gin-gonic/gin"
)

// 定义全局的 WaitGroup 和关闭标志
var (
    wg           sync.WaitGroup
    shuttingDown bool
    mu           sync.Mutex // 保护 shuttingDown 并发读写
)

// 中间件:每次请求开始时,waitGroup加1,结束时减1
func requestTracker() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 如果正在关闭,则直接拒绝新请求
        mu.Lock()
        if shuttingDown {
            mu.Unlock()
            c.JSON(http.StatusServiceUnavailable, gin.H{
                "message": "server is shutting down, please retry later",
            })
            c.Abort()
            return
        }
        mu.Unlock()

        wg.Add(1)               // 开始处理,计数+1
        defer wg.Done()         // 请求结束,无论成功还是失败都减1

        c.Next()                // 继续处理请求
    }
}

// 模拟长时间处理的任务
func slowHandler(c *gin.Context) {
    // 模拟耗时5秒的数据库查询
    time.Sleep(5 * time.Second)
    c.String(http.StatusOK, "Done after 5 seconds")
}

func main() {
    r := gin.Default()
    // 注册请求跟踪中间件
    r.Use(requestTracker())

    r.GET("/slow", slowHandler)
    r.GET("/fast", func(c *gin.Context) {
        c.String(http.StatusOK, "Fast!")
    })

    srv := &http.Server{
        Addr:    ":8080",
        Handler: r,
    }

    // 监听信号
    go func() {
        quit := make(chan os.Signal, 1)
        signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
        sig := <-quit
        log.Printf("收到信号 %v ,开始优雅关闭...", sig)

        // 设置关闭标志,阻止新请求
        mu.Lock()
        shuttingDown = true
        mu.Unlock()

        // 等待所有正在处理的请求完成(最多等待60秒)
        // 这里使用带超时的等待,防止永远卡住(比如某个请求死循环)
        done := make(chan struct{})
        go func() {
            wg.Wait()      // 等待所有请求结束
            close(done)
        }()

        select {
        case <-done:
            log.Println("所有正在处理的请求已完成")
        case <-time.After(60 * time.Second):
            log.Println("等待超时,强制关闭")
        }

        // 然后关闭服务器(停止监听,并等待已建立连接的请求完成)
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        if err := srv.Shutdown(ctx); err != nil {
            log.Fatalf("服务关闭出错: %v", err)
        }
        log.Println("服务已优雅关闭")
    }()

    log.Println("服务启动 :8080")
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("启动失败: %v", err)
    }

    // 注意:当 ListenAndServe 返回后,主程序退出,但 goroutine 中可能还在打印日志
    // 这里可以再加一点等待,但通常不用
    time.Sleep(100 * time.Millisecond)
    log.Println("程序退出")
}

4.3 关键点解释

  • requestTracker 中间件:每个请求进入时检查 shuttingDown 标志。如果为 true,直接返回 503 并 Abort,不执行后续处理(包括 WaitGroup 加减)。如果为 false,则 wg.Add(1) 再 defer wg.Done()。这样确保了正在处理的请求会被跟踪,而新请求被拒绝。
  • 关闭流程:收到信号后先设置 shuttingDown = true,这之后新请求立即被拒。然后等待 wg.Wait(),直到所有正在处理中的请求完成。注意这里设置了 60 秒超时,防止某个请求卡死导致永远不关闭。超时后强制进入 Shutdown。最后调用 srv.Shutdown 关闭 HTTP 服务器本身。
  • 为什么还要 Shutdown:WaitGroup 只跟踪了 Gin 中间件处理中的请求,但 HTTP 服务器本身可能还保持着一些空闲连接(比如 keep-alive),Shutdown 会关闭这些连接。另外,Shutdown 还负责停止监听端口。所以两步是互补的。

4.4 测试验证

启动服务后,先发一个 /slow 请求(会耗时5秒),然后在 1 秒内快速按 Ctrl+C。你会发现这个请求依然能成功返回,而新的请求会收到 503。这样就实现了零丢失。

五、应用场景

  1. 滚动更新:在 Kubernetes 中,旧 Pod 收到 SIGTERM 后优雅关闭,同时新 Pod 开始服务。如果不优雅,用户可能体验到间歇性错误。
  2. 版本升级:手动替换二进制文件时,先启动新进程监听新端口,再关闭老进程。关闭过程需要确保当前请求完成。
  3. 突发缩容:负载降低后需要减少实例,关闭部分实例时不能丢失正在处理的请求。
  4. 定时重启:有些服务因为内存泄漏等原因需要定期重启,优雅关闭可以避免影响用户。

六、技术优缺点

优点

  • 零数据丢失:已经接受的请求不会被中断,用户体验好。
  • 兼容性高:与 Gin、标准库无缝集成,无需额外中间件。
  • 可定制:通过中间件可以自定义拒绝新请求时的响应(如 503 或重定向)。
  • 避免脏数据:比如写入数据库一半的请求,等到 commit 完成才关闭。

缺点

  • 等待时间不确定:如果请求处理时间很长(如大文件上传),关闭可能延迟几十分钟。需要设置超时。
  • 实现稍复杂:需要维护全局状态(shuttingDown、WaitGroup),注意并发安全。
  • 依赖开发者自觉:如果某些请求没有通过中间件(比如静态文件服务),可能不会被跟踪。需要覆盖所有路由。
  • 需要客户端配合:如果客户端收到 503 后不重试,请求还是失败。最佳实践是返回可重试的响应头(如 Retry-After)。

七、注意事项

  1. 超时设置:等待 wg.Wait() 的超时时间和 Shutdown 的超时时间要合理。建议等待时间大于正常请求处理时间的最大值,比如正常请求最长 30 秒,可以设置 60 秒。避免过长导致运维困难。
  2. 并发安全shuttingDown 标志必须加锁访问,或者使用 atomic 包。示例中使用 sync.Mutex 简单安全。
  3. 信号注册:不要同时注册多个 signal.Notify 到同一个信道,否则可能导致信号丢失。示例中只在一个 goroutine 中监听。
  4. 重复关闭:确保 Shutdown 只调用一次。示例中信号只被消费一次,所以没问题。
  5. 中间件顺序requestTracker 中间件应该放在最外层(比如 r.Use 在最前面),这样所有路由都经过它。如果有分组路由,也要确保它被应用。
  6. 测试:编写集成测试,模拟发送信号然后验证请求是否完整。可以使用 os.Process.Signal 触发信号。

八、文章总结

优雅关闭不是一个可选功能,而是生产服务的必备素质。通过信号监听和请求计数协同机制,我们可以让 Gin 服务在收到关闭信号后,礼貌地拒绝新请求,同时等待所有正在处理的请求安全结束,从而实现零丢失。本文给出的完整示例代码可以直接集成到你的项目中,只需根据业务调整超时时间和响应内容即可。记住:每一次优雅关闭,都是对用户的一份尊重,也是对系统稳定性的保障。