👋 Chào mừng đến với Nocoem — kiến thức Backend, System Design và lập trình. Khám phá ngay

Backend Performance Best Practices - Database

TL;DR: Hiệu năng database không nằm ở một mẹo thần kỳ, mà là tổng hợp nhiều quyết định nhỏ: tái sử dụng kết nối thay vì mở mới liên tục, đánh chỉ mục đúng cột hay lọc, chỉ lấy đúng dữ liệu cần, và biết khi nào nên đánh đổi chuẩn hóa lấy tốc độ đọc. Bài này đi qua 14 việc cụ thể bạn có thể áp dụng cho tầng database của backend, từ tầng kết nối tới tầng vận hành.

Bạn có từng thấy một API chạy mượt lúc demo, nhưng lên production với vài trăm nghìn bản ghi thì ì ạch hẳn? Phần lớn thời gian phản hồi của một request thường nằm ở tầng database, chứ không phải ở code xử lý logic. Tin vui là những nút thắt phổ biến nhất đều có cách xử lý rõ ràng, không cần đổi cả kiến trúc. Dưới đây là danh sách những việc tôi thấy đáng làm nhất, xếp theo thứ tự từ tầng kết nối tới tầng vận hành.

Connection Pooling: Giảm Thiểu Chi Phí Kết Nối

Mỗi lần ứng dụng mở một kết nối mới tới database, nó phải bắt tay TCP, xác thực, rồi mới bắt đầu chạy truy vấn. Toàn bộ quá trình đó tốn thời gian và tài nguyên, dù bản thân truy vấn có thể chỉ mất vài mili giây. Connection pooling (gộp kết nối) giải quyết vấn đề bằng cách giữ sẵn một nhóm kết nối đã mở, request nào cần thì mượn, dùng xong trả lại, thay vì mở mới rồi đóng liên tục.

Hãy hình dung một bến taxi so với việc gọi hãng lắp ráp một chiếc xe mới cho từng khách: giữ sẵn vài chiếc taxi chờ ở bến (pool) rõ ràng nhanh hơn nhiều so với đóng một chiếc xe mới mỗi lần có khách gọi.

Với một trang thương mại điện tử lượng truy cập cao, bật connection pooling có thể giảm đáng kể độ trễ khi tải chi tiết sản phẩm hay xử lý thanh toán, vì phần lớn thời gian tiết kiệm được chính là phần "bắt tay" kết nối bị loại bỏ.

Tối Ưu Hóa Cài Đặt Connection Pool

Bật connection pooling mới là bước đầu, cấu hình đúng thông số mới là phần quyết định hiệu quả thực tế. Ba tham số đáng quan tâm nhất:

  • Kích thước pool (pool size): số kết nối tối đa giữ sẵn. Quá nhỏ thì request phải xếp hàng chờ, quá lớn thì tốn tài nguyên phía database, vì mỗi kết nối chiếm bộ nhớ và một phần giới hạn process/thread trên server DB.
  • Thời gian chờ khi hết pool (pool timeout): request nên đợi bao lâu trước khi báo lỗi, thay vì treo vô thời hạn.
  • Thời gian sống của kết nối (idle timeout / pool recycle): đóng và mở lại kết nối đã nằm im quá lâu, để tránh bị database hoặc firewall âm thầm cắt kết nối giữa chừng.

Ví dụ cấu hình pool bằng SQLAlchemy (Python):

from sqlalchemy import create_engine

engine = create_engine(
    "postgresql://user:pass@localhost/mydb",
    pool_size=10,       # số kết nối duy trì sẵn trong pool
    max_overflow=5,     # số kết nối "vay thêm" khi pool đầy, tự đóng khi trả lại
    pool_timeout=30,    # số giây chờ tối đa để lấy được một kết nối trước khi báo lỗi
    pool_recycle=1800,  # tái tạo kết nối sau 30 phút, tránh bị DB hoặc firewall ngắt ngầm
)

Quay lại ẩn dụ bến taxi: pool_size là số taxi đậu sẵn, max_overflow là số taxi gọi thêm từ hãng khi bến hết xe, còn pool_recycle giống việc định kỳ đưa xe về garage bảo dưỡng dù chưa hỏng, để tránh xe "chết máy" giữa chuyến. Trong một đợt giảm giá lớn với hàng nghìn người dùng liên tục kết nối và ngắt kết nối, cấu hình pool hợp lý giúp ứng dụng xử lý request nhanh và ổn định hơn nhiều so với để mặc định.

Lập Chỉ Mục Cơ Sở Dữ Liệu Hiệu Quả

Chỉ mục (index) là cấu trúc dữ liệu phụ giúp database tìm đúng dòng cần thiết mà không phải quét qua toàn bộ bảng. Không có chỉ mục, một truy vấn lọc theo customer_id buộc database phải đọc từng dòng để so sánh, giống như bạn lật từng trang sách vì thư viện không có hệ thống mục lục. Có chỉ mục đúng cột, database định vị thẳng tới đúng vị trí, giống bạn tra mục lục rồi mở đúng trang cần.

-- Trước khi có chỉ mục, PostgreSQL phải quét toàn bộ bảng orders
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;

-- Tạo chỉ mục cho cột hay dùng để lọc
CREATE INDEX idx_orders_customer_id ON orders (customer_id);

-- Chạy lại truy vấn sau khi có chỉ mục
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
Trước: Seq Scan on orders (cost=0.00..18334.00 rows=50 width=120) - quét toàn bộ khoảng 1 triệu dòng Sau: Index Scan using idx_orders_customer_id on orders (cost=0.29..8.31 rows=50 width=120) - chỉ đọc đúng phần liên quan
Lưu ý: chỉ mục không miễn phí. Mỗi lần ghi (insert/update/delete), database phải cập nhật thêm chỉ mục, nên đánh chỉ mục vào mọi cột sẽ làm chậm ghi dữ liệu và tốn thêm dung lượng lưu trữ. Ưu tiên chỉ mục cho cột hay xuất hiện trong WHERE, JOIN, hoặc ORDER BY.

Tinh Chỉnh Truy Vấn ORM

ORM (Object-Relational Mapping) dịch giữa mô hình hướng đối tượng trong code và câu lệnh SQL bên dưới, giúp bạn viết post.author.name thay vì tự tay ghép JOIN. Tiện là vậy, nhưng lớp dịch này có thể âm thầm sinh ra những truy vấn tệ nếu bạn không để ý. N+1 query là ví dụ kinh điển nhất: một vòng lặp N phần tử vô tình kích hoạt thêm N truy vấn phụ. Tôi có một bài riêng đi sâu vào N+1 query với ví dụ Django cụ thể, nếu bạn muốn xem chi tiết.

Cách phòng tránh phổ biến: dùng eager loading (select_related/prefetch_related trong Django, joinedload/selectinload trong SQLAlchemy) để gom dữ liệu liên quan vào ít truy vấn hơn, thay vì để ORM tự động gọi thêm truy vấn mỗi lần bạn truy cập một quan hệ.

Tối Ưu Hóa Truy Xuất Dữ Liệu với Lazy Loading, Eager Loading và Xử Lý Batch

Ba kỹ thuật này giải quyết ba tình huống khác nhau khi truy xuất dữ liệu:

  • Lazy loading: chỉ tải dữ liệu khi thực sự cần, giúp trang tải ban đầu nhanh hơn vì không phải chờ những phần chưa ai xem tới.
  • Eager loading: tải sẵn toàn bộ dữ liệu liên quan ngay từ đầu trong một (hoặc ít) truy vấn, đánh đổi độ trễ ban đầu cao hơn một chút để tránh hàng loạt truy vấn nhỏ về sau. Đây chính là cách khắc phục N+1 query ở mục trên.
  • Xử lý theo lô (batch processing): gom nhiều thao tác nhỏ giống nhau lại chạy cùng lúc, thay vì lặp lại chi phí mở/đóng cho từng thao tác riêng lẻ.

Chọn đúng kỹ thuật cho đúng tình huống, thay vì dùng một kiểu cho tất cả, mới là điều tạo ra khác biệt về hiệu năng.

Phân Trang Hiệu Quả cho Tập Dữ Liệu Lớn

Phân trang tưởng đơn giản, nhưng cách phổ biến nhất - dùng LIMIT kèm OFFSET - lại chậm dần khi người dùng lướt sâu vào các trang sau, vì database vẫn phải đếm qua toàn bộ số dòng bị bỏ qua trước khi lấy đủ 20 dòng bạn cần.

-- LIMIT/OFFSET: càng vào trang sau, DB càng phải đếm qua nhiều dòng bị bỏ
SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 100000;

-- Keyset pagination (còn gọi là seek method), dùng mốc là dòng cuối đã thấy
SELECT * FROM posts WHERE id > 100000 ORDER BY id LIMIT 20;

Cách thứ hai (keyset) nhanh và ổn định hơn với tập dữ liệu lớn vì tận dụng được chỉ mục trên cột id thay vì phải đếm qua từng dòng. Đánh đổi là bạn không nhảy thẳng tới "trang số 500" được nữa, chỉ đi tiếp hoặc lùi theo mốc, nên phù hợp với kiểu cuộn vô hạn (infinite scroll) hơn là phân trang có đánh số trang.

Tối Ưu Hóa Dữ Liệu: Tránh Truy Vấn Select * và Chỉ Truy Xuất Các Cột Cần Thiết

Truy vấn SELECT * tiện khi viết code nhanh, nhưng bắt database đọc và truyền tải toàn bộ cột trong bảng, kể cả những cột bạn không dùng tới. Với bảng có hàng trăm cột, hoặc vài cột chứa dữ liệu lớn (văn bản dài, JSON, blob), phần lãng phí này cộng dồn lại đáng kể.

-- Kéo toàn bộ cột dù chỉ cần hiển thị tên và giá
SELECT * FROM products WHERE category_id = 5;

-- Chỉ lấy đúng cột cần dùng
SELECT id, name, price FROM products WHERE category_id = 5;

Phi Chuẩn Hóa Lược Đồ Cơ Sở Dữ Liệu cho Khối Lượng Đọc Cao và Giảm Thiểu Thao Tác Nối

Chuẩn hóa (normalization) tách dữ liệu thành nhiều bảng để tránh trùng lặp, nhưng đổi lại phải JOIN nhiều bảng mỗi khi đọc. Nếu bạn chưa nắm rõ 1NF/2NF/3NF, bài về chuẩn hóa cơ sở dữ liệu của tôi giải thích từ đầu.

Với hệ thống đọc nhiều hơn ghi rất nhiều, ví dụ trang xem chi tiết sản phẩm của một sàn thương mại điện tử phải gộp dữ liệu từ nhiều bảng như sản phẩm, đánh giá, giá cả, nhà cung cấp, phi chuẩn hóa (denormalization) - gộp bớt dữ liệu hay dùng chung vào cùng một bảng - có thể loại bỏ phần lớn các JOIN đó, đổi lấy tốc độ đọc nhanh hơn.

Cái giá phải trả: dữ liệu bị lặp ở nhiều nơi, nên mỗi lần cập nhật (ví dụ đổi giá sản phẩm) bạn phải cập nhật đồng bộ ở mọi bản sao, nếu không hệ thống sẽ hiển thị dữ liệu không nhất quán. Vì vậy phi chuẩn hóa hợp nhất với dữ liệu ít thay đổi, hoặc khi bạn chấp nhận đánh đổi đó để lấy tốc độ đọc.

Tối Ưu Hóa Thao Tác Nối và Tránh Các Thao Tác Nối Không Cần Thiết

JOIN kết hợp dữ liệu từ nhiều bảng, nhưng là một trong những thao tác tốn tài nguyên nhất của database, đặc biệt khi bảng lớn hoặc thiếu chỉ mục trên cột dùng để nối. Hai việc đáng làm: đánh chỉ mục đúng cột khóa ngoại dùng để JOIN, và chọn đúng loại JOIN (INNER JOIN khi chắc chắn có dữ liệu khớp ở cả hai bên, LEFT JOIN khi cần giữ lại dòng không khớp).

Một cạm bẫy hay gặp: JOIN hai bảng vốn không có quan hệ logic thật sự, thường do quên điều kiện JOIN hoặc ghi sai điều kiện, vô tình tạo ra tích Descartes (cross join). Số dòng kết quả nhân lên gấp nhiều lần, khiến truy vấn ì ạch mà không rõ lý do ban đầu.

Bảo Trì và Dọn Dẹp Dữ Liệu Thường Xuyên

Dữ liệu bị đánh dấu xóa không biến mất ngay lập tức. Nhiều hệ quản trị, ví dụ PostgreSQL, chỉ đánh dấu dòng cũ là "chết" (dead tuple) rồi dọn dẹp sau, để tránh khóa bảng khi xóa. Nếu không dọn định kỳ, các dòng chết này chiếm chỗ, làm chậm quét bảng và khiến chỉ mục phình to không cần thiết.

-- PostgreSQL: dọn các dòng "chết" và cập nhật lại thống kê cho query planner
VACUUM ANALYZE orders;

Việc này giống dọn dẹp ngăn kéo định kỳ, thay vì để giấy tờ cũ chất chồng cho tới khi không tìm nổi thứ mình cần. Ngoài VACUUM, việc thay một truy vấn lồng nhau (subquery) bằng một JOIN tương đương đôi khi cũng giúp query planner chọn được kế hoạch thực thi tốt hơn.

Ghi Nhật Ký Truy Vấn Chậm và Giám Sát Thường Xuyên

Ghi log truy vấn chậm giúp bạn tìm ra chính xác câu SQL nào đang kéo tụt hiệu năng, thay vì đoán mò. Hầu hết hệ quản trị phổ biến đều hỗ trợ sẵn:

-- MySQL: bật log cho truy vấn chạy lâu hơn 1 giây
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;

Giám sát các log này thường xuyên, chứ không chỉ bật lên rồi quên, mới giúp bạn phát hiện sớm những truy vấn đang chậm dần theo thời gian khi dữ liệu tăng lên.

Sao Chép Cơ Sở Dữ Liệu để Đảm Bảo Dự Phòng và Tăng Hiệu Năng Đọc

Sao chép cơ sở dữ liệu (replication) giữ nhiều bản sao dữ liệu trên các máy chủ khác nhau. Lợi ích rõ nhất là dự phòng, vì nếu máy chủ chính gặp sự cố thì một bản sao có thể thay thế, và tăng khả năng đọc, vì bạn có thể chuyển bớt truy vấn đọc sang các máy chủ sao chép để giảm tải cho máy chủ chính.

Cần nói rõ một điểm hay bị hiểu nhầm: replication không tự động tăng tính nhất quán dữ liệu. Với replication bất đồng bộ, kiểu phổ biến nhất, bản sao luôn trễ hơn bản gốc một khoảng thời gian ngắn, gọi là replication lag, nên đọc từ bản sao có thể thấy dữ liệu hơi cũ. Đây là đánh đổi bạn cần biết trước khi đẩy toàn bộ truy vấn đọc sang replica.

Ví dụ, trong một đợt bán hàng lớn, nếu mọi thao tác đọc và ghi đều dồn vào một database, hệ thống dễ nghẽn. Chuyển các thao tác đọc như xem danh sách sản phẩm sang máy chủ sao chép giúp giảm tải đáng kể, miễn là bạn chấp nhận người dùng có thể thấy dữ liệu trễ vài trăm mili giây, chẳng hạn số lượng tồn kho vừa cập nhật chưa kịp lan tới mọi bản sao.

Phân Mảnh Cơ Sở Dữ Liệu để Phân Phối Dữ Liệu

Phân mảnh cơ sở dữ liệu (database sharding) là một kiểu phân vùng (partitioning) theo chiều ngang: chia một database lớn thành nhiều mảnh nhỏ hơn, mỗi mảnh nằm trên một máy chủ riêng và chỉ giữ một phần dữ liệu. Khi một máy chủ không còn đủ sức chứa hoặc xử lý toàn bộ tải, sharding cho phép bạn phân tán dữ liệu và tải ra nhiều máy, thay vì phải liên tục nâng cấp một cỗ máy duy nhất.

Ví dụ, một ứng dụng thương mại điện tử có khách hàng toàn cầu có thể phân mảnh theo khu vực địa lý, để dữ liệu của khách ở khu vực nào nằm gần máy chủ phục vụ khu vực đó, giảm cả độ trễ mạng lẫn tải trên từng máy chủ.

Đánh đổi cần biết: truy vấn cần dữ liệu từ nhiều mảnh cùng lúc, ví dụ báo cáo tổng hợp toàn cầu, sẽ phức tạp hơn nhiều so với một database duy nhất, vì ứng dụng phải tự gộp kết quả từ nhiều nguồn.

Sử Dụng Các Công Cụ Phân Tích Hiệu Năng trong Quản Lý Cơ Sở Dữ Liệu

Hầu hết hệ quản trị cơ sở dữ liệu phổ biến đều có công cụ phân tích hiệu năng tích hợp, giúp bạn thấy trước tình trạng sức khỏe của hệ thống thay vì đợi người dùng report chậm. Ví dụ, EXPLAIN/EXPLAIN ANALYZE trong PostgreSQL và MySQL cho biết kế hoạch thực thi (execution plan) mà query planner chọn: có dùng chỉ mục không, quét bao nhiêu dòng, tốn bao nhiêu thời gian ở từng bước.

Dùng các công cụ này thường xuyên, ngay từ lúc viết truy vấn mới, giúp bạn bắt được vấn đề trước khi nó thành sự cố production, tiết kiệm cả thời gian debug lẫn trải nghiệm người dùng.

Cạm Bẫy Và Lưu Ý Thực Tế

Cẩn thận với: tối ưu hóa quá sớm khi chưa đo. Hầu hết kỹ thuật trong bài, từ chỉ mục tới phi chuẩn hóa hay sharding, đều có chi phí đánh đổi. Luôn đo bằng EXPLAIN, slow query log, hoặc công cụ phân tích trước khi quyết định áp dụng, thay vì tối ưu theo cảm tính.
  • Chỉ mục không miễn phí: tăng tốc đọc nhưng làm chậm ghi và tốn thêm dung lượng lưu trữ. Đừng đánh chỉ mục tràn lan.
  • Phi chuẩn hóa đánh đổi bằng tính nhất quán: dữ liệu trùng lặp ở nhiều nơi nghĩa là nhiều chỗ phải cập nhật đồng bộ khi dữ liệu gốc đổi.
  • Replica lag: đọc từ bản sao có thể thấy dữ liệu cũ hơn vài trăm mili giây tới vài giây, tùy độ trễ mạng và tải hệ thống.
  • Sharding tăng độ phức tạp truy vấn: mọi truy vấn cần dữ liệu từ nhiều mảnh đều phải tự gộp kết quả ở tầng ứng dụng.

Tóm Tắt

  • Tái sử dụng kết nối bằng connection pooling, và cấu hình đúng pool size/timeout thay vì để mặc định.
  • Đánh chỉ mục cho cột hay xuất hiện trong WHERE/JOIN/ORDER BY, nhưng đừng đánh chỉ mục tràn lan.
  • Theo dõi và tinh chỉnh truy vấn ORM, đặc biệt tránh N+1 query bằng eager loading.
  • Chọn đúng chiến lược tải dữ liệu: lazy loading, eager loading, hoặc xử lý theo lô, tùy tình huống.
  • Dùng keyset pagination thay vì OFFSET khi dữ liệu lớn và người dùng cuộn sâu.
  • Chỉ SELECT đúng cột cần dùng, tránh SELECT *.
  • Cân nhắc phi chuẩn hóa cho hệ thống đọc nhiều, nhưng chấp nhận đánh đổi về tính nhất quán.
  • Tối ưu JOIN bằng chỉ mục đúng cột và loại JOIN phù hợp, tránh JOIN thừa.
  • Bảo trì định kỳ (VACUUM, dọn dữ liệu cũ) và ghi log truy vấn chậm để phát hiện sớm vấn đề.
  • Replication tăng dự phòng và khả năng đọc, sharding tăng khả năng chứa và xử lý; cả hai đều đánh đổi bằng độ phức tạp vận hành.

Nguồn Tham Khảo

- Kai

Thấy bài viết hữu ích?

0 lượt thích

Đăng nhận xét