App server là phần dễ của chuyện scaling. Chúng không giữ state, nên khi traffic gấp đôi bạn nhân bản chúng sau một load balancer rồi đi chỗ khác. Database mới là phần khó, vì nó giữ state — đúng cái thứ bạn không thể nhân bản rồi quên đi. Đó là lý do, trong gần như mọi hệ thống đang lớn, database là thứ gục ngã đầu tiên.
Nó cũng là thứ kỹ sư over-engineer đầu tiên trong cơn hoảng. Bản năng khi bị tải nặng là với ngay tới chiêu kịch tính nhất trên menu — “shard thôi,” “qua NoSQL đi,” “tách microservices nào.” Gần như luôn sai thứ tự. Ở đây có một cái thang, với những bước rẻ nhất và ít xâm lấn nhất ở dưới cùng, và câu trả lời đúng là leo từng bậc một — chỉ bước lên khi bậc dưới thật sự hết tác dụng.
Bậc 0: Đo trước đã — thường chỉ là một query
Trước khi scale bất cứ thứ gì, hãy tìm ra vì sao nó chậm. Chín trên mười lần, ở giai đoạn đầu, câu trả lời không phải “database quá nhỏ.” Đó là một index thiếu, một vòng lặp N+1 bắn cả trăm query để render một trang, hoặc một query trông vô hại đang full table scan cả triệu dòng.
Một index là thứ có đòn bẩy cao nhất và rẻ nhất trong cả bài này. Cái index đúng có thể biến một query hai giây thành hai mili-giây mà không cần thêm một server nào — vì nó cho database nhảy thẳng tới đúng những dòng cần thay vì đọc cả bảng.
-- Hỏi database xem nó thật sự đang làm gì
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
-- "Seq Scan on orders ... rows=2,000,000" ← đọc sạch mọi thứ
-- Một dòng đổi tất cả
CREATE INDEX idx_orders_customer ON orders (customer_id);
-- giờ: "Index Scan ... rows=37" ← nhảy thẳng tới chỗ khớp
Ném phần cứng vào một index đang thiếu. Bạn sẽ trả hoá đơn server to hơn mỗi tháng, mãi mãi, chỉ để tránh viết một migration một dòng đúng một lần. Hãy luôn với tới EXPLAIN trước khi với tới ví tiền.
Bậc 1: Scale up trước khi scale out
Khi chính cái máy mới là giới hạn thật — CPU kịch trần, RAM cạn, disk I/O nghẽn — nước đi tiếp theo là nước nhàm chán: vertical scaling. Cho database một cái máy to hơn. Nhiều RAM hơn (để nhiều dữ liệu nằm trong bộ nhớ hơn), disk nhanh hơn, nhiều core hơn.
Nó không hào nhoáng, và đó chính xác là điểm hay. Không đổi code, không sinh failure mode mới, không khái niệm mới để cả team phải học. Nó mua cho bạn thời gian và khoảng trống trong khi bạn làm điều gì đó khôn hơn.
Nó cũng có ba giới hạn thành thật. Có một trần — cái instance to nhất tiền thuê được. Có một vách chi phí — giá tăng siêu tuyến tính rất gắt ở khúc cao, nên lần gấp đôi cuối đắt hơn lần đầu rất nhiều. Và nó vẫn là một single point of failure: một cái máy, và reboot nó để nâng cấp là downtime. Nhưng đừng để mấy điều đó doạ bạn rời bậc này quá sớm. Những instance managed lớn nhất hôm nay khổng lồ; một database tinh chỉnh tốt phục vụ nhiều traffic hơn tuyệt đại đa số công ty từng có. Đừng phân tán cho tới khi bạn thật sự đụng tường.
Bậc 2: Sự bất đối xứng đọc/ghi → read replica
Đây là cái insight mà hai bậc tiếp theo dựng lên trên: gần như mọi ứng dụng đọc nhiều hơn ghi rất, rất nhiều. Một feed mạng xã hội, một catalogue sản phẩm, một trang tin — chúng có thể phục vụ cả trăm hay nghìn lượt đọc cho mỗi lượt ghi. Tải ghi của bạn tí xíu; tải đọc mới là vòi rồng.
Vậy thì đừng bắt một máy gánh cả hai. Giữ một primary duy nhất nhận mọi lệnh ghi, và thêm read replica — những bản sao liên tục stream dữ liệu của primary và phục vụ query đọc. Giờ bạn có thể thêm replica để hút tải đọc gần như tuyến tính, và còn được tặng kèm một ứng viên failover nếu primary chết.
Cái bẫy có tên hẳn hoi: replication lag. Replication là bất đồng bộ, nên replica luôn chậm hơn một chút — thường là mili-giây, đôi khi vài giây khi nó bận. Đây là eventual consistency ghé thăm ngay trong database của bạn, và nó đẻ ra một con bug kinh điển: người dùng đăng một bình luận (ghi → primary), trang reload và đọc một replica chưa kịp bắt kịp, và bình luận của họ như bốc hơi. Họ đăng lại. Giờ có hai cái.
Định tuyến những lệnh đọc bắt buộc phải thấy lệnh ghi mới nhất quay về primary — “read-your-own-writes” cho vài luồng cần nó (người dùng nhìn đúng cái thứ họ vừa đổi). Còn lại — search, feed, dashboard, analytics — cứ để chúng đọc một replica hơi cũ một cách vui vẻ. Kỹ năng nằm ở chỗ quyết định, theo từng query, rằng một câu trả lời cũ một giây có ổn không. Thường là ổn.
Bậc 3: Đừng đụng tới database luôn → caching
Query nhanh nhất là query bạn không bao giờ chạy. Với dữ liệu được đọc thường xuyên, đắt để tính, và hiếm khi đổi, hãy đặt một cache (Redis, Memcached) phía trước database và trả lời thẳng từ bộ nhớ.
Mẫu thường ngày là cache-aside: nhìn vào cache trước; nếu trượt, đọc database, lưu kết quả lại, rồi trả về.
async function getProduct(id) {
const hit = await cache.get(`product:${id}`)
if (hit) return JSON.parse(hit) // đường nhanh: trả từ bộ nhớ
const row = await db.products.findById(id) // trượt: trả giá cho query đúng một lần
await cache.set(`product:${id}`, JSON.stringify(row), { ttl: 300 })
return row
}
Caching là nước đi có tỉ lệ lợi-ích-trên-công-sức cao nhất trên thang — và cũng là nước sắc cạnh nhất. Có một câu đùa cũ rằng hai bài toán khó nhất khoa học máy tính là cache invalidation và đặt tên cho biến. Câu đùa nói về cái đầu tiên. Khoảnh khắc dữ liệu gốc thay đổi, cache của bạn sai, và quyết định khi nào cùng cách nào làm mới nó (hết hạn theo giờ? xoá khi ghi? cả hai?) mới là việc thật. Cái bẫy thứ hai là cache stampede: một key nóng hết hạn, cả nghìn request cùng trượt một lúc, và tất cả cùng nện vào database — đúng cái đỉnh tải mà cache sinh ra để ngăn.
Database là nguồn ghi nhận; cache là một ý kiến nhanh và bỏ đi được về nó. Hãy thiết kế sao cho nếu cả cache bốc hơi ngay giây này, bạn chỉ phục vụ chậm hơn vài phút — chứ không mất dữ liệu hay phục vụ thứ hỏng. Nếu mất cache là mất dữ liệu, bạn đã xây một database mong manh, không phải một cache.
Bậc 4: Khi một máy không chứa nổi phần ghi → partitioning & sharding
Mọi thứ tới giờ scale phần đọc và giữ một primary duy nhất cho phần ghi. Cuối cùng — và với hầu hết công ty thì lúc này tới muộn hơn họ sợ rất nhiều — lượng ghi hoặc cỡ dữ liệu thuần lớn vượt một máy. Giờ bạn không còn lựa chọn nào ngoài tách chính dữ liệu ra. Có hai hình dạng.
Vertical partitioning tách theo cột hoặc bảng: dời một cột blob khổng lồ hiếm dùng, hoặc cả một bảng, sang database riêng của nó. Đẩy tới tận cùng logic của nó trên cả một doanh nghiệp, đây chính là database-per-service — mỗi service sở hữu lát của mình.
Horizontal partitioning, quen thuộc hơn với tên sharding, tách các dòng của một bảng lớn ra nhiều database theo một shard key — user_id theo hash, khách theo vùng, đơn theo khoảng ngày. Mỗi shard là một database bình thường giữ lát dòng của nó.
Shard key là quyết định quan trọng nhất và vĩnh viễn nhất trong cả bài này. Một key tốt làm hai việc: nó trải tải đều (để bạn không bị một “hot shard” cháy trong khi mấy cái kia rảnh rỗi), và nó khớp với query phổ biến nhất của bạn (để query đó rơi vào đúng một shard). Chọn sai là bạn sống với hậu quả nhiều năm.
Và hoá đơn rất đắt, đó chính xác là lý do đây là bậc trên cùng:
- Query xuyên shard đau đớn. “Top 10 đơn hàng trên toàn bộ khách” giờ nghĩa là hỏi từng shard rồi gộp kết quả — một cú scatter-gather vừa chậm vừa lằng nhằng.
- Transaction xuyên shard biến mất. Bạn quay lại với saga và eventual consistency — bài toán dữ liệu phân tán, giờ nằm ngay trong tầng lưu trữ của bạn.
- Resharding là một dự án thật sự. Tách một shard lớn quá nóng, hoặc đổi key, nghĩa là di trú dữ liệu sống mà không downtime. Các team lên kế hoạch cho việc này trước hàng tháng.
Một ghi chú về SQL với NoSQL: là chuyện access pattern, không phải “web scale”
Đâu đó trên đường đi sẽ có người bảo bạn cần “chuyển qua NoSQL để scale.” Thường thì đó là một huyền thoại. Một relational database, leo lên cái thang ở trên, scale tới một cỡ thật sự khổng lồ — những công ty bạn từng nghe tên chạy tải kinh người trên Postgres hay MySQL đã shard một cách nhàm chán.
Bạn với tới một store khác khi access pattern thật sự khác, không phải vì relational “không scale được.” Một key-value store cho throughput tra cứu đơn giản cực lớn; một document store cho schema linh hoạt, lồng nhau; một search engine (Elasticsearch) cho full-text; một time-series database cho metric; một wide-column store (Cassandra) cho workload nặng ghi với một hình dạng query đã biết. Mỗi cái là một công cụ chính xác cho một pattern mà relational SQL phục vụ vụng về.
Hãy ở lại với cái relational database nhàm chán của bạn lâu hơn nhiều so với những gì internet gợi ý. “Polyglot persistence” — đúng store cho đúng việc — là một ý tưởng thật và hay ở quy mô lớn, nhưng mỗi datastore mới là thêm một thứ phải vận hành, backup, giám sát, và suy luận về tính nhất quán xuyên qua. Hãy thêm database thứ hai khi một pattern cụ thể đòi hỏi, không phải vì cái tên nghe có vẻ cao cấp hơn.
Leo thang theo đúng thứ tự
| Triệu chứng bạn thật sự có | Cách sửa rẻ nhất mà hiệu quả |
|---|---|
| Một endpoint chậm | EXPLAIN nó — thêm index đang thiếu, diệt N+1 |
| Cả DB gần kịch CPU/RAM | Scale up lên instance to hơn trước đã |
| Đọc là nút thắt, ghi vẫn ổn | Read replica — và định tuyến lệnh đọc cần tươi về primary |
| Cùng những lệnh đọc đắt, lặp đi lặp lại | Một cache phía trước (cache-aside + TTL) |
| Lượng ghi hoặc cỡ dữ liệu vượt một máy | Partition, rồi shard theo một key chọn cẩn thận |
| Một pattern cụ thể chống lại mô hình relational | Thêm một store chuyên dụng — và chấp nhận vận hành nó |
Góc nhìn thành thật theo quy mô công ty
- Solo / startup giai đoạn đầu. Một database, index tử tế, backup đều đặn, thử restore một lần. Đó là toàn bộ chiến lược scaling của bạn, và nó đúng. Sai lầm lớn nhất ở giai đoạn này là shard cho một tải bạn không có — một Postgres managed duy nhất phục vụ nhiều traffic hơn 99% startup từng chạm tới.
- Scale-up đang lớn. Thêm một read replica khi đọc áp đảo (và để an tâm failover). Đặt cache lên vài đường nóng hiển nhiên. Scale primary lên khi buộc phải. Cưỡng lại sharding cho tới khi lượng ghi thật sự ép tay bạn — và khi nó tới, hãy dành thời gian thật để chọn shard key. Đo lường không ngừng; để dữ liệu, đừng để nỗi sợ, đẩy bạn lên một bậc.
- Doanh nghiệp lớn. Replica, các tầng cache, và sharding (hoặc một distributed-SQL database managed tự shard giùm) là đồ đạc bình thường. Đầu tư chuyển từ “bậc nào” sang kỷ luật vận hành: quản trị shard key, cache invalidation như một mối quan tâm của nền tảng, failover replica tự động, và lập kế hoạch dung lượng. Polyglot persistence ở đây là có chủ đích và có chủ sở hữu — không phải sự lan man tình cờ của cả tá team mỗi nhóm chọn một database ưa thích.
Những điều cốt lõi
- Database gục trước vì nó giữ state bạn không thể nhân bản đơn thuần. Hãy leo thang; đừng nhảy thẳng lên đỉnh.
- Đo trước khi scale. Index là cách sửa rẻ nhất, đòn bẩy cao nhất cả trang — với tới
EXPLAINtrước khi với tới phần cứng. - Scale up trước khi scale out. Một cái máy tinh chỉnh tốt đi xa hơn hầu hết team tưởng.
- Read replica khai thác sự bất đối xứng đọc/ghi — nhưng mang theo replication lag. Định tuyến lệnh đọc cần thấy ghi mới nhất về primary; còn lại cứ đọc hơi cũ.
- Caching biến query nhanh nhất thành query bạn không bao giờ chạy. Invalidation mới là phần khó, và cache không bao giờ là nguồn sự thật.
- Sharding là bậc trên cùng vì shard key khó quay đầu. Chỉ với tới nó khi ghi hoặc cỡ dữ liệu thật sự vượt một máy — và chọn key như thể nó là vĩnh viễn, vì nó đúng là vậy.
Scale tầng dữ liệu phần lớn là kỷ luật không làm cái việc hấp dẫn trước. Cùng với tách code thành service, quyết định ai sở hữu dữ liệu, và trả cái thuế resilience, nó là mảnh lặng lẽ cuối cùng để giữ một hệ thống đứng vững khi nó lớn lên — từng bước rẻ, quay đầu được, chừng nào bước đó còn hiệu quả.