红魔咖啡馆

头发越掉越多,头发越掉越少

0%

【GORM】N+1查询问题

这是一种应用程序和数据库交互时的低效数据查询模式,本质上在于程序为了获取一个主表与关联的子表信息,错误的执行了一次用于查询主列表的查询,紧跟着有n次查询每个主对象关联子对象的、额外的独立查询,进而将一次查询变成了n+1次查询,总结下来:

  • 低效的数据查询模式
  • 发生处理在一对多和多对一的关联关系
  • 本质是一次主查询引发了n次额外的关联查询
  • 根源来自于ORM框架的懒加载机制与它通过放大的网络往返次数和数据库负载来拖垮性能

问题本质

现代应用架构中,应用程序服务器和数据库服务器是两个独立系统,通过网络进行通信,因此存在各种时间开销

一个存在n+1查询问题的程序,性能表现如下:访问详情页等只需要查询一条数据的场景时,响应很快,但访问列表页等需要显示多条数据的场景时,响应会急剧下降

这是因为数据库为了获取n份数据,会向数据库询问n次,每次获取一条数据,这种一问一答的高频、短小的模式会极大累计网络延迟的开销,导致响应急剧下降

懒加载

这个问题的根源在于ORM中懒加载的设计

懒加载是ORM为了提高性能和节省资源而设计的一种默认数据加载策略,即在需要的时候才会加载对应资源

如查询一个文章表,大部分的业务下我们只需要用到文章的标题和内容字段,而不关心其他关联信息,这时框架在执行查询的时候,它只会查询并填充本身的数据,而对于关联数据只会保留一个占位符,只有当我们的代码在未来某个时候第一次真正试图访问关联数据的某个属性时,这个占位符才会真正激活

这种懒加载策略在处理单个对象的场景下是很高效的,但和循环等批量查询结合时,就会产生性能灾难

解决方法

预加载

即提前准备好关联对象的数据,比如使用sql里的连接查询,包括自己手写SQL语句与使用ORM里的关联查询语句(GORM中的preload,只需要两次查询)

这种方法也有局限性,若两个表不在同一个服务器就无法直接JOIN

自行控制子查询

比如将多次查询封装成一次查询:查询一次用户列表,将id存入列表,为其他微服务提供批量查询接口,并用map在调用方组装数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
productIDs := make([]string, len(orders))
for i, order := range orders {
    productIDs[i] = order.ProductID
}

// 一次批量请求
products, _ := productClient.ListProductsByIDs(ctx, productIDs)

// 内存映射
productMap := make(map[string]Product, len(products))
for _, p := range products {
    productMap[p.ID] = p
}

for i, order := range orders {
    orders[i].Product = productMap[order.ProductID]
}

聚合层BFF

若某个前端页面需要来自多个微服务的数据,不要再前端或客户端一个个调,而是建立一个BFF层,BFF层内部可以通过批量调用或goroutine并发提升效率

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var (
    users    []User
    orders   []Order
    products []Product
)

g, ctx := errgroup.WithContext(ctx)

g.Go(func() error {
    users, err = userClient.ListUsers(ctx, userIDs)
    return err
})
g.Go(func() error {
    orders, err = orderClient.ListOrders(ctx, orderIDs)
    return err
})
g.Go(func() error {
    products, err = productClient.ListProducts(ctx, productIDs)
    return err
})

if err := g.Wait(); err != nil {
    // 处理错误
}

数据冗余

如果某些数据要经常被一起读取,可以允许部分数据在多个服务内冗余存储

比如订单服务存储商品id与名称,不用每次去商品服务查,当商品服务修改商品名称时,发出一条更新事件,订单服务消费事件更新自己的冗余字段

这种情况适用于读次数远大于写次数,对实时一致性要求不高的场景

CQRS

命令查询职责分离;即把读写模型彻底分开

构建一个独立的查询服务,消费所有相关事件,构建一张宽表,需要多服务关联数据的查询可以专门对着这个读视图来查,查询一次

1
2
3
// 查询服务提供专用接口
orders, err := readClient.GetOrderDetails(ctx, userID)
// 返回的 OrderDetails 已经包含用户信息、商品信息、物流状态等

接口层提供展开参数

如Netflix的API设计,下游服务提供一个expand参数,允许调用方在请求时指定要关联的字段,一次返回扩展后的数据

1
GET /orders?expand=product,customer

这样调用方只需要一次网络请求,被调用方自己可以控制如何高效查询

例子

假设我们有一个user表,一个order表,两者是一对多的关系,user是post的外键:

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 用户表
CREATE TABLE users (
    id   INT PRIMARY KEY,
    name VARCHAR(50)
);

-- 订单表
CREATE TABLE orders (
    id      INT PRIMARY KEY,
    user_id INT,
    amount  DECIMAL(10,2),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

我们需要查询所有用户与他们的所有订单

n+1

以下这种写法是典型的n+1查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1 次查询所有用户
rows, _ := db.Query("SELECT id, name FROM users")
var users []User
for rows.Next() {
    var u User
    rows.Scan(&u.ID, &u.Name)
    users = append(users, u)
}

// 对于每个用户,再发 1 次查询获取订单 (N 次)
for i, u := range users {
    rows, _ := db.Query("SELECT id, amount FROM orders WHERE user_id = ?", u.ID)
    for rows.Next() {
        var o Order
        rows.Scan(&o.ID, &o.Amount)
        users[i].Orders = append(users[i].Orders, o)
    }
}

一次查用户+每个用户查一次订单,共n次

因此总计1+n次查询,日志中会体现为执行了大量结构相同,参数不同的sql语句

这样写会极大浪费网络开销

正确做法

可以使用join一次性得到所有数据

1
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
// 一次 JOIN 查询
rows, _ := db.Query(`
    SELECT u.id, u.name, o.id, o.amount
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
    ORDER BY u.id
`)
// 用 map 在应用层合并数据
userMap := make(map[int]*User)
for rows.Next() {
    var uID, oID int
    var uName string
    var oAmount sql.NullFloat64  // 可能没有订单,用 Null 类型
    rows.Scan(&uID, &uName, &oID, &oAmount)

    // 如果用户还没放进 map,创建一个
    if _, ok := userMap[uID]; !ok {
        userMap[uID] = &User{ID: uID, Name: uName}
    }
    // 如果有订单数据,追加进去
    if oAmount.Valid {
        userMap[uID].Orders = append(userMap[uID].Orders, Order{ID: oID, Amount: oAmount.Float64})
    }
}
users := make([]*User, 0, len(userMap))
for _, u := range userMap {
    users = append(users, u)
}

首先用一次JOIN得到所有数据,然后在应用层用map合并数据,当然用preload会更省代码:

1
2
var users []User
db.Preload("Orders").Find(&users)