一、为什么需要优雅关闭
想象一下,你开了一家小饭店,生意正火。突然接到通知说马上要关门装修,你直接把大门一锁,正在吃饭的客人碗筷一扔就被赶出去,下次谁还敢来?线上服务也是一样的道理。当我们对后台服务进行升级、重启或者扩缩容时,如果直接 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。这样就实现了零丢失。
五、应用场景
- 滚动更新:在 Kubernetes 中,旧 Pod 收到
SIGTERM后优雅关闭,同时新 Pod 开始服务。如果不优雅,用户可能体验到间歇性错误。 - 版本升级:手动替换二进制文件时,先启动新进程监听新端口,再关闭老进程。关闭过程需要确保当前请求完成。
- 突发缩容:负载降低后需要减少实例,关闭部分实例时不能丢失正在处理的请求。
- 定时重启:有些服务因为内存泄漏等原因需要定期重启,优雅关闭可以避免影响用户。
六、技术优缺点
优点:
- 零数据丢失:已经接受的请求不会被中断,用户体验好。
- 兼容性高:与 Gin、标准库无缝集成,无需额外中间件。
- 可定制:通过中间件可以自定义拒绝新请求时的响应(如 503 或重定向)。
- 避免脏数据:比如写入数据库一半的请求,等到 commit 完成才关闭。
缺点:
- 等待时间不确定:如果请求处理时间很长(如大文件上传),关闭可能延迟几十分钟。需要设置超时。
- 实现稍复杂:需要维护全局状态(shuttingDown、WaitGroup),注意并发安全。
- 依赖开发者自觉:如果某些请求没有通过中间件(比如静态文件服务),可能不会被跟踪。需要覆盖所有路由。
- 需要客户端配合:如果客户端收到 503 后不重试,请求还是失败。最佳实践是返回可重试的响应头(如
Retry-After)。
七、注意事项
- 超时设置:等待
wg.Wait()的超时时间和Shutdown的超时时间要合理。建议等待时间大于正常请求处理时间的最大值,比如正常请求最长 30 秒,可以设置 60 秒。避免过长导致运维困难。 - 并发安全:
shuttingDown标志必须加锁访问,或者使用atomic包。示例中使用sync.Mutex简单安全。 - 信号注册:不要同时注册多个
signal.Notify到同一个信道,否则可能导致信号丢失。示例中只在一个 goroutine 中监听。 - 重复关闭:确保
Shutdown只调用一次。示例中信号只被消费一次,所以没问题。 - 中间件顺序:
requestTracker中间件应该放在最外层(比如r.Use在最前面),这样所有路由都经过它。如果有分组路由,也要确保它被应用。 - 测试:编写集成测试,模拟发送信号然后验证请求是否完整。可以使用
os.Process.Signal触发信号。
八、文章总结
优雅关闭不是一个可选功能,而是生产服务的必备素质。通过信号监听和请求计数协同机制,我们可以让 Gin 服务在收到关闭信号后,礼貌地拒绝新请求,同时等待所有正在处理的请求安全结束,从而实现零丢失。本文给出的完整示例代码可以直接集成到你的项目中,只需根据业务调整超时时间和响应内容即可。记住:每一次优雅关闭,都是对用户的一份尊重,也是对系统稳定性的保障。
Comments