Go 优雅关闭实践指南:从原理到框架落地
在分布式系统中,服务关闭环节的稳定性直接影响系统可用性。一个粗糙的关闭流程可能引发请求丢失、数据不一致、资源泄露等问题,而 “优雅关闭”(Graceful Shutdown)通过 “收到停止信号后,先处理存量任务、释放资源,再平稳退出” 的逻辑,成为高可用服务的必备能力。本文将从错误案例切入,拆解核心原则,结合多场景实践与框架应用,完整呈现 Go 优雅关闭的实现方案。
# 一、先避坑:“不优雅” 关闭的 3 类典型错误
理解错误做法是设计优雅关闭的前提,以下 3 类案例是实际开发中最易踩的坑:
# 错误 1:收到信号直接终止进程(请求丢失)
直接调用os.Exit会强制终止进程,正在处理的任务(如耗时请求)会被中断,客户端可能收到 “连接重置” 或 “502” 错误。
package main
import (
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
// 模拟耗时5秒的HTTP请求处理
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Println("开始处理请求(预计5秒)")
time.Sleep(5 * time.Second)
w.Write([]byte("处理完成"))
log.Println("请求处理完毕")
})
// 非阻塞启动服务
go func() {
log.Println("服务启动 on :8080")
http.ListenAndServe(":8080", nil)
}()
// 监听关闭信号(Ctrl+C或kill)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// 错误操作:直接退出,中断存量请求
log.Println("收到信号,立即退出")
os.Exit(0)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 错误 2:忽略超时控制(服务 “关不掉”)
http.Server.Shutdown等方法会等待存量任务完成,但若存在无限阻塞的异常任务(如死循环),未设置超时会导致服务卡死在关闭阶段,最终需kill -9强制终止。
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
)
func main() {
srv := &http.Server{Addr: ":8080"}
// 模拟无限阻塞的请求(死循环)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
select {} // 永远不会退出
})
go srv.ListenAndServe()
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT)
<-quit
// 错误操作:无超时控制,服务可能永久阻塞
log.Println("开始关闭...")
srv.Shutdown(context.Background()) // 无超时兜底
log.Println("关闭完成") // 可能永远不执行
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 错误 3:资源释放顺序颠倒(任务失败)
若先关闭依赖资源(如数据库),再等待存量任务完成,会导致任务执行时因资源不可用报错,违背 “平稳” 原则。
package main
import (
"log"
"os"
"os/signal"
"syscall"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func main() {
// 初始化MySQL连接
db, _ := gorm.Open(mysql.Open("user:pass@tcp(localhost:3306)/db"), &gorm.Config{})
sqlDB, _ := db.DB()
// 模拟依赖MySQL的后台任务
done := make(chan struct{})
go func() {
defer close(done)
for {
log.Println("执行数据库查询...")
time.Sleep(1 * time.Second)
// 实际场景:执行db.Query(...)
}
}()
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT)
<-quit
// 错误操作:先关数据库,再等任务结束(任务会报错)
log.Println("先关闭数据库连接...")
sqlDB.Close()
log.Println("等待任务结束...")
<-done // 任务访问数据库时会提示“connection closed”
log.Println("退出")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 二、核心原则:优雅关闭 “四步曲”
从错误案例中可提炼出通用流程,无论何种组件,优雅关闭都需遵循以下四步,确保逻辑闭环:
停接收:停止接收新请求 / 消息(如关闭 HTTP 监听、停止拉取消息队列),避免新任务进入;
清存量:等待正在处理的存量任务完成,且必须设置超时兜底,防止无限阻塞;
释资源:关闭数据库、缓存、连接池等依赖资源,释放系统占用;
稳退出:所有步骤完成后,正常终止进程,避免强制退出。
# 三、多场景优雅关闭实践:错误 vs 正确对比
# 3.1 HTTP 服务:用Shutdown替代强制关闭
HTTP 服务优雅关闭的关键是利用标准库http.Server的Shutdown方法,该方法会先停止接收新请求,再等待存量请求完成。
# 错误做法:直接关闭监听
package main
import (
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
lis, _ := net.Listen("tcp", ":8080")
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second) // 模拟耗时请求
w.Write([]byte("处理完成"))
})
srv := &http.Server{}
go srv.Serve(lis)
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT)
<-quit
// 错误:直接关闭监听,中断现有连接
lis.Close()
log.Println("退出")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 正确做法:Shutdown+ 超时控制
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{Addr: ":8080"}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Println("开始处理请求")
time.Sleep(3 * time.Second)
w.Write([]byte("请求完成"))
log.Println("请求处理完毕")
})
// 非阻塞启动服务
go func() {
log.Println("HTTP服务启动 on :8080")
// 仅在非关闭错误时退出(排除http.ErrServerClosed)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务启动失败: %v", err)
}
}()
// 监听关闭信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("开始优雅关闭...")
// 设置5秒超时:避免无限等待
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 执行优雅关闭
if err := srv.Shutdown(ctx); err != nil {
log.Printf("关闭超时,强制退出: %v", err)
}
log.Println("HTTP服务优雅退出")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# 3.2 gRPC 服务:GracefulStopvsStop
gRPC 服务需区分GracefulStop(等待存量请求完成)和Stop(立即终止所有请求),前者才符合优雅关闭要求,且需配合超时兜底。
# 错误做法:用Stop强制终止
package main
import (
"log"
"net"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
pb "your/proto/package" // 替换为实际proto包
)
type myServer struct{ pb.UnimplementedDemoServer }
// 模拟耗时3秒的gRPC请求处理
func (s *myServer) Process(ctx context.Context, req *pb.Request) (*pb.Response, error) {
log.Println("gRPC开始处理请求(耗时3秒)")
time.Sleep(3 * time.Second)
return &pb.Response{Msg: "完成"}, nil
}
func main() {
lis, _ := net.Listen("tcp", ":9000")
srv := grpc.NewServer()
pb.RegisterDemoServer(srv, &myServer{})
go srv.Serve(lis)
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT)
<-quit
// 错误:Stop立即终止所有请求
srv.Stop()
log.Println("退出")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 正确做法:GracefulStop+ 超时
package main
import (
"log"
"net"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
pb "your/proto/package"
)
type myServer struct{ pb.UnimplementedDemoServer }
func (s *myServer) Process(ctx context.Context, req *pb.Request) (*pb.Response, error) {
log.Println("gRPC开始处理请求(耗时3秒)")
time.Sleep(3 * time.Second)
return &pb.Response{Msg: "完成"}, nil
}
func main() {
lis, _ := net.Listen("tcp", ":9000")
srv := grpc.NewServer()
pb.RegisterDemoServer(srv, &myServer{})
go func() {
log.Println("gRPC服务启动 on :9000")
// 排除grpc.ErrServerStopped(正常关闭错误)
if err := srv.Serve(lis); err != nil && err != grpc.ErrServerStopped {
log.Fatalf("服务启动失败: %v", err)
}
}()
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("开始优雅关闭...")
// 用goroutine执行GracefulStop(避免阻塞)
done := make(chan struct{})
go func() {
srv.GracefulStop() // 等待所有存量请求完成
close(done)
}()
// 5秒超时兜底
select {
case <-done:
log.Println("gRPC服务优雅关闭完成")
case <-time.After(5 * time.Second):
log.Println("关闭超时,强制终止")
srv.Stop()
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# 3.3 消息队列消费者:避免重复消费 / 丢失
以 Kafka 为例,优雅关闭需确保 “停止拉新消息→处理存量→提交偏移量→关闭连接”,防止已处理消息未提交导致重复消费。
# 错误做法:未提交偏移量直接退出
package main
import (
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/Shopify/sarama" // Kafka客户端
)
func main() {
// 初始化Kafka消费者
config := sarama.NewConfig()
config.Version = sarama.V2_8_1_0
consumer, _ := sarama.NewConsumer([]string{"localhost:9092"}, config)
pc, _ := consumer.ConsumePartition("test-topic", 0, sarama.OffsetNewest)
// 消费消息(未提交偏移量)
go func() {
for msg := range pc.Messages() {
log.Printf("处理消息: %s (offset: %d)", msg.Value, msg.Offset)
time.Sleep(2 * time.Second) // 模拟处理
// 错误:未提交偏移量,重启后重复消费
}
}()
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT)
<-quit
// 错误:直接关闭,未确保偏移量提交
pc.Close()
consumer.Close()
log.Println("退出")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 正确做法:先停消费再提交偏移量
package main
import (
"context"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/Shopify/sarama"
)
func main() {
config := sarama.NewConfig()
config.Version = sarama.V2_8_1_0
consumer, _ := sarama.NewConsumer([]string{"localhost:9092"}, config)
pc, _ := consumer.ConsumePartition("test-topic", 0, sarama.OffsetNewest)
defer consumer.Close() // 最终关闭消费者
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(context.Background())
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case msg := <-pc.Messages():
log.Printf("处理消息: %s (offset: %d)", msg.Value, msg.Offset)
time.Sleep(2 * time.Second)
// 处理完成后提交偏移量(下一次从offset+1开始)
_, err := consumer.CommitOffset(&sarama.OffsetCommitRequest{
Topic: msg.Topic,
Partition: msg.Partition,
Offset: msg.Offset + 1,
})
if err != nil {
log.Printf("提交偏移量失败: %v", err)
}
case <-ctx.Done():
log.Println("停止接收新消息,等待存量处理")
return
}
}
}()
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("开始关闭消费者...")
// 1. 停止接收新消息
cancel()
// 2. 等待存量消息处理完成
wg.Wait()
// 3. 关闭分区消费者
pc.Close()
log.Println("消费者优雅退出")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# 3.4 第三方依赖:数据库 / 缓存的释放
数据库(MySQL)、缓存(Redis)等依赖需在 “存量任务完成后” 关闭,避免任务执行时资源不可用。
# 错误做法:提前关闭资源
package main
import (
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/go-redis/redis/v8"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func main() {
// 初始化资源
db, _ := gorm.Open(mysql.Open("user:pass@tcp(localhost:3306)/db"), &gorm.Config{})
sqlDB, _ := db.DB()
redisClient := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
// 模拟依赖资源的任务
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
log.Println("任务开始:查询数据库和Redis")
time.Sleep(3 * time.Second)
log.Println("任务完成")
}()
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT)
<-quit
// 错误:先关资源,再等任务(任务会报错)
log.Println("先关闭数据库和Redis...")
sqlDB.Close()
redisClient.Close()
log.Println("等待任务完成...")
wg.Wait()
log.Println("退出")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 正确做法:任务完成后释放资源
package main
import (
"context"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/go-redis/redis/v8"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func main() {
// 初始化资源
db, _ := gorm.Open(mysql.Open("user:pass@tcp(localhost:3306)/db"), &gorm.Config{})
sqlDB, _ := db.DB()
redisClient := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
// 模拟任务
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
log.Println("任务开始:查询数据库和Redis")
time.Sleep(3 * time.Second)
log.Println("任务完成")
}()
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("开始关闭...")
// 1. 等待任务完成
wg.Wait()
// 2. 带超时关闭资源
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 关闭MySQL连接池
if err := sqlDB.Close(); err != nil {
log.Printf("MySQL关闭错误: %v", err)
}
// 关闭Redis连接
if err := redisClient.Close(); err != nil {
log.Printf("Redis关闭错误: %v", err)
}
log.Println("资源关闭完成,服务退出")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# 3.5 直接go协程:用WaitGroup+Context跟踪生命周期
直接通过go关键字启动的协程若不跟踪,关闭时会导致任务中断或资源泄露。核心是用sync.WaitGroup统计协程数量,context.Context传递关闭信号。
# 错误做法:不跟踪协程,直接退出
package main
import (
"log"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
// 错误:直接启动协程,无跟踪机制
for i := 0; i < 5; i++ {
go func(id int) {
for {
log.Printf("协程%d:处理任务(耗时1秒)", id)
time.Sleep(1 * time.Second)
// 若此时收到关闭信号,协程会被强制中断
}
}(i)
}
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT)
<-quit
// 错误:直接退出,未等待协程完成
log.Println("收到信号,立即退出")
os.Exit(0)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
问题分析:未跟踪协程生命周期,收到信号后直接退出,导致协程中未完成的任务(如数据写入、文件操作)中断,可能引发数据不一致。
# 正确做法:WaitGroup等待 + Context取消
package main
import (
"context"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
func main() {
// 1. 创建Context传递关闭信号,WaitGroup统计协程
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
// 2. 启动协程并注册到WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done() // 协程退出时标记完成
for {
select {
case <-ctx.Done():
// 收到关闭信号,退出协程
log.Printf("协程%d:收到关闭信号,停止任务", id)
return
default:
// 正常处理任务
log.Printf("协程%d:处理任务(耗时1秒)", id)
time.Sleep(1 * time.Second)
}
}
}(i)
}
// 3. 监听关闭信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("开始优雅关闭协程...")
// 4. 发送关闭信号,等待所有协程完成(带超时)
cancel() // 通知协程停止新任务
done := make(chan struct{})
go func() {
wg.Wait() // 等待所有协程完成存量任务
close(done)
}()
// 5. 超时兜底:避免协程无限阻塞
select {
case <-done:
log.Println("所有协程优雅退出")
case <-time.After(3 * time.Second):
log.Println("协程关闭超时,强制退出")
}
log.Println("服务退出")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
关键逻辑:
Context:通过cancel()向所有协程广播关闭信号,协程通过ctx.Done()接收信号后停止新任务;WaitGroup:通过Add(1)和Done()统计协程数量,确保所有存量任务完成;超时控制:避免协程因异常(如死循环)导致服务无法退出。
# 3.6 线程池(工作池):停止入队 + 等待出队
线程池(工作池)通常包含 “任务队列” 和 “工作协程”,优雅关闭需确保:停止接收新任务→处理完队列中存量任务→关闭工作协程。
# 错误做法:直接关闭任务队列,中断存量任务
package main
import (
"log"
"os"
"os/signal"
"syscall"
"time"
)
// 错误的工作池实现:无优雅关闭机制
type BadWorkerPool struct {
taskChan chan func() // 任务队列
}
func NewBadWorkerPool(workerNum int) *BadWorkerPool {
pool := &BadWorkerPool{
taskChan: make(chan func(), 100), // 带缓冲的任务队列
}
// 启动工作协程
for i := 0; i < workerNum; i++ {
go func(id int) {
for task := range pool.taskChan {
// 处理任务(若此时关闭taskChan,会 panic 或中断任务)
log.Printf("工作协程%d:执行任务", id)
task()
}
}(i)
}
return pool
}
// 提交任务到队列
func (p *BadWorkerPool) Submit(task func()) {
p.taskChan <- task
}
func main() {
pool := NewBadWorkerPool(3) // 3个工作协程
// 提交10个任务(每个任务耗时1秒)
for i := 0; i < 10; i++ {
taskID := i
pool.Submit(func() {
time.Sleep(1 * time.Second)
log.Printf("任务%d:执行完成", taskID)
})
}
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT)
<-quit
// 错误:直接关闭任务队列,导致未处理的任务丢失,工作协程 panic
close(pool.taskChan)
log.Println("收到信号,立即退出")
os.Exit(0)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
问题分析:直接关闭任务队列taskChan,会导致:
队列中未处理的任务丢失;
工作协程从已关闭的通道接收数据时触发
panic;正在处理的任务被强制中断。
# 正确做法:停止入队 + 等待存量任务处理
package main
import (
"context"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
// 正确的工作池实现:支持优雅关闭
type WorkerPool struct {
ctx context.Context
cancel context.CancelFunc
taskChan chan func()
wg sync.WaitGroup // 等待工作协程完成
isStopped bool // 标记是否已停止接收新任务
stopMutex sync.Mutex // 保护isStopped的并发访问
}
// 新建工作池:workerNum=工作协程数,bufSize=任务队列缓冲大小
func NewWorkerPool(workerNum int, bufSize int) *WorkerPool {
ctx, cancel := context.WithCancel(context.Background())
pool := &WorkerPool{
ctx: ctx,
cancel: cancel,
taskChan: make(chan func(), bufSize),
}
// 启动工作协程
pool.wg.Add(workerNum)
for i := 0; i < workerNum; i++ {
go func(id int) {
defer pool.wg.Done()
pool.workerLoop(id) // 工作协程循环处理任务
}(i)
}
return pool
}
// 工作协程循环:处理任务直到收到关闭信号
func (p *WorkerPool) workerLoop(id int) {
for {
select {
case <-p.ctx.Done():
// 收到关闭信号,处理完当前任务后退出
log.Printf("工作协程%d:收到关闭信号,停止处理新任务", id)
// 检查队列中是否还有存量任务(可选:处理完存量再退出)
for len(p.taskChan) > 0 {
task := <-p.taskChan
log.Printf("工作协程%d:处理队列剩余任务", id)
task()
}
return
case task, ok := <-p.taskChan:
if !ok {
return // 任务队列关闭(一般不会走到这,因先通过ctx控制)
}
// 正常处理任务
log.Printf("工作协程%d:执行任务", id)
task()
}
}
}
// 提交任务:若已停止则拒绝提交
func (p *WorkerPool) Submit(task func()) error {
p.stopMutex.Lock()
defer p.stopMutex.Unlock()
if p.isStopped {
return fmt.Errorf("工作池已停止,无法提交新任务")
}
select {
case p.taskChan <- task:
return nil
case <-p.ctx.Done():
return fmt.Errorf("工作池已关闭,任务提交失败")
}
}
// 优雅关闭:停止接收新任务→等待工作协程完成
func (p *WorkerPool) Shutdown(timeout time.Duration) error {
// 1. 标记为已停止,拒绝新任务
p.stopMutex.Lock()
p.isStopped = true
p.stopMutex.Unlock()
log.Println("工作池开始优雅关闭:停止接收新任务")
// 2. 发送关闭信号给工作协程
p.cancel()
// 3. 等待工作协程完成(带超时)
done := make(chan struct{})
go func() {
p.wg.Wait() // 等待所有工作协程处理完存量任务
close(p.taskChan) // 所有任务处理完后,关闭任务队列
close(done)
}()
select {
case <-done:
log.Println("工作池优雅关闭完成")
return nil
case <-time.After(timeout):
return fmt.Errorf("工作池关闭超时(%v)", timeout)
}
}
func main() {
// 初始化工作池:3个工作协程,任务队列缓冲100
pool := NewWorkerPool(3, 100)
defer pool.Shutdown(5 * time.Second) // 退出时优雅关闭
// 提交10个任务(每个任务耗时1秒)
for i := 0; i < 10; i++ {
taskID := i
if err := pool.Submit(func() {
time.Sleep(1 * time.Second)
log.Printf("任务%d:执行完成", taskID)
}); err != nil {
log.Printf("任务%d提交失败:%v", taskID, err)
}
}
// 监听关闭信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("收到关闭信号,触发工作池关闭")
// 执行优雅关闭
if err := pool.Shutdown(5 * time.Second); err != nil {
log.Printf("工作池关闭超时:%v", err)
}
log.Println("服务退出")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
关键逻辑:
- 停止入队:通过
isStopped标记和互斥锁,拒绝关闭后提交的新任务; - 存量处理:工作协程收到
ctx.Done()信号后,先处理完任务队列中剩余的存量任务,再退出; - 安全关闭:所有工作协程完成后才关闭任务队列,避免
panic; - 超时控制:防止工作协程因异常任务无限阻塞,确保服务能按时退出。
# 3.7 Docker Compose 环境:避免超时被强制 kill
Docker Compose 默认存在优雅关闭超时机制:发送SIGTERM信号后等待 10 秒,若服务未主动退出,则发送SIGKILL强制终止。若 Go 服务的优雅关闭逻辑耗时超过 10 秒,会被强制中断,导致优雅关闭失效。
# 错误做法:忽略 Compose 超时,优雅关闭被强制终止
需准备三部分文件:Go 服务代码(优雅关闭超时 15 秒)、Dockerfile(构建镜像)、docker-compose.yml(默认配置)。
- Go 服务代码(graceful.go):优雅关闭超时 15 秒,超过 Compose 默认 10 秒
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{Addr: ":8080"}
// 模拟耗时8秒的请求(存量任务需处理)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Println("开始处理请求(耗时8秒)")
time.Sleep(8 * time.Second)
w.Write([]byte("处理完成"))
log.Println("请求处理完毕")
})
// 非阻塞启动服务
go func() {
log.Println("服务启动 on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("启动失败: %v", err)
}
}()
// 监听关闭信号(Compose会发送SIGTERM)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("开始优雅关闭(超时15秒)...")
// 优雅关闭超时15秒(超过Compose默认10秒)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("关闭失败: %v", err) // 会打印“context deadline exceeded”
}
log.Println("服务优雅退出") // 这行不会执行,因被SIGKILL强制终止
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
- Dockerfile:构建最小化 Go 镜像
# 构建阶段
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod init graceful-demo && go mod tidy
COPY graceful.go ./
# 静态编译(避免依赖系统库)
RUN CGO_ENABLED=0 GOOS=linux go build -o graceful-server .
# 运行阶段(最小镜像)
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/graceful-server .
# 确保Go服务接收SIGTERM(作为容器PID 1进程)
CMD ["./graceful-server"]
2
3
4
5
6
7
8
9
10
11
12
13
14
- docker-compose.yml(默认配置):未设置
stop_grace_period
version: '3.8'
services:
graceful-service:
build: .
ports:
- "8080:8080"
# 未配置stop_grace_period,默认10秒
2
3
4
5
6
7
问题复现与分析:
启动服务:
docker-compose up -d --build发送请求(触发存量任务):
curl ``http://localhost:8080停止服务:
docker-compose down查看日志:
docker-compose logs graceful-service
日志会显示:
收到
SIGTERM信号,开始优雅关闭;处理存量请求(耗时 8 秒),但仅 10 秒后被
SIGKILL强制终止;最终打印
signal: killed,且 “服务优雅退出” 未输出。
核心原因:Compose 10 秒超时后强制 kill,Go 服务未完成优雅关闭流程。
# 正确做法:适配 Compose 超时配置
有两种方案:缩短 Go 服务优雅关闭超时(适配默认 10 秒),或延长 Compose 超时(适配长耗时优雅关闭)。
# 方案 1:缩短 Go 服务超时(适配默认 10 秒)
修改 Go 服务的优雅关闭超时为 8 秒(小于默认 10 秒),确保在 Compose 超时内完成:
// 优雅关闭超时8秒(< 10秒)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
2
启动并停止服务后,日志会完整打印 “服务优雅退出”,无SIGKILL信息。
# 方案 2:延长 Compose 超时(适配长耗时)
若 Go 服务需更长时间优雅关闭(如 15 秒),修改docker-compose.yml的stop_grace_period为 20 秒:
version: '3.8'
services:
graceful-service:
build: .
ports:
- "8080:8080"
stop_grace_period: 20s # 延长超时到20秒,适配15秒优雅关闭
2
3
4
5
6
7
8
Go 服务保持 15 秒超时,停止后日志会显示完整优雅关闭流程,无强制 kill。
关键补充:确保信号传递正常
Go 服务需作为容器内 PID 1 进程(如 Dockerfile 中
CMD ["./graceful-server"],无 shell 包裹),否则SIGTERM会被 shell 拦截,无法触发优雅关闭;若需启动多个进程,需用 init 系统(如
tini)转发信号,示例 Dockerfile 调整:
# 运行阶段添加tini
FROM alpine:3.18
RUN apk add --no-cache tini
WORKDIR /app
COPY --from=builder /app/graceful-server .
# 用tini作为PID 1,转发信号给Go服务
CMD ["tini", "--", "./graceful-server"]
2
3
4
5
6
7
8
# 四、主流框架优雅关闭:Gin 与 Kratos
框架通常封装了基础逻辑,开发者只需按规范使用,减少重复编码。
# 4.1 Gin 框架:用http.Server包装
Gin 基于标准库net/http,需通过http.Server包装引擎,才能使用Shutdown实现优雅关闭。
# 错误做法:直接用r.Run()
package main
import (
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
time.Sleep(3 * time.Second)
c.String(200, "处理完成")
})
// 错误:r.Run()内部阻塞,无关闭接口
go r.Run(":8080")
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT)
<-quit
// 无法优雅关闭,只能强制退出
log.Println("退出")
os.Exit(0)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 正确做法:http.Server包装 Gin 引擎
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
log.Println("Gin开始处理请求")
time.Sleep(3 * time.Second)
c.String(200, "完成")
log.Println("Gin请求处理完毕")
})
// 关键:用http.Server包装Gin
srv := &http.Server{
Addr: ":8080",
Handler: r,
}
// 非阻塞启动
go func() {
log.Println("Gin服务启动 on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("启动失败: %v", err)
}
}()
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("开始优雅关闭...")
// 优雅关闭(与标准库一致)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("关闭超时: %v", err)
}
log.Println("Gin服务优雅退出")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# 4.2 Kratos 框架:注册关闭钩子
Kratos内置信号处理与优雅关闭逻辑,只需注册HookStop钩子释放资源。
# 错误做法:未注册关闭钩子
package main
import (
"log"
"github.com/go-kratos/kratos/v2"
"github.com/go-kratos/kratos/v2/transport/http"
"gorm.io/gorm"
)
func main() {
// 初始化HTTP服务
httpSrv := http.NewServer(http.Address(":8080"))
// 初始化数据库(未注册关闭逻辑)
db, _ := gorm.Open(...) // 连接数据库
sqlDB, _ := db.DB()
// 创建Kratos应用
app := kratos.New(kratos.Server(httpSrv))
// 错误:未注册HookStop,服务关闭时sqlDB未释放
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 正确做法:注册HookStop释放资源
package main
import (
"log"
"github.com/go-kratos/kratos/v2"
"github.com/go-kratos/kratos/v2/transport/http"
"gorm.io/gorm"
)
func main() {
httpSrv := http.NewServer(http.Address(":8080"))
// 初始化数据库
db, _ := gorm.Open(...)
sqlDB, _ := db.DB()
// 创建应用并注册关闭钩子
app := kratos.New(
kratos.Name("kratos-demo"),
kratos.Server(httpSrv),
)
// HookStop:服务关闭时执行(释放资源)
app.RegisterHook(kratos.HookStop(func() error {
log.Println("关闭数据库连接...")
return sqlDB.Close()
}))
// Kratos自动处理:
// 1. 监听SIGINT/SIGTERM信号;
// 2. 停止服务(不接收新请求);
// 3. 执行HookStop钩子;
// 4. 平稳退出。
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 五、验证优雅关闭:4 类关键测试
优雅关闭需通过实际测试验证,避免 “逻辑正确但落地失效”:
耗时请求测试:创建耗时 5 秒的接口,调用接口后立即发送
kill <pid>信号,观察日志是否打印 “处理完成” 后再退出;资源残留检查:关闭服务后,用
lsof -i :端口检查端口是否释放,数据库用show processlist确认连接数归零;超时场景测试:写一个无限阻塞的请求,触发关闭后观察是否在超时时间(如 5 秒)后强制退出;
重复消费测试:消费 Kafka 消息并提交偏移量,关闭服务后重启,检查是否重复消费已处理的消息;
协程 / 线程池测试:启动多个协程或工作池任务,触发关闭后观察是否所有存量任务完成,无协程泄漏(可通过
pprof查看协程数量)。
# 六、总结:优雅关闭的 5 个核心要点
信号处理是起点:通过
os/signal监听SIGINT(Ctrl+C)和SIGTERM(kill 命令),触发关闭流程;四步曲是核心:严格遵循 “停接收→清存量→释资源→稳退出”,确保流程闭环;
超时控制是底线:所有等待步骤(如
Shutdown、GracefulStop、协程等待)必须设置超时,避免服务 “关不掉”;资源顺序要牢记:先关闭对外服务(HTTP/gRPC/ 任务入队),再处理存量任务(协程 / 工作池),最后释放依赖资源(数据库 / 缓存);
组件特性要适配:
直接协程:用
WaitGroup+Context跟踪生命周期;线程池:先停入队、再清队列、最后关协程;
框架:Gin 需
http.Server包装,Kratos 注册HookStop,借助框架减少重复开发。
优雅关闭不是 “可选功能”,而是高可用服务的基础 —— 服务总有重启、升级的时刻,只有能平稳退出的服务,才能在分布式系统中真正保障数据一致性与用户体验。