跳转到内容

V3: 性能问题 —— 「怎么这么慢?」

10个人用了一个月,数据库里有几千条费用记录。财务主管打开报表页要等5秒钟,发来消息说”这系统比Excel还慢”。你用EXPLAIN ANALYZE看了一下,全是Seq Scan(全表扫描),Dashboard接口每次都重新算聚合。

当前状态: V2功能正常,但几千条数据就开始卡了。 目标: 报表页从5秒降到500毫秒以内。 约束: 不改架构,加索引+缓存+分页解决。


层级问题思考
Why为什么慢?没有索引→全表扫描;每次请求都重算聚合;一次返回全部数据
What慢在哪?Dashboard聚合查询(GROUP BY);费用列表无分页全量返回
How (索引)加什么索引?user_id、date、category、status,覆盖常用查询条件
How (缓存)缓存什么?Dashboard结果缓存到Redis,数据变更时失效
How (分页)怎么分页?cursor-based分页,避免OFFSET性能问题

graph LR
B["浏览器<br/>(React)"] --> G["Go/Gin<br/>后端"]
G -->|"cache hit"| R["Redis<br/>缓存"]
G -->|"cache miss"| DB["PostgreSQL<br/>+ 索引"]
DB --> G
R --> G
// GORM通过标签加索引
type Expense struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"index:idx_user_date,priority:1"`
Date string `gorm:"index:idx_user_date,priority:2;index:idx_date"`
Category string `gorm:"index:idx_category"`
Status string `gorm:"index:idx_status"`
Amount int64
Note string
CreatedAt time.Time `gorm:"index:idx_created"`
}
// 复合索引 idx_user_date 覆盖最常用的查询:
// SELECT * FROM expenses WHERE user_id = ? AND date BETWEEN ? AND ?
Key格式: dashboard:{user_id}:{month}
Value: JSON序列化的Dashboard结果
TTL: 5分钟
失效策略: 费用增删改时删除对应用户当月的缓存key
// cursor-based 分页(基于id)
GET /api/expenses?cursor=100&limit=20
// 返回
{
"data": [...],
"next_cursor": 80, // 下一页的起始id
"has_more": true
}
// SQL: WHERE id < cursor ORDER BY id DESC LIMIT 20
决策点选项A选项B选择理由
分页方式OFFSET/LIMITCursor-basedCursorOFFSET越大越慢,cursor恒定O(1)
缓存位置内存MapRedisRedis多实例部署时共享,支持TTL
缓存策略Write-throughCache-asideCache-aside实现简单,读多写少场景适合
索引类型单列索引复合索引两者结合复合索引覆盖高频查询,单列索引覆盖其余
缓存TTL1分钟5分钟5分钟报表数据允许短暂延迟,减少DB压力

V2的记账系统遇到性能问题,几千条数据报表页要等5秒。请帮我做以下优化:
## 问题诊断
当前SQL执行计划显示全表扫描(Seq Scan),没有任何索引。
Dashboard每次请求都执行 GROUP BY 聚合,没有缓存。
费用列表一次返回全部数据,没有分页。
## 优化1:添加数据库索引
在Expense表上添加以下索引:
- 复合索引 (user_id, date):覆盖"某用户某月费用"查询
- 单列索引 date:覆盖管理员按日期范围查询
- 单列索引 category:覆盖分类统计
- 单列索引 status:覆盖审批状态过滤
- 单列索引 created_at:覆盖排序
用GORM标签方式定义索引,AutoMigrate会自动创建。
## 优化2:Redis缓存Dashboard
- docker-compose.yml 加一个Redis服务
- 用go-redis库连接Redis
- Dashboard接口先查缓存,miss时查DB并写入缓存
- 缓存key格式:dashboard:{user_id}:{month},TTL 5分钟
- 费用的创建、更新、删除操作后,删除对应用户当月缓存
- 管理员Dashboard缓存key:dashboard:admin:{month}
## 优化3:Cursor-based分页
- GET /api/expenses 接收 cursor(上一页最后一条的id)和 limit(默认20)
- SQL: WHERE user_id = ? AND id < cursor ORDER BY id DESC LIMIT ?
- 第一次请求不传cursor,返回最新的20条
- 返回格式:{ data: [], next_cursor: 最后一条的id, has_more: bool }
## 优化4:EXPLAIN ANALYZE验证
- 写一个测试或脚本,用 EXPLAIN ANALYZE 对比加索引前后的查询计划
- 把结果打印出来,确认从Seq Scan变成了Index Scan
## 技术要求
- Redis连接信息从环境变量读取,默认 localhost:6379
- 缓存失效用DEL命令,不要用FLUSH
- 分页limit最大100,防止客户端传过大的值
- 如果Redis不可用,降级直接查DB(不能因为缓存挂了整个服务不可用)
请保持V2所有功能不变,只做性能优化。

  • EXPLAIN ANALYZE确认查询使用Index Scan而非Seq Scan
  • Dashboard首次请求正常返回(缓存miss,查DB)
  • Dashboard第二次请求明显更快(缓存hit)
  • 创建新费用后,Dashboard缓存被正确清除
  • 分页第一页返回正确数据和next_cursor
  • 使用next_cursor请求第二页,数据不重复不遗漏
  • limit超过100时自动截断为100
  • Redis挂掉后,接口仍能正常工作(降级查DB)
  • docker-compose.yml包含Redis服务且能正常启动

知识点对应模块
数据库索引原理、复合索引列顺序→ Module 2(索引)
EXPLAIN ANALYZE读懂查询计划→ Module 2(索引)
Cache-aside缓存模式、TTL、失效策略→ Module 4(缓存)
Cursor-based分页 vs OFFSET分页→ Module 1(分页)
Redis基础操作:GET/SET/DEL/TTL→ Module 4(缓存)
缓存降级:Redis不可用时的容错→ Module 4(缓存)

-- 错误:第100页时数据库要先扫描前99页的数据再丢掉
SELECT * FROM expenses ORDER BY id DESC LIMIT 20 OFFSET 1980;
-- 正确:cursor-based,无论第几页都只扫描20条
SELECT * FROM expenses WHERE id < 1980 ORDER BY id DESC LIMIT 20;
-- 索引是 (user_id, date)
-- 能用上索引的查询:
WHERE user_id = 1 -- 用到第一列
WHERE user_id = 1 AND date > '2024' -- 用到两列
-- 用不上索引的查询:
WHERE date > '2024-01' -- 跳过了第一列,无法用复合索引
-- 所以还需要一个单独的date索引
// 错误:先删缓存再改DB(并发时另一个请求可能把旧数据写回缓存)
cache.Del(key)
db.Create(&expense)
// 正确:先改DB再删缓存
db.Create(&expense)
cache.Del(key)
// 还有极端case,但对于这个场景5分钟TTL兜底足够了
// 如果某用户某月没有数据,每次查缓存都miss,每次都打DB
// 解法:空结果也缓存,但TTL短一些
if len(expenses) == 0 {
cache.Set(key, emptyResult, 1*time.Minute) // 空结果缓存1分钟
}
// 错误:缓存操作报错直接返回500
result, err := cache.Get(key)
if err != nil {
c.JSON(500, gin.H{"error": "cache error"})
return
}
// 正确:缓存失败降级查DB
result, err := cache.Get(key)
if err != nil {
log.Warn("cache unavailable, falling back to DB")
// 继续执行DB查询
}
// 创建费用 → 清缓存 ✓
// 更新费用 → 忘了清缓存 ✗ ← 很容易漏
// 删除费用 → 忘了清缓存 ✗
// 审批状态变更 → 忘了清缓存 ✗
// 建议:写一个统一的清缓存函数,所有写操作都调用
func invalidateDashboardCache(userID uint, date string) {
month := date[:7] // "2024-01-15" → "2024-01"
cache.Del(fmt.Sprintf("dashboard:%d:%s", userID, month))
cache.Del(fmt.Sprintf("dashboard:admin:%s", month))
}