优雅关闭

在生产环境中运行的服务,直接强制终止可能会导致一些问题:正在处理的请求被中断、数据库连接没有正确释放、正在写入的文件可能损坏。优雅关闭就是让服务在收到停止信号后,先完成手头的工作,再安全退出。

Go 1.8+ 提供了 Shutdown 方法,Gin 可以直接使用。

为什么需要优雅关闭

假设你的服务正在处理一个支付请求,用户已经扣款成功,正在写入订单记录。这时候如果直接 kill 掉进程,订单可能写入失败,但用户钱已经扣了——这就是生产事故。

优雅关闭要解决的问题:

  • 等待正在处理的请求完成
  • 拒绝新的请求进入
  • 释放占用的资源(数据库连接、文件句柄等)
  • 记录必要的日志信息

基本实现

Go 1.8 之后,http.Server 内置了 Shutdown 方法,配合 context 可以实现优雅关闭:

package main

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

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

func main() {
    router := gin.Default()
    
    router.GET("/", func(c *gin.Context) {
        time.Sleep(5 * time.Second) // 模拟耗时操作
        c.String(http.StatusOK, "Welcome Gin Server")
    })

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

    // 启动服务器(非阻塞)
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %s\n", err)
        }
    }()

    // 等待中断信号
    quit := make(chan os.Signal, 1)
    // SIGINT: Ctrl+C, SIGTERM: kill 命令
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("Shutting down server...")

    // 给5秒时间处理未完成的请求
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatal("Server forced to shutdown:", err)
    }

    log.Println("Server exiting")
}

这段代码的关键点:

  1. 用 goroutine 启动服务器,主线程等待信号
  2. signal.Notify 捕获 SIGINT 和 SIGTERM 信号
  3. 收到信号后,调用 srv.Shutdown 开始优雅关闭
  4. 设置超时时间,防止某些请求卡住导致无法退出

Shutdown 的工作原理

调用 Shutdown 后,服务器会:

  1. 停止接受新连接
  2. 等待所有活跃请求完成
  3. 超时后强制关闭

需要注意的是,Shutdown 不会关闭 WebSocket 连接或长连接,这些需要单独处理。

实际项目中的完整示例

实际项目中,优雅关闭通常还需要处理数据库连接、Redis 连接等资源:

package main

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

    "github.com/gin-gonic/gin"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var db *gorm.DB

func initDB() error {
    dsn := "user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
    var err error
    db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
    return err
}

func closeDB() error {
    sqlDB, err := db.DB()
    if err != nil {
        return err
    }
    return sqlDB.Close()
}

func main() {
    // 初始化数据库
    if err := initDB(); err != nil {
        log.Fatalf("Failed to connect database: %v", err)
    }

    router := gin.Default()

    router.GET("/users", func(c *gin.Context) {
        // 模拟数据库查询
        time.Sleep(2 * time.Second)
        c.JSON(http.StatusOK, gin.H{"users": []string{"Alice", "Bob"}})
    })

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

    go func() {
        log.Println("Server starting on :8080")
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %s\n", err)
        }
    }()

    // 优雅关闭
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("Shutting down server...")

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    // 关闭 HTTP 服务器
    if err := srv.Shutdown(ctx); err != nil {
        log.Printf("Server shutdown error: %v", err)
    }

    // 关闭数据库连接
    if err := closeDB(); err != nil {
        log.Printf("Database close error: %v", err)
    }

    log.Println("Server exited properly")
}

使用通道协调多个资源关闭

如果项目中有多个需要关闭的资源,可以用通道来协调:

func main() {
    // ... 初始化代码 ...

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("Shutting down...")

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    // 并发关闭多个资源
    done := make(chan struct{})
    
    go func() {
        var wg sync.WaitGroup
        
        // 关闭 HTTP 服务器
        wg.Add(1)
        go func() {
            defer wg.Done()
            if err := srv.Shutdown(ctx); err != nil {
                log.Printf("HTTP server shutdown error: %v", err)
            }
        }()
        
        // 关闭数据库
        wg.Add(1)
        go func() {
            defer wg.Done()
            if err := closeDB(); err != nil {
                log.Printf("Database close error: %v", err)
            }
        }()
        
        // 关闭 Redis
        wg.Add(1)
        go func() {
            defer wg.Done()
            if err := closeRedis(); err != nil {
                log.Printf("Redis close error: %v", err)
            }
        }()
        
        wg.Wait()
        close(done)
    }()

    select {
    case <-done:
        log.Println("All resources closed")
    case <-ctx.Done():
        log.Println("Shutdown timeout, forcing exit")
    }
}

配合 Gin 的中间件记录关闭状态

可以写一个中间件,在服务关闭期间拒绝新请求:

var isShuttingDown atomic.Bool

func shutdownMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        if isShuttingDown.Load() {
            c.JSON(http.StatusServiceUnavailable, gin.H{
                "error": "Server is shutting down",
            })
            c.Abort()
            return
        }
        c.Next()
    }
}

func main() {
    router := gin.Default()
    router.Use(shutdownMiddleware())
    
    // ... 其他代码 ...

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    // 标记服务正在关闭
    isShuttingDown.Store(true)
    
    // ... 继续关闭流程 ...
}

这样,在关闭期间新进来的请求会立即收到 503 响应,而不是等待超时。

不同部署环境下的信号

环境信号触发方式
本地开发SIGINTCtrl+C
DockerSIGTERMdocker stop
KubernetesSIGTERM删除 Pod
SystemdSIGTERMsystemctl stop

Docker 和 Kubernetes 默认会给容器 10 秒时间优雅关闭,超时后会发送 SIGKILL 强制终止。可以通过配置调整这个时间。

小结

优雅关闭是生产环境必备的功能,核心是:

  • 用 goroutine 启动 HTTP 服务器
  • 监听系统信号(SIGINT、SIGTERM)
  • 调用 http.Server.Shutdown 等待请求完成
  • 设置合理的超时时间
  • 依次关闭其他资源(数据库、缓存等)

这样做可以保证服务停止时不丢失请求、不损坏数据,用户体验更好,运维也更安心。