优秀的编程知识分享平台

网站首页 > 技术文章 正文

七爪源码:N+1 查询如何烧毁您的数据库

nanyue 2024-09-09 04:59:25 技术文章 5 ℃

处理这种有害逻辑的简要指南

概述

您是否曾经看过电影/连续剧或体验过自己去一家提供客房服务的酒店?

假设您去其中一家酒店,酒店的餐厅在一楼,而您在十楼招待所。 现在,当你在那里时,你决定点一些午餐吃的东西。 想象一下,女服务员——而不是一次给你带来食物和补充品(饮料、甜点等)——给你带来每一餐、饮料、甜点等等,一个接一个。

那效率太低了,因为女服务员必须跑很多次才能给你带来你要求的一切。 他们需要从一楼到十楼来回走动。 这就是 N+1 问题,在多次运行中得到你所要求的一切。

理想的情况是把你点的东西放在这样的大推车里,这样女服务员就可以一次把它全部拿来。

本文将深入探讨此问题在代码中的表现,并提供您可以采取的解决方案来避免这种情况并确保您的应用程序具有最佳性能。

是时候看一些代码了

为了展示 N+1 在您的代码中的外观,我将构建一个简单的控制台应用程序,该应用程序打印可用的菜单以从餐厅订购。 为此,我们将有一个包含餐食和饮料表的数据库。 在菜单中,每餐都会附赠一杯饮品。

让我们看看这些表的模型:

// Meal model a record of meals table
type Meal struct {
	ID          uint
	Name        string
	Description string
	DrinkID     uint
	Drink       Drink
}

type Meals []Meal

// Drink model a record of drinks table
type Drink struct {
	ID          uint
	Name        string
	Description string
}

type Drinks []Drink

现在,让我们看看 N+1 的作用。 这里我们有一些方法可以从数据库中查询数据(你可以在我的 repo 中找到完整的代码):

type Waitress struct {
	db *sql.DB
}


// getMeals gets 20 meals from the db
func (b Waitress) getMeals() (Meals, error) {
	// this method has the logic to query from the db, if you want to see
	// the actual code, you can go the repo

	// SQL used in this method
	// `SELECT id, name, description, drink_id FROM meals LIMIT 20`


	return meals, nil
}

// getDrinkByID gets a drink from the db by an id
func (b Waitress) getDrinkByID(ID uint) (Drink, error) {
	// this method has the logic to query from the db, if you want to see
	// the actual code, you can go the repo
	
	// SQL used in this method
  // `SELECT id, name, description FROM drinks WHERE id = $1`
	
	return Drink{}, nil
}

最后,这是 N+1 查询问题的方法:

func (b Waitress) ListMenu() (Meals, error) {
	// the `1` of our `N+1`
	meals, err := b.getMeals()
	if err != nil {
		return nil, err
	}

	// here is our waitress going back and forth 
	// from floor 1 to 10, this will be the `N` of our `N+1`
	for i, meal := range meals {
		drink, err := b.getDrinkByID(meal.DrinkID)
		if err != nil {
			return nil, err
		}

		meals[i].Drink = drink
	}

	return meals, nil
}

是的,这个简单的逻辑会烧毁你的数据库,因为你要来回往每餐添加饮料,效率不高。

您要查询的记录越多或拥有的用户越多,这个 N+1 问题对您的应用程序的影响最大,因为时间复杂度是 O(N)/线性时间。

这里我给你一个后端的例子,但是这个问题也可以在你的前端找到,而不是直接调用你的数据库,你将调用后端的端点,同时调用数据库。


解决方案

现在,让我们看看我们的问题的两种解决方案。


在 SQL 查询中加入作者

这可能是更简单的解决方案。 在这里,您必须编写如下查询:

SELECT m.id, m.name, m.description, d.name, d.description
FROM meals AS m
	INNER JOIN drinks AS d ON d.id = m.drink_id

使用此查询,我们的代码将如下所示:

type Solution1 struct {
	db *sql.DB
}

func (s Solution1) ListMenu() (Meals, error) {
	// this method will execute the previous SQL
	// SELECT
	// m.id,
	// 	m.name,
	// 	m.description,
	// 	d.name,
	// 	d.description
	// FROM meals AS m
	// INNER JOIN drinks AS d ON d.id = m.drink_id

	// if you want to see the full code, refer to my repo

	return meals, nil
}

有了这个查询,现在我们只需查询一次我们的数据库,就是这样。


享用餐点,然后使用您的编程语言加入饮品

不,我们不会像我们看到 N+1 问题的例子那样做。 在这里,我们将在数据库中进行两次查询,而不是一一查询饭菜然后查询饮料。 让我们看看如何:



func (s Solution2) ListMenu() (Meals, error) {
	meals, err := s.getMeals()
	if err != nil {
		return nil, err
	}

	// this is our waitress bringing everything in a bussing cart 
	drinks, err := s.getDrinksByIDsIn(meals.GetUniqueDrinkIDs())
	if err != nil {
		return nil, err
	}

	meals.JoinDrinks(drinks)

	return meals, nil
}

func (s Solution2) getMeals() (Meals, error) {
	// this method has the logic to query from the db, if you want to see
	// what's in these methods, you can go the repo

	// sql used in this method
	// `SELECT id, name, description, drink_id FROM meals LIMIT 20`

	return Meals{}, nil
}

func (s Solution2) getDrinksByIDsIn(ids []uint) (Drinks, error) {
	// this method has the logic to query from the db, if you want to see
	// what's in these methods, you can go the repo

	// sql used in this method
	// `SELECT id, name, description FROM drinks WHERE id IN (1, 2, 3)`
	
	return Drinks{}, nil
}

如您所见,我们的数据库只有两个查询:s.getMeals() 和 s.getDrinksByIDsIn,如果您阅读 ListMenu 方法,您会注意到我们引入了另外两个方法。 让我们看看它们做了什么以及我们为什么需要它们:

// to query the drinks, we need to know their IDs, for that
// we add this method to our `Meals` slice
func (m Meals) GetUniqueDrinkIDs() []uint {
	var ids []uint
	drinks := make(map[uint]struct{}, 0)

	for _, v := range m {
		_, ok := drinks[v.DrinkID]
		if ok {
			continue
		}

		drinks[v.DrinkID] = struct{}{}
		ids = append(ids, v.DrinkID)
	}

	return ids
}

// once we've query our meals and drinks, we proceed to join the 
// corresponding drink to our meals
func (m Meals) JoinDrinks(drink Drinks) {
	for i, meal := range m {
		m[i].Drink = drink.GetByID(meal.DrinkID)
	}
}

// this methos is needed by the `JoinDrinks()` method
// so it can find the drink for a meal 
func (d Drinks) GetByID(ID uint) Drink {
	for _, drink := range d {
		if drink.ID == ID {
			return drink
		}
	}

	return Drink{}
}

现在,您可以看到我们不会为每种饮料都查询数据库。相反,在一个查询中,我们获取所有餐点,在另一个查询中,我们查询饮料,然后将它们加入相应的餐点。


何时使用一种或另一种解决方案?

好吧,在这个应用程序中,每餐只包含一种饮料,但如果一餐包含不止一种饮料会怎样?

在那种情况下,第一个解决方案对我们没有帮助,因为 SQL 查询将针对一顿饭中的每杯饮料重复记录。所以我们要做的是在我们首先查询餐点时使用第二种解决方案,然后将饮料加入到相应的餐点中


个人经验

在工作中,我们有一个微服务负责缓存大量关于我们每天两次或按需提供的产品的数据。由于这个问题,过去缓存所有数据大约需要一分钟。在我们去掉 N+1 之后,它从一分钟变成了两秒!


结论

不要高估像 N+1 这样的简单逻辑。你可以很容易地陷入这个问题,但你也可以很容易地解决它。但是如果你不及时做,你的应用程序性能会随着时间的推移让你知道。

我没有提到的是像 Gorm 这样的 ORM 中的 N+1。我没有这方面的经验,但如果你使用 ORM,我会建议你深入研究底层代码,看看你是否有这个问题。

注意:我在本文中构建代码的方式并不意味着您应该如何构建代码。尽可能简单地关注问题和解决方案代码。


关注七爪网,获取更多APP/小程序/网站源码资源!

Tags:

最近发表
标签列表