Към съдържанието
wannacode

ORM и проблемът N+1

Накратко: ORM превръща редовете на таблицата в обекти и пише SQL вместо теб, а проблемът N+1 е, когато вместо една заявка пуска по една за всеки ред.

Проблем

Бавната страница с поръчките

Страницата „Поръчки“ в админ панела показва номера на поръчката, датата и името на клиента. В магазина са 60 поръчки и страницата отваря бавно. Логът на базата показва 61 заявки за едно отваряне. Откъде са, при положение че в кода няма цикъл със SQL?

Идеята

ORM: класът вместо таблицата

ORM (object-relational mapping) е библиотека, която свързва клас в програмата с таблица в базата. Описваш класа Order и коя таблица е, а ORM-ът пише SQL вместо теб, когато поискаш поръчките:

@Entity
@Table(name = "orders")
class Order {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@Column(name = "ordered_at")
private LocalDate orderedAt;
}
List<Order> orders = em.createQuery("SELECT o FROM Order o ORDER BY o.id", Order.class)
.getResultList();

Това са Hibernate/JPA в Java, Entity Framework Core в C#, SQLAlchemy в Python и Prisma в JavaScript. Печелиш по-малко повтарящ се код, обекти вместо редове и една и съща програма за различни бази. Плащаш с това, че SQL-ът не се вижда: ORM-ът решава колко заявки да прати.

По-точно, за любопитните

Примерите с ORM не се пускат в проверката на сайта, защото пакетите им не са там. В истинска програма към тях има и настройка: адрес на базата, класът Customer и контекстът (EntityManager, DbContext, Session, PrismaClient).

Виж го

N+1

Ето същото без ORM, но със същата грешка: първо поръчките, после клиентът на всяка. Програмата брои заявките, които праща:

try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) {
setup(conn);
int queries = 0;
List<int[]> orders = new ArrayList<>();
try (Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT id, customer_id FROM orders ORDER BY id")) {
queries++;
while (rs.next()) {
orders.add(new int[] { rs.getInt("id"), rs.getInt("customer_id") });
}
}
for (int[] order : orders) {
try (PreparedStatement ps = conn.prepareStatement("SELECT name FROM customers WHERE id = ?")) {
ps.setInt(1, order[1]);
queries++;
try (ResultSet rs = ps.executeQuery()) {
rs.next();
System.out.println("поръчка " + order[0] + ": " + rs.getString("name"));
}
}
}
System.out.println("заявки: " + queries);
}
Изход
поръчка 1: Радост Христова
поръчка 2: Георги Петров
поръчка 3: Мария Иванова
поръчка 4: Ивана Колева
поръчка 5: Радост Христова
поръчка 6: Мария Иванова
заявки: 7

Шест поръчки, седем заявки: една за поръчките (1) и по една за клиента на всяка (N). Със 60 поръчки са 61. С ORM става същото, само че цикълът е невидим: o.getCustomer().getName() изглежда като четене на поле, а всъщност е заявка.

for (Order o : orders) {
System.out.println(o.getId() + ": " + o.getCustomer().getName()); // заявка за всеки клиент
}
// Поправката: клиентите идват в същата заявка.
em.createQuery("SELECT o FROM Order o JOIN FETCH o.customer ORDER BY o.id", Order.class)

Идеята

Една заявка

Базата може да свърже поръчките и клиентите сама и да върне всичко наведнъж:

try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) {
setup(conn);
String sql = """
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY o.id
""";
try (Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(sql)) {
while (rs.next()) {
System.out.println("поръчка " + rs.getInt("id") + ": " + rs.getString("name"));
}
}
System.out.println("заявки: 1");
}
Изход
поръчка 1: Радост Христова
поръчка 2: Георги Петров
поръчка 3: Мария Иванова
поръчка 4: Ивана Колева
поръчка 5: Радост Христова
поръчка 6: Мария Иванова
заявки: 1

Същите редове с една заявка. На страницата на магазина това е всичко, което трябва:

SQLбаза: магазин

Разликата не е в SQL-а на всяка заявка, а в пътя до базата. Всяка заявка е отиване и връщане по мрежата, обикновено под милисекунда в един център за данни и десетки милисекунди, ако базата е далече. 61 такива са забележими, 6001 спират страницата.

Пробвай

Последните поръчки

Таблото показва последните 5 поръчки: номер, дата, името и имейлът на клиента, най-новите първи (при еднаква дата по-големият номер отгоре). С една заявка.

SQLбаза: магазин

Упражнение

Колко поръчки има всеки клиент

Страницата „Клиенти“ показва всеки клиент с броя на поръчките му (колона orders), по азбучен ред. Без N+1, тоест без заявка за всеки клиент, и без да изпускаш клиентите без поръчки.

SQLбаза: магазин

Тест

Провери се

1. Страница показва 50 поръчки с името на клиента. Програмата чете поръчките с една заявка, а после клиента на всяка с отделна. Колко заявки е това?
2. Защо N+1 не личи, докато пишеш програмата?
3. Кое оправя N+1 в ORM?
4. ORM пише SQL вместо теб. Трябва ли тогава да знаеш SQL?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·