跳转到内容

阶段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:
```go
func 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/bash
echo "=== 酒店搜索 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"

创建 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

请创建 docs/benchmark-results.md 模板:

# 性能基准记录
## 环境
- 数据量:10000 酒店 / 40000 房型 / 3600000 库存
- 机器:[你的机器配置]
- 数据库:PostgreSQL 15, 无额外优化
## 基准(优化前)
| 指标 | 搜索API | 详情API | 预订API |
|------|---------|---------|---------|
| QPS | | | |
| P50延迟 | | | |
| P99延迟 | | | |
| 错误率 | | | |

请输出所有文件的完整代码。

### 步骤2: 索引优化
用 EXPLAIN ANALYZE 找到慢查询,添加索引。

你是一名数据库性能优化工程师,使用 PostgreSQL。

请帮我优化酒店预订系统的搜索查询。

-- 搜索北京有空房的酒店
SELECT h.*, MIN(i.price) as min_price
FROM hotels h
JOIN room_types rt ON rt.hotel_id = h.id
JOIN inventories i ON i.room_type_id = rt.id
WHERE 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 >= 2
GROUP BY h.id
HAVING COUNT(DISTINCT i.date) = 2 -- 每天都要有房
ORDER BY min_price ASC
LIMIT 10 OFFSET 0;

先运行 EXPLAIN ANALYZE,解读输出:

  • 哪些操作耗时最多?
  • 是否有 Seq Scan(全表扫描)?
  • 预估行数 vs 实际行数差距大吗?

创建 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 > 0
CREATE 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);

添加索引后再跑一次 EXPLAIN ANALYZE,对比:

  • Seq Scan → Index Scan
  • 执行时间降低了多少

更新 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"`
// ...
}

跑和步骤1一样的压测,记录改善幅度。

请输出:

  1. EXPLAIN ANALYZE 的解读(模拟输出即可)
  2. 完整的 SQL 迁移文件
  3. 更新后的 GORM 模型
  4. 优化前后的性能对比表格
### 步骤3: Redis 缓存
实现 Cache-aside 模式,缓存搜索结果。

你是一名 Go 后端工程师,熟悉 Redis 缓存策略。

请为酒店预订系统添加 Redis 缓存,使用 Cache-aside(旁路缓存)模式。

  1. 搜索结果search:{city}:{check_in}:{check_out}:{guests}:{min_price}:{max_price}:{page}:{page_size}
    • TTL: 5 分钟(库存变化频繁,不能太长)
  2. 酒店详情hotel:{id}
    • TTL: 30 分钟
  3. 房型列表hotel:{id}:room_types
    • TTL: 30 分钟
  • 订单数据(强一致性要求)
  • 库存数据(实时性要求)
  • 用户数据(隐私敏感)

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})
}

对于不存在的城市(如 “亚特兰蒂斯”),每次都会查数据库且结果为空。

// 空结果也缓存,但 TTL 短(1分钟)
if len(hotels) == 0 {
cache.Set(ctx, cacheKey, SearchResult{Hotels: []Hotel{}, Pagination: pagination}, 1*time.Minute)
}

大量缓存同时过期 → 请求全部打到数据库。

解决方案:

  • TTL 添加随机偏移:5分钟 + rand(0~60秒)
  • 热点数据提前续期(访问时如果 TTL < 1分钟,异步延长)

预订成功后,需要失效相关的搜索缓存:

// 在创建预订成功后
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))
}

REDIS_ADDR=localhost:6379

请输出所有文件的完整代码。确保缓存失败时不影响主流程(降级为直接查库)。

### 步骤4: N+1 查询修复

你是一名 Go+GORM 后端工程师。

请找出并修复酒店预订系统中的 N+1 查询问题。

查询 10 家酒店:

  1. 查询酒店列表 → 1 条 SQL
  2. 对每家酒店查房型 → 10 条 SQL
  3. 对每个房型查库存 → 30 条 SQL(假设每家 3 个房型) 总共 41 条 SQL!应该 2-3 条就够。
// 临时开启详细日志
db.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Info)})

在日志中搜索 [rows: 关键字,数一下一次搜索请求执行了多少条 SQL。

❌ 错误做法(N+1):

var hotel Hotel
db.First(&hotel, id)
// 然后在模板/JSON序列化时触发 lazy load

✅ 正确做法(Preload):

var hotel Hotel
db.Preload("RoomTypes").First(&hotel, id)
// 2条SQL: SELECT * FROM hotels; SELECT * FROM room_types WHERE hotel_id IN (?)

✅ 更好做法(JOIN):

var hotel Hotel
db.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 []Hotel
db.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 []Order
db.Where("user_id = ?", userID).Find(&orders)
// 序列化时触发 lazy load Hotel 和 RoomType

✅ Preload:

var orders []Order
db.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)
  1. 审查 handlers/hotel.go 和 handlers/booking.go 中的所有数据库查询
  2. 找出所有 N+1 问题
  3. 用 Preload 或 JOIN 修复
  4. 修复后开启 GORM 日志,验证 SQL 数量减少
  5. 给出修改前后的 SQL 数量对比

请输出修改后的完整代码。

### 步骤5: 异步处理 — 邮件通知

你是一名 Go 后端工程师。

请将酒店预订系统中的邮件通知改为异步处理。

预订成功后同步发送邮件,如果邮件服务慢(2-3秒),用户要等很久才能看到预订成功页面。

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))
}
// 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),
)
// 启动 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: 再次压测对比

你是一名性能工程师。

我已经完成了酒店预订系统的所有性能优化:

  1. 添加了数据库索引
  2. 添加了 Redis 缓存(Cache-aside)
  3. 修复了 N+1 查询
  4. 邮件通知异步化

请帮我做最终的压测和结果分析。

使用和步骤1 相同的压测脚本(scripts/benchmark.sh),跑完后记录结果。

# 性能优化结果
## 环境
- 数据量: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 到 3000
3. **N+1 修复减少数据库压力** — SQL 从 41 条降到 3 条
4. **异步邮件不影响核心指标** — 但改善了用户体感(响应时间减少 2-3 秒)
## 还可以继续优化的方向
1. 连接池调优(当前使用 GORM 默认配置)
2. 数据库读写分离
3. 搜索用 Elasticsearch
4. 热点城市数据预热
5. CDN 缓存静态资源

如果某个优化效果不明显,用以下方式定位:

-- 开启慢查询日志
ALTER SYSTEM SET log_min_duration_statement = 100; -- 记录 >100ms 的查询
SELECT pg_reload_conf();
// 在 API 响应头中添加缓存状态
c.Header("X-Cache", "HIT") // 或 "MISS"
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`。