阶段5: 性能优化
将酒店搜索 API 的响应时间从 ~500ms 优化到 <50ms,搜索 QPS 从 ~100 提升到 ~2000+。通过压测量化每一步优化的效果,建立”度量→分析→优化→验证”的工作习惯。
| 模块 | 核心知识点 | 在本阶段的作用 |
|---|---|---|
| Module 4 | 缓存策略 / 穿透 / 雪崩 | Redis Cache-aside、布隆过滤器、随机 TTL |
| Module 2 | 数据库索引 | EXPLAIN ANALYZE、复合索引 |
| Module 5 | 异步处理 | 邮件通知异步化 |
| Module 1 | 规模估算 | 验证优化后是否达标 |
步骤1: 压测基准 — 记录当前性能
Section titled “步骤1: 压测基准 — 记录当前性能”在优化之前,先用压测工具建立基准线。
你是一名后端性能工程师。请帮我对酒店预订系统的搜索 API 进行基准压测。
## 环境准备
1. 确保数据库有足够数据: - 10000 家酒店(分布在 50 个城市) - 每家 3-5 种房型 - 每种房型未来 90 天的库存
请先写一个数据生成脚本 server/seed/benchmark_seed.go:```gofunc SeedBenchmarkData(db *gorm.DB) { cities := []string{"北京", "上海", "广州", "深圳", "杭州", "成都", "武汉", "南京", "西安", "重庆", "天津", "苏州", "长沙", "郑州", "东莞", "青岛", "沈阳", "宁波", "昆明", "大连", // ... 共50个城市 }
// 批量插入酒店(每城市200家) // 批量插入房型 // 批量插入库存(用 batch insert 提高速度) // 打印进度}要求:
- 批量插入(每批 1000 条),不要逐条插入
- 打印进度(每 1000 家酒店打印一次)
- 预计耗时:< 5 分钟
方案1: 使用 hey(Go 编写的压测工具)
Section titled “方案1: 使用 hey(Go 编写的压测工具)”安装:go install github.com/rakyll/hey@latest
创建压测脚本 scripts/benchmark.sh:
#!/bin/bashecho "=== 酒店搜索 API 基准压测 ==="echo ""echo "--- 测试1: 单城市搜索(北京)---"hey -n 1000 -c 50 \ "http://localhost:8080/api/v1/hotels/search?city=北京&check_in=2024-07-01&check_out=2024-07-03&page=1&page_size=10"
echo ""echo "--- 测试2: 带价格筛选 ---"hey -n 1000 -c 50 \ "http://localhost:8080/api/v1/hotels/search?city=上海&check_in=2024-07-01&check_out=2024-07-03&min_price=200&max_price=500&page=1&page_size=10"
echo ""echo "--- 测试3: 酒店详情 ---"hey -n 1000 -c 50 \ "http://localhost:8080/api/v1/hotels/1?check_in=2024-07-01&check_out=2024-07-03"
echo ""echo "--- 测试4: 创建预订(写操作)---"hey -n 100 -c 10 -m POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TOKEN" \ -d '{"hotel_id":1,"room_type_id":1,"check_in":"2024-08-01","check_out":"2024-08-02","guest_name":"测试","guest_phone":"13800000000"}' \ "http://localhost:8080/api/v1/bookings"方案2: 使用 wrk(更强大)
Section titled “方案2: 使用 wrk(更强大)”创建 scripts/wrk_search.lua:
-- 随机城市cities = {"北京", "上海", "广州", "深圳", "杭州", "成都"}request = function() local city = cities[math.random(#cities)] local path = "/api/v1/hotels/search?city=" .. city .. "&check_in=2024-07-01&check_out=2024-07-03&page=1&page_size=10" return wrk.format("GET", path)end运行:wrk -t4 -c100 -d30s -s scripts/wrk_search.lua http://localhost:8080
记录基准数据
Section titled “记录基准数据”请创建 docs/benchmark-results.md 模板:
# 性能基准记录
## 环境- 数据量:10000 酒店 / 40000 房型 / 3600000 库存- 机器:[你的机器配置]- 数据库:PostgreSQL 15, 无额外优化
## 基准(优化前)| 指标 | 搜索API | 详情API | 预订API ||------|---------|---------|---------|| QPS | | | || P50延迟 | | | || P99延迟 | | | || 错误率 | | | |请输出所有文件的完整代码。
### 步骤2: 索引优化
用 EXPLAIN ANALYZE 找到慢查询,添加索引。你是一名数据库性能优化工程师,使用 PostgreSQL。
请帮我优化酒店预订系统的搜索查询。
当前搜索查询(简化版)
Section titled “当前搜索查询(简化版)”-- 搜索北京有空房的酒店SELECT h.*, MIN(i.price) as min_priceFROM hotels hJOIN room_types rt ON rt.hotel_id = h.idJOIN inventories i ON i.room_type_id = rt.idWHERE h.city = '北京' AND h.status = 'active' AND i.date >= '2024-07-01' AND i.date < '2024-07-03' AND i.available_count > 0 AND rt.max_guests >= 2GROUP BY h.idHAVING COUNT(DISTINCT i.date) = 2 -- 每天都要有房ORDER BY min_price ASCLIMIT 10 OFFSET 0;请做以下事情
Section titled “请做以下事情”1. EXPLAIN ANALYZE 分析
Section titled “1. EXPLAIN ANALYZE 分析”先运行 EXPLAIN ANALYZE,解读输出:
- 哪些操作耗时最多?
- 是否有 Seq Scan(全表扫描)?
- 预估行数 vs 实际行数差距大吗?
2. 创建优化索引
Section titled “2. 创建优化索引”创建 SQL 迁移文件 server/migrations/001_add_indexes.sql:
-- 酒店表:按城市+状态搜索CREATE INDEX IF NOT EXISTS idx_hotels_city_status ON hotels(city, status);
-- 房型表:按酒店查询CREATE INDEX IF NOT EXISTS idx_room_types_hotel_id ON room_types(hotel_id);
-- 房型表:按入住人数过滤CREATE INDEX IF NOT EXISTS idx_room_types_hotel_max_guests ON room_types(hotel_id, max_guests);
-- 库存表:核心搜索索引(最重要!)-- 搜索时按 room_type_id + date 查询,且需要过滤 available_count > 0CREATE INDEX IF NOT EXISTS idx_inventories_search ON inventories(room_type_id, date) WHERE available_count > 0; -- 部分索引,只索引有库存的行
-- 库存表:覆盖索引(包含 price,避免回表)CREATE INDEX IF NOT EXISTS idx_inventories_covering ON inventories(room_type_id, date, available_count, price);
-- 订单表CREATE INDEX IF NOT EXISTS idx_orders_user_status ON orders(user_id, status);CREATE INDEX IF NOT EXISTS idx_orders_status_created ON orders(status, created_at);3. 再次 EXPLAIN ANALYZE
Section titled “3. 再次 EXPLAIN ANALYZE”添加索引后再跑一次 EXPLAIN ANALYZE,对比:
- Seq Scan → Index Scan
- 执行时间降低了多少
4. 在 GORM 模型中声明索引
Section titled “4. 在 GORM 模型中声明索引”更新 Go 模型的 GORM tag,确保 AutoMigrate 能自动创建这些索引:
type Inventory struct { // ... RoomTypeID uint `gorm:"index:idx_inventories_search;index:idx_inventories_covering"` Date time.Time `gorm:"index:idx_inventories_search;index:idx_inventories_covering"` // ...}5. 压测验证
Section titled “5. 压测验证”跑和步骤1一样的压测,记录改善幅度。
请输出:
- EXPLAIN ANALYZE 的解读(模拟输出即可)
- 完整的 SQL 迁移文件
- 更新后的 GORM 模型
- 优化前后的性能对比表格
### 步骤3: Redis 缓存
实现 Cache-aside 模式,缓存搜索结果。你是一名 Go 后端工程师,熟悉 Redis 缓存策略。
请为酒店预订系统添加 Redis 缓存,使用 Cache-aside(旁路缓存)模式。
缓存策略设计
Section titled “缓存策略设计”- 搜索结果:
search:{city}:{check_in}:{check_out}:{guests}:{min_price}:{max_price}:{page}:{page_size}- TTL: 5 分钟(库存变化频繁,不能太长)
- 酒店详情:
hotel:{id}- TTL: 30 分钟
- 房型列表:
hotel:{id}:room_types- TTL: 30 分钟
- 订单数据(强一致性要求)
- 库存数据(实时性要求)
- 用户数据(隐私敏感)
请创建以下文件
Section titled “请创建以下文件”1. server/cache/redis.go — Redis 客户端初始化
Section titled “1. server/cache/redis.go — Redis 客户端初始化”package cache
import ( "context" "encoding/json" "time" "github.com/redis/go-redis/v9")
var Client *redis.Client
func InitRedis(addr string) error { Client = redis.NewClient(&redis.Options{ Addr: addr, Password: "", DB: 0, PoolSize: 20, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return Client.Ping(ctx).Err()}
// Get 从缓存获取并反序列化func Get[T any](ctx context.Context, key string) (*T, error) { val, err := Client.Get(ctx, key).Result() if err == redis.Nil { return nil, nil // 缓存未命中 } if err != nil { return nil, err } var result T if err := json.Unmarshal([]byte(val), &result); err != nil { return nil, err } return &result, nil}
// Set 序列化并写入缓存func Set[T any](ctx context.Context, key string, value T, ttl time.Duration) error { data, err := json.Marshal(value) if err != nil { return err } return Client.Set(ctx, key, data, ttl).Err()}
// Delete 删除缓存func Delete(ctx context.Context, keys ...string) error { return Client.Del(ctx, keys...).Err()}
// DeletePattern 按模式删除(用于失效某类缓存)func DeletePattern(ctx context.Context, pattern string) error { iter := Client.Scan(ctx, 0, pattern, 100).Iterator() var keys []string for iter.Next(ctx) { keys = append(keys, iter.Val()) } if len(keys) > 0 { return Client.Del(ctx, keys...).Err() } return nil}2. 更新 server/handlers/hotel.go — 搜索加缓存
Section titled “2. 更新 server/handlers/hotel.go — 搜索加缓存”func SearchHotels(c *gin.Context) { // ... 参数解析 ...
// 1. 构建缓存 key cacheKey := fmt.Sprintf("search:%s:%s:%s:%d:%d:%d:%d:%d", city, checkIn, checkOut, guests, minPrice, maxPrice, page, pageSize)
// 2. 尝试从缓存读取 ctx := c.Request.Context() cached, err := cache.Get[SearchResult](ctx, cacheKey) if err != nil { // 缓存读取失败,不影响主流程,只记日志 log.Printf("缓存读取失败: %v", err) } if cached != nil { c.JSON(200, gin.H{"data": cached.Hotels, "pagination": cached.Pagination, "from_cache": true}) return }
// 3. 缓存未命中,查数据库 // ... 原有的数据库查询逻辑 ...
// 4. 写入缓存(异步,不阻塞响应) go func() { result := SearchResult{Hotels: hotels, Pagination: pagination} ttl := 5*time.Minute + time.Duration(rand.Intn(60))*time.Second // 随机TTL防雪崩 if err := cache.Set(context.Background(), cacheKey, result, ttl); err != nil { log.Printf("缓存写入失败: %v", err) } }()
c.JSON(200, gin.H{"data": hotels, "pagination": pagination})}3. 防止缓存穿透
Section titled “3. 防止缓存穿透”对于不存在的城市(如 “亚特兰蒂斯”),每次都会查数据库且结果为空。
// 空结果也缓存,但 TTL 短(1分钟)if len(hotels) == 0 { cache.Set(ctx, cacheKey, SearchResult{Hotels: []Hotel{}, Pagination: pagination}, 1*time.Minute)}4. 防止缓存雪崩
Section titled “4. 防止缓存雪崩”大量缓存同时过期 → 请求全部打到数据库。
解决方案:
- TTL 添加随机偏移:
5分钟 + rand(0~60秒) - 热点数据提前续期(访问时如果 TTL < 1分钟,异步延长)
5. 缓存失效
Section titled “5. 缓存失效”预订成功后,需要失效相关的搜索缓存:
// 在创建预订成功后func invalidateSearchCache(ctx context.Context, hotelID uint) { // 查询酒店的城市 var hotel models.Hotel database.DB.Select("city").First(&hotel, hotelID)
// 删除该城市的所有搜索缓存 cache.DeletePattern(ctx, fmt.Sprintf("search:%s:*", hotel.City))
// 删除酒店详情缓存 cache.Delete(ctx, fmt.Sprintf("hotel:%d", hotelID))}6. server/.env.example 追加
Section titled “6. server/.env.example 追加”REDIS_ADDR=localhost:6379
请输出所有文件的完整代码。确保缓存失败时不影响主流程(降级为直接查库)。
### 步骤4: N+1 查询修复你是一名 Go+GORM 后端工程师。
请找出并修复酒店预订系统中的 N+1 查询问题。
什么是 N+1 问题
Section titled “什么是 N+1 问题”查询 10 家酒店:
- 查询酒店列表 → 1 条 SQL
- 对每家酒店查房型 → 10 条 SQL
- 对每个房型查库存 → 30 条 SQL(假设每家 3 个房型) 总共 41 条 SQL!应该 2-3 条就够。
定位 N+1
Section titled “定位 N+1”方法1: 开启 GORM 日志
Section titled “方法1: 开启 GORM 日志”// 临时开启详细日志db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Info)})方法2: 在搜索 API 上数 SQL 数量
Section titled “方法2: 在搜索 API 上数 SQL 数量”在日志中搜索 [rows: 关键字,数一下一次搜索请求执行了多少条 SQL。
场景1: 酒店详情页加载房型
Section titled “场景1: 酒店详情页加载房型”❌ 错误做法(N+1):
var hotel Hoteldb.First(&hotel, id)// 然后在模板/JSON序列化时触发 lazy load✅ 正确做法(Preload):
var hotel Hoteldb.Preload("RoomTypes").First(&hotel, id)// 2条SQL: SELECT * FROM hotels; SELECT * FROM room_types WHERE hotel_id IN (?)✅ 更好做法(JOIN):
var hotel Hoteldb.Joins("LEFT JOIN room_types ON room_types.hotel_id = hotels.id"). Where("hotels.id = ?", id). First(&hotel)// 1条SQL场景2: 搜索结果中每个酒店的最低价格
Section titled “场景2: 搜索结果中每个酒店的最低价格”❌ 错误做法:先查酒店列表,再逐个查最低价
var hotels []Hoteldb.Where("city = ?", city).Find(&hotels)for _, h := range hotels { var minPrice int64 db.Model(&Inventory{}). Joins("JOIN room_types ON ..."). Where("room_types.hotel_id = ?", h.ID). Select("MIN(price)").Scan(&minPrice) h.MinPrice = minPrice // N次查询!}✅ 正确做法:用子查询一次完成
subQuery := db.Model(&Inventory{}). Joins("JOIN room_types ON room_types.id = inventories.room_type_id"). Where("inventories.date >= ? AND inventories.date < ?", checkIn, checkOut). Where("inventories.available_count > 0"). Select("room_types.hotel_id, MIN(inventories.price) as min_price"). Group("room_types.hotel_id")
var results []struct { Hotel MinPrice int64}db.Table("hotels"). Joins("JOIN (?) as prices ON prices.hotel_id = hotels.id", subQuery). Where("hotels.city = ? AND hotels.status = 'active'", city). Select("hotels.*, prices.min_price"). Order("prices.min_price ASC"). Limit(pageSize).Offset(offset). Find(&results)场景3: 订单列表加载酒店名和房型名
Section titled “场景3: 订单列表加载酒店名和房型名”❌ N+1:
var orders []Orderdb.Where("user_id = ?", userID).Find(&orders)// 序列化时触发 lazy load Hotel 和 RoomType✅ Preload:
var orders []Orderdb.Preload("Hotel", func(db *gorm.DB) *gorm.DB { return db.Select("id", "name", "city") // 只查需要的字段}).Preload("RoomType", func(db *gorm.DB) *gorm.DB { return db.Select("id", "name")}).Where("user_id = ?", userID). Order("created_at DESC"). Find(&orders)请做以下事情
Section titled “请做以下事情”- 审查 handlers/hotel.go 和 handlers/booking.go 中的所有数据库查询
- 找出所有 N+1 问题
- 用 Preload 或 JOIN 修复
- 修复后开启 GORM 日志,验证 SQL 数量减少
- 给出修改前后的 SQL 数量对比
请输出修改后的完整代码。
### 步骤5: 异步处理 — 邮件通知你是一名 Go 后端工程师。
请将酒店预订系统中的邮件通知改为异步处理。
预订成功后同步发送邮件,如果邮件服务慢(2-3秒),用户要等很久才能看到预订成功页面。
方案:Go channel + worker
Section titled “方案:Go channel + worker”创建 server/tasks/email_worker.go
Section titled “创建 server/tasks/email_worker.go”package tasks
import ( "fmt" "log" "net/smtp")
// EmailTask 邮件任务type EmailTask struct { To string Subject string Body string}
// 邮件队列(带缓冲的 channel)var emailQueue = make(chan EmailTask, 100)
// StartEmailWorker 启动邮件工作协程(在 main.go 中调用)func StartEmailWorker(workerCount int) { for i := 0; i < workerCount; i++ { go func(id int) { log.Printf("邮件工作协程 #%d 已启动", id) for task := range emailQueue { if err := sendEmail(task); err != nil { log.Printf("邮件发送失败: to=%s, subject=%s, err=%v", task.To, task.Subject, err) // 失败重试:重新入队(最多重试3次,需要在 task 中加 retry 字段) } else { log.Printf("邮件发送成功: to=%s, subject=%s", task.To, task.Subject) } } }(i) }}
// EnqueueEmail 将邮件任务放入队列(非阻塞)func EnqueueEmail(to, subject, body string) { select { case emailQueue <- EmailTask{To: to, Subject: subject, Body: body}: // 入队成功 default: // 队列满了,记录日志(降级处理) log.Printf("邮件队列已满,丢弃邮件: to=%s, subject=%s", to, subject) }}
func sendEmail(task EmailTask) error { // 使用 SMTP 发送 // 开发环境可以用 MailHog (localhost:1025) 代替 from := "noreply@hotel-reservation.dev" addr := "localhost:1025" // MailHog SMTP
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", from, task.To, task.Subject, task.Body)
return smtp.SendMail(addr, nil, from, []string{task.To}, []byte(msg))}在预订成功后发送邮件
Section titled “在预订成功后发送邮件”// handlers/booking.go 中,创建订单成功后tasks.EnqueueEmail( user.Email, "预订确认 - " + hotel.Name, fmt.Sprintf(` <h2>预订确认</h2> <p>您已成功预订 %s 的 %s</p> <p>入住日期:%s</p> <p>离店日期:%s</p> <p>订单号:%s</p> <p>总价:¥%.2f</p> `, hotel.Name, roomType.Name, order.CheckInDate.Format("2006-01-02"), order.CheckOutDate.Format("2006-01-02"), order.ID, float64(order.TotalPrice)/100),)在 main.go 中启动 worker
Section titled “在 main.go 中启动 worker”// 启动 3 个邮件工作协程tasks.StartEmailWorker(3)Docker Compose 添加 MailHog(开发用邮件服务器)
Section titled “Docker Compose 添加 MailHog(开发用邮件服务器)”mailhog: image: mailhog/mailhog ports: - "1025:1025" # SMTP - "8025:8025" # Web UI(查看收到的邮件)请输出所有文件的完整代码。
### 步骤6: 再次压测对比你是一名性能工程师。
我已经完成了酒店预订系统的所有性能优化:
- 添加了数据库索引
- 添加了 Redis 缓存(Cache-aside)
- 修复了 N+1 查询
- 邮件通知异步化
请帮我做最终的压测和结果分析。
1. 压测脚本
Section titled “1. 压测脚本”使用和步骤1 相同的压测脚本(scripts/benchmark.sh),跑完后记录结果。
2. 更新 docs/benchmark-results.md
Section titled “2. 更新 docs/benchmark-results.md”# 性能优化结果
## 环境- 数据量:10000 酒店 / 40000 房型 / 3600000 库存- 机器:MacBook Pro M2, 16GB RAM- 数据库:PostgreSQL 15- 缓存:Redis 7
## 搜索 API 性能对比
| 指标 | 优化前 | +索引 | +缓存(首次) | +缓存(命中) | 目标 ||------|--------|-------|-------------|-------------|------|| QPS | ~100 | ~500 | ~500 | ~3000 | >2000 || P50延迟 | ~500ms | ~80ms | ~80ms | ~5ms | <50ms || P99延迟 | ~2s | ~200ms | ~200ms | ~20ms | <200ms || SQL/请求 | ~41 | ~41 | ~3 | 0 | <5 |
## 详情 API 性能对比
| 指标 | 优化前 | +索引+Preload | +缓存 ||------|--------|---------------|-------|| QPS | | | || P50延迟 | | | |
## 预订 API 性能
| 指标 | 优化前 | +异步邮件 ||------|--------|-----------|| P50延迟 | | || 说明 | 含同步邮件发送 | 邮件异步不阻塞 |
## 关键发现
1. **索引是最关键的优化** — 搜索 API 从 500ms 降到 80ms,提升 6 倍2. **缓存让读性能质变** — 命中缓存时 5ms,QPS 从 500 到 30003. **N+1 修复减少数据库压力** — SQL 从 41 条降到 3 条4. **异步邮件不影响核心指标** — 但改善了用户体感(响应时间减少 2-3 秒)
## 还可以继续优化的方向
1. 连接池调优(当前使用 GORM 默认配置)2. 数据库读写分离3. 搜索用 Elasticsearch4. 热点城市数据预热5. CDN 缓存静态资源3. 分析工具
Section titled “3. 分析工具”如果某个优化效果不明显,用以下方式定位:
PostgreSQL 慢查询日志
Section titled “PostgreSQL 慢查询日志”-- 开启慢查询日志ALTER SYSTEM SET log_min_duration_statement = 100; -- 记录 >100ms 的查询SELECT pg_reload_conf();Redis 命中率监控
Section titled “Redis 命中率监控”// 在 API 响应头中添加缓存状态c.Header("X-Cache", "HIT") // 或 "MISS"Go pprof 性能分析
Section titled “Go pprof 性能分析”import _ "net/http/pprof"go func() { log.Println(http.ListenAndServe("localhost:6060", nil))}()访问 http://localhost:6060/debug/pprof/ 查看 CPU 和内存 profile。
请输出完整的压测脚本和结果分析模板。
---
## 检查清单
- [ ] 基准压测已完成,有优化前的数据记录- [ ] 数据库中有 10000+ 酒店的测试数据- [ ] EXPLAIN ANALYZE 确认搜索查询使用了索引(Index Scan,非 Seq Scan)- [ ] Redis 缓存命中时,搜索 API 延迟 < 10ms- [ ] 缓存穿透已处理(空结果也缓存)- [ ] 缓存 TTL 有随机偏移(防雪崩)- [ ] 预订成功后,相关搜索缓存被正确失效- [ ] N+1 已修复,单次搜索请求的 SQL 数量 < 5- [ ] 优化后压测结果满足目标(搜索 P50 < 50ms, QPS > 2000)
---
## 常见踩坑
1. **缓存 key 不包含所有查询参数** — 如果搜索 key 只有 `city`,不包含 `check_in/check_out/guests/price`,那不同搜索条件会返回相同缓存结果。Key 必须包含所有影响结果的参数。
2. **缓存更新但没删旧缓存** — 预订成功后库存变了,但搜索缓存还是旧数据。必须在写操作后主动删除相关缓存。用 `DeletePattern("search:北京:*")` 删除该城市所有搜索缓存。注意:SCAN 命令在 key 很多时可能很慢,生产环境考虑用 Redis 发布/订阅。
3. **GORM Preload 不是 JOIN** — `db.Preload("RoomTypes")` 会执行 2 条 SQL(一次查酒店,一次查所有相关房型),不是 1 条 JOIN 查询。对于搜索场景,2 条 SQL 可能反而比 JOIN 快(因为 JOIN 结果集可能很大)。但对于详情页(查 1 个酒店的房型),JOIN 更好。
4. **Redis 连接数耗尽** — 默认连接池大小可能不够。高并发时出现 `redis: connection pool exhausted` 错误。在 `redis.NewClient` 中设置 `PoolSize: 20`(或更大),并设置 `MinIdleConns: 5`。
5. **压测时忘记关 GORM 日志** — GORM 的 Info 级别日志会打印每条 SQL,压测时 I/O 开销会严重影响性能数据。压测前改为 `logger.Silent` 或 `logger.Error`。