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();public class Order{ public int Id { get; set; } public int CustomerId { get; set; } public Customer Customer { get; set; } public DateOnly OrderedAt { get; set; }}
var orders = db.Orders.OrderBy(o => o.Id).ToList();class Order(Base): __tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True) customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id")) customer: Mapped["Customer"] = relationship() ordered_at: Mapped[date]
orders = session.scalars(select(Order).order_by(Order.id)).all()model Order { id Int @id customerId Int @map("customer_id") customer Customer @relation(fields: [customerId], references: [id]) orderedAt DateTime @map("ordered_at")
@@map("orders")}
const orders = await prisma.order.findMany({ orderBy: { id: "asc" } });Това са 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: Мария Ивановазаявки: 7using var conn = new SqliteConnection("Data Source=:memory:");conn.Open();Setup(conn);var queries = 0;var orders = new List<(long Id, long CustomerId)>();var all = conn.CreateCommand();all.CommandText = "SELECT id, customer_id FROM orders ORDER BY id";queries++;using (var reader = all.ExecuteReader()){ while (reader.Read()) { orders.Add((reader.GetInt64(0), reader.GetInt64(1))); }}foreach (var order in orders){ var one = conn.CreateCommand(); one.CommandText = "SELECT name FROM customers WHERE id = $id"; one.Parameters.AddWithValue("$id", order.CustomerId); queries++; Console.WriteLine($"поръчка {order.Id}: {one.ExecuteScalar()}");}Console.WriteLine($"заявки: {queries}");поръчка 1: Радост Христовапоръчка 2: Георги Петровпоръчка 3: Мария Ивановапоръчка 4: Ивана Колевапоръчка 5: Радост Христовапоръчка 6: Мария Ивановазаявки: 7conn = sqlite3.connect(":memory:")setup(conn)queries = 1orders = conn.execute("SELECT id, customer_id FROM orders ORDER BY id").fetchall()for order_id, customer_id in orders: queries += 1 (name,) = conn.execute("SELECT name FROM customers WHERE id = ?", (customer_id,)).fetchone() print(f"поръчка {order_id}: {name}")print("заявки:", queries)conn.close()поръчка 1: Радост Христовапоръчка 2: Георги Петровпоръчка 3: Мария Ивановапоръчка 4: Ивана Колевапоръчка 5: Радост Христовапоръчка 6: Мария Ивановазаявки: 7const db = new DatabaseSync(":memory:");setup(db);let queries = 1;const orders = db.prepare("SELECT id, customer_id FROM orders ORDER BY id").all();const customer = db.prepare("SELECT name FROM customers WHERE id = ?");for (const order of orders) { queries++; console.log(`поръчка ${order.id}: ${customer.get(order.customer_id).name}`);}console.log("заявки:", queries);db.close();поръчка 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)// В EF Core мързеливото зареждане е изключено по подразбиране: без Include тук Customer е null.// С UseLazyLoadingProxies() всяко o.Customer става отделна заявка.foreach (var o in orders){ Console.WriteLine($"{o.Id}: {o.Customer.Name}");}
// Поправката:var withCustomers = db.Orders.Include(o => o.Customer).OrderBy(o => o.Id).ToList();for o in orders: print(f"{o.id}: {o.customer.name}") # заявка за всеки клиент (lazy="select")
# Поправката: клиентите идват в същата заявка (JOIN) или в една втора (selectinload).orders = session.scalars(select(Order).options(joinedload(Order.customer)).order_by(Order.id)).all()// Prisma не зарежда връзките сама: N+1 се получава, ако ги поискаш в цикъл.for (const o of orders) { const c = await prisma.customer.findUnique({ where: { id: o.customerId } }); console.log(`${o.id}: ${c.name}`);}
// Поправката:const withCustomers = await prisma.order.findMany({ include: { customer: true }, orderBy: { id: "asc" } });Идеята
Една заявка
Базата може да свърже поръчките и клиентите сама и да върне всичко наведнъж:
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: Мария Ивановазаявки: 1using var conn = new SqliteConnection("Data Source=:memory:");conn.Open();Setup(conn);var cmd = conn.CreateCommand();cmd.CommandText = """ SELECT o.id, c.name FROM orders o JOIN customers c ON c.id = o.customer_id ORDER BY o.id """;using var reader = cmd.ExecuteReader();while (reader.Read()){ Console.WriteLine($"поръчка {reader.GetInt64(0)}: {reader.GetString(1)}");}Console.WriteLine("заявки: 1");поръчка 1: Радост Христовапоръчка 2: Георги Петровпоръчка 3: Мария Ивановапоръчка 4: Ивана Колевапоръчка 5: Радост Христовапоръчка 6: Мария Ивановазаявки: 1conn = sqlite3.connect(":memory:")setup(conn)sql = """ SELECT o.id, c.name FROM orders o JOIN customers c ON c.id = o.customer_id ORDER BY o.id"""for order_id, name in conn.execute(sql): print(f"поръчка {order_id}: {name}")print("заявки: 1")conn.close()поръчка 1: Радост Христовапоръчка 2: Георги Петровпоръчка 3: Мария Ивановапоръчка 4: Ивана Колевапоръчка 5: Радост Христовапоръчка 6: Мария Ивановазаявки: 1const db = new DatabaseSync(":memory:");setup(db);const sql = ` SELECT o.id, c.name FROM orders o JOIN customers c ON c.id = o.customer_id ORDER BY o.id`;for (const row of db.prepare(sql).all()) { console.log(`поръчка ${row.id}: ${row.name}`);}console.log("заявки: 1");db.close();поръчка 1: Радост Христовапоръчка 2: Георги Петровпоръчка 3: Мария Ивановапоръчка 4: Ивана Колевапоръчка 5: Радост Христовапоръчка 6: Мария Ивановазаявки: 1Същите редове с една заявка. На страницата на магазина това е всичко, което трябва:
Разликата не е в SQL-а на всяка заявка, а в пътя до базата. Всяка заявка е отиване и връщане по мрежата, обикновено под милисекунда в един център за данни и десетки милисекунди, ако базата е далече. 61 такива са забележими, 6001 спират страницата.
Пробвай
Последните поръчки
Таблото показва последните 5 поръчки: номер, дата, името и имейлът на клиента, най-новите първи (при еднаква дата по-големият номер отгоре). С една заявка.
Решение
SELECT o.id, o.ordered_at, c.name, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY o.ordered_at DESC, o.id DESC
LIMIT 5;С JOIN клиентът идва заедно с поръчката. LIMIT 5 е в същата заявка, а не в програмата: базата връща пет реда, а не 60.
Упражнение
Колко поръчки има всеки клиент
Страницата „Клиенти“ показва всеки клиент с броя на поръчките му (колона orders), по азбучен ред. Без N+1, тоест без заявка за всеки клиент, и без да изпускаш клиентите без поръчки.
Решение
SELECT c.name, COUNT(o.id) AS orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY c.name;LEFT JOIN пази клиентите без поръчки, а COUNT(o.id) им дава 0 (COUNT(*) би дал 1, защото броят реда с NULL). Иначе програмата би пратила по един SELECT COUNT(*) за всеки от 20-те клиента.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: JOIN: две таблици в една заявка · LEFT JOIN: и редовете без двойка · Връзка с базата от код · SQL injection и параметрите
- Задачи по темата: Базата в програмата