V3: 性能问题 —— 「怎么这么慢?」
10个人用了一个月,数据库里有几千条费用记录。财务主管打开报表页要等5秒钟,发来消息说”这系统比Excel还慢”。你用EXPLAIN ANALYZE看了一下,全是Seq Scan(全表扫描),Dashboard接口每次都重新算聚合。
当前状态: V2功能正常,但几千条数据就开始卡了。 目标: 报表页从5秒降到500毫秒以内。 约束: 不改架构,加索引+缓存+分页解决。
问题分析(5层框架)
Section titled “问题分析(5层框架)”| 层级 | 问题 | 思考 |
|---|---|---|
| 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 ?Redis缓存设计
Section titled “Redis缓存设计”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/LIMIT | Cursor-based | Cursor | OFFSET越大越慢,cursor恒定O(1) |
| 缓存位置 | 内存Map | Redis | Redis | 多实例部署时共享,支持TTL |
| 缓存策略 | Write-through | Cache-aside | Cache-aside | 实现简单,读多写少场景适合 |
| 索引类型 | 单列索引 | 复合索引 | 两者结合 | 复合索引覆盖高频查询,单列索引覆盖其余 |
| 缓存TTL | 1分钟 | 5分钟 | 5分钟 | 报表数据允许短暂延迟,减少DB压力 |
给AI的Prompt
Section titled “给AI的Prompt”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服务且能正常启动
你学到了什么
Section titled “你学到了什么”| 知识点 | 对应模块 |
|---|---|
| 数据库索引原理、复合索引列顺序 | → 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(缓存) |
1. OFFSET分页越翻越慢
Section titled “1. OFFSET分页越翻越慢”-- 错误:第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;2. 复合索引列顺序搞反
Section titled “2. 复合索引列顺序搞反”-- 索引是 (user_id, date)-- 能用上索引的查询:WHERE user_id = 1 -- 用到第一列WHERE user_id = 1 AND date > '2024' -- 用到两列
-- 用不上索引的查询:WHERE date > '2024-01' -- 跳过了第一列,无法用复合索引-- 所以还需要一个单独的date索引3. 缓存和数据库不一致
Section titled “3. 缓存和数据库不一致”// 错误:先删缓存再改DB(并发时另一个请求可能把旧数据写回缓存)cache.Del(key)db.Create(&expense)
// 正确:先改DB再删缓存db.Create(&expense)cache.Del(key)// 还有极端case,但对于这个场景5分钟TTL兜底足够了4. 缓存穿透没处理
Section titled “4. 缓存穿透没处理”// 如果某用户某月没有数据,每次查缓存都miss,每次都打DB// 解法:空结果也缓存,但TTL短一些if len(expenses) == 0 { cache.Set(key, emptyResult, 1*time.Minute) // 空结果缓存1分钟}5. Redis挂了整个服务挂了
Section titled “5. Redis挂了整个服务挂了”// 错误:缓存操作报错直接返回500result, err := cache.Get(key)if err != nil { c.JSON(500, gin.H{"error": "cache error"}) return}
// 正确:缓存失败降级查DBresult, err := cache.Get(key)if err != nil { log.Warn("cache unavailable, falling back to DB") // 继续执行DB查询}6. 忘记在所有写操作后清缓存
Section titled “6. 忘记在所有写操作后清缓存”// 创建费用 → 清缓存 ✓// 更新费用 → 忘了清缓存 ✗ ← 很容易漏// 删除费用 → 忘了清缓存 ✗// 审批状态变更 → 忘了清缓存 ✗
// 建议:写一个统一的清缓存函数,所有写操作都调用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))}