Scale app, scale DB: từ 1 server lên nhiều server
Tiếp đi: scale app rồi scale database cho shop-app.
Bài trước ta ước lượng shop-app cần chịu khoảng 10,000 req/s đọc lúc peak. Bài này ráp Load Balancer, stateless, Redis, CDN và DB scaling thành một hệ thống, ở mức system design. Chi tiết kỹ thuật DB đã có ở Replication, partition, sharding, LB ở Load balancer & NAT.
1. Bài toán
Client
│
▼
┌─────────────┐
│ shop-app │ 1 instance, 200 worker threads
│ Spring Boot │
└──────┬──────┘
▼
PostgreSQL
100 req/s chạy ngon. Rồi traffic tăng 100 → 1,000 → 10,000 req/s. CPU 95%, thread pool 200/200 busy, request bắt đầu xếp hàng rồi timeout.
2. Vấn đề
Cách dễ nhất: Vertical Scaling (scale up). Nâng 4 CPU/8 GB lên 16 CPU/32 GB. Đơn giản, nhưng:
- Có giới hạn: 16 → 32 → 64 → 128 CPU → ??? Không nâng vô hạn, và càng to càng đắt.
- Vẫn một máy: server chết là cả shop-app chết (SPOF).
3. Giải pháp: Horizontal Scaling + các lớp đi kèm
Scale out: chạy nhiều instance. Câu hỏi mới: ai quyết định request vào instance nào? → Load Balancer (Round Robin, least connections...), kèm health check để không gửi request vào instance chết.
Clients
│
▼
┌─────────────┐
│Load Balancer│
└──────┬──────┘
┌──────────┼──────────┐
▼ ▼ ▼
shop-app shop-app shop-app
A1 A2 A3
Vấn đề kéo theo: session trong RAM. User login vào A1, session nằm trong RAM của A1. Request sau vào A2 → A2 không thấy session → user bị coi là chưa login.
Giải pháp: app stateless, state đưa ra ngoài:
- Session trong Redis: mọi instance đọc chung.
- Token (JWT): client gửi
Authorization: Bearer ..., instance nào cũng tự verify được. Xem JWT. - Sticky session cũng chạy được nhưng instance chết là mất session, và load lệch.
Cache: 100,000 user cùng xem iPhone → 100,000 query SELECT ... WHERE id = 123. Thêm Redis theo cache-aside: hit thì trả luôn, miss thì đọc DB rồi ghi cache. Hit rate 95% thì 10,000 req/s chỉ còn ~500 req/s xuống DB. Chiến lược cache chi tiết: Cache strategies.
CDN: ảnh sản phẩm, JS, CSS là static. Đặt ở CDN gần user: giảm latency, giảm bandwidth về origin (bài 15.2 tính ra ảnh là phần nặng nhất).
DB: khi cache vẫn không đủ, đi tiếp theo thứ tự:
Single DB
↓ index / query optimization
↓ cache
↓ read replica (scale READ)
↓ vertical scale primary
↓ partition table (dữ liệu lớn, vẫn 1 DB)
↓ sharding (scale WRITE + DATA, nhiều DB)
4. Code từng bước
Bước 1: Dependency để đưa session ra Redis và dùng cache.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
Bước 2: Config. Có spring-session-data-redis trên classpath là Spring Boot tự lưu HttpSession vào Redis.
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: 6379
session:
redis:
namespace: shop:session
cache:
type: redis
redis:
time-to-live: 5m # TTL: cache tự hết hạn, giới hạn stale
Bước 3: Code. Cache-aside và xóa cache khi update.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repo;
@Cacheable(cacheNames = "product", key = "#id") // HIT: không chạm DB
public ProductDto get(long id) {
return repo.findById(id).map(ProductDto::from)
.orElseThrow(() -> new ProductNotFound(id));
}
@Transactional
@CacheEvict(cacheNames = "product", key = "#id") // update DB rồi xóa cache
public void updatePrice(long id, BigDecimal price) {
repo.findById(id).orElseThrow(() -> new ProductNotFound(id)).setPrice(price);
}
}
Bước 4: Test bằng curl: login qua A1, gọi tiếp qua LB (có thể rơi vào A2) vẫn còn session.
curl -c cookie.txt -X POST http://lb.local/login -d 'username=demo&password=...'
curl -b cookie.txt http://lb.local/me # lặp vài lần, instance khác vẫn OK
redis-cli KEYS 'shop:session:*' # thấy session nằm trong Redis
5. Cách hoạt động: kiến trúc sau bài này
Clients
│
┌───────────┴───────────┐
▼ ▼
CDN Load Balancer
(ảnh, JS, CSS) │
┌────────────┼────────────┐
▼ ▼ ▼
App A1 App A2 App A3 (stateless)
└────────────┼────────────┘
│
Redis (cache + session)
│
DB Routing Layer
┌────────────┼────────────┐
▼ ▼ ▼
Shard 1 Shard 2 Shard 3
│ │ │
Replicas Replicas Replicas
Read Replica: write vào Primary, read chia ra Replica. Với 8,000 read + 2,000 write mỗi giây, sau khi có 3 replica: Primary còn 2,000 write + một ít read cần dữ liệu mới, mỗi replica ~2,600 read.
Replication lag: user đổi tên thành "Phong Nguyen", ghi vào Primary, trả 200 OK. Ngay sau đó GET rơi vào Replica chưa kịp sync → vẫn thấy "Phong". Refresh 1 giây sau lại đúng. Đây là vi phạm read-your-writes. Cách đơn giản: sau khi write, các read liên quan đọc Primary. Số like lệch vài trăm ms thì không sao, số dư ngân hàng thì có sao: phải hỏi "read này cần consistency mức nào?".
Sharding: 100 replica cũng không giảm write, vì mọi write vẫn vào 1 Primary. Sharding chia data sang nhiều DB độc lập:
shard = hash(user_id) % 3
Service
│
hash(user_id)
┌─────────┼─────────┐
▼ ▼ ▼
Shard 0 Shard 1 Shard 2
300,000 writes/s → mỗi shard ~100k
Cái giá: cross-shard query, JOIN khác shard, hot shard (shard theo country thì USA chiếm 80%). Shard key tốt phải chia đều cả data lẫn traffic.
6. So sánh
| Kỹ thuật | Giải quyết | Cái giá |
|---|---|---|
| Vertical scaling | Thêm sức cho 1 máy, đơn giản | Giới hạn phần cứng, vẫn SPOF |
| Horizontal + LB | Scale app, chịu được instance chết | App phải stateless |
| Session Redis / JWT | Instance nào cũng phục vụ được | Redis thành dependency; JWT khó thu hồi |
| Cache (Redis) | Giảm read xuống DB | Stale data, invalidation, Redis chết → DB bị dội |
| CDN | Static gần user, giảm bandwidth | Purge cache khi đổi file |
| Read replica | Scale READ | Replication lag |
| Sharding | Scale WRITE + DATA | Routing, cross-shard query/JOIN, rebalance |
7. Khi nào nên / không nên
- Chưa đo thấy bottleneck thì đừng shard; complexity tăng rất mạnh.
- Cache chỉ hợp với data đọc nhiều, chấp nhận stale ngắn. Đừng cache số dư tài khoản như cache trang sản phẩm.
- Cẩn thận Redis chết: 500 req/s xuống DB thành 10,000 req/s → DB chết → cả hệ thống chết (cascading failure). Cần Redis HA, timeout, circuit breaker, rate limit.
Trước khi qua bài mới, mình hỏi vụ sharding nè. Bạn bảo chọn shard key rất quan trọng, nhưng đâu thể cover toàn bộ query được:
- Query may mắn trúng key thì không sao.
- Còn query không trúng key thì sao?
Chính xác. Không có shard key nào tối ưu cho tất cả query. Đây là trade-off lớn nhất của sharding.
Shard theo user_id. Query có user_id:
SELECT * FROM orders WHERE user_id = 123;
hash(123) → Shard 2 → chỉ hỏi Shard 2 (single-shard query, nhanh)
Query không có user_id:
SELECT * FROM orders WHERE status = 'PENDING';
Query
│
┌───────┼───────┐
▼ ▼ ▼
Shard 1 Shard 2 Shard 3
└───────┼───────┘
▼
merge results (scatter-gather)
3 shard còn đỡ. 1,000 shard thì 1 request thành 1,000 query, rồi merge, sort. Rất đắt.
Vì vậy chọn shard key theo access pattern quan trọng nhất. shop-app có:
1. Get orders by user_id ← rất nhiều
2. Get order by order_id ← rất nhiều
3. Get pending orders ← background worker
4. Revenue by date ← reporting
5. Orders by product_id ← đôi khi
Use case chính là "đơn hàng của user X" → user_id hợp. Các query khác dùng giải pháp khác:
Orders DB (shard by user_id)
│ ORDER_CREATED
▼
Kafka
│
├──► Pending Order Processor (status = PENDING)
├──► Search Index (status, product, date...)
└──► Data warehouse (revenue report)
Tức là chấp nhận duplicate/denormalize data để phục vụ access pattern khác. Còn JOIN: đặt users và orders cùng shard theo user_id (co-location) thì JOIN trong 1 shard vẫn đẹp.
Có shard key → route đúng 1 shard → nhanh
Không có key → không biết data ở đâu → scatter-gather → đắt
→ thiết kế access pattern khác (index riêng, search, CQRS)
order_id có giúp tìm đúng shard không?À cho mình hỏi thêm xíu. Ví dụ shard theo user_id, còn order_id mình đánh index trong database:
- Query theo
order_idthì nó dùng index như thế nào? - Nó có query tất cả các shard không?
- Ý mình là các shard nằm riêng hoàn toàn rồi phải không?
Đúng, bạn đang chạm đúng sự khác nhau giữa sharding và index. Nếu shard nằm trên các DB/node riêng, index trong một shard chỉ biết dữ liệu của shard đó.
Router (shard_key = user_id)
/ | \
Shard 1 Shard 2 Shard 3
DB #1 DB #2 DB #3
idx_order_id idx_order_id idx_order_id
Có cả user_id: router tính hash(123) → Shard 2, rồi chỉ Shard 2 dùng idx_order_id. Chỉ có order_id: router không biết ORD-999 ở đâu, phải hỏi cả 3 shard, mỗi shard tra index của mình, 1 shard FOUND, 2 shard trả rỗng.
Shard key → tìm đúng DATABASE
Index → tìm đúng ROW nhanh bên trong database đó
Cách nhớ: shard là thư viện, index là mục lục trong thư viện. Mục lục tốt giúp tra nhanh, nhưng không biết sách ở thư viện nào thì vẫn phải đi từng thư viện.
Và đúng, mỗi shard thường là DB riêng hoàn toàn: table, index, connection, CPU/RAM/disk riêng. Tầng application/infra xây router để chúng trông như một dataset. Đây khác với partition trong cùng một DB, nơi DB engine tự route giúp bạn.
Cách tránh scatter-gather cho GET /orders/{orderId}:
- Nhúng shard vào ID:
ORD-S02-999→ biết ngay shard 2. - Global lookup: bảng
order_id → shard. Nhưng đây lại là một component có state phải scale và giữ HA. - Request mang đủ routing info:
/users/123/orders/ORD-999.
Phỏng vấn hỏi "scale hệ thống thế nào" và "shard key" thì trả lời sao?
Q1. How would you scale a web application?
🇻🇳 Tiếng Việt:
Em làm app stateless, đưa session ra Redis hoặc dùng JWT, rồi scale out nhiều instance sau load balancer có health check. Static content đưa lên CDN. Read nhiều thì thêm cache theo cache-aside với TTL và xóa cache khi update. DB đọc quá tải thì thêm read replica, chú ý replication lag với các read cần read-your-writes. Chỉ khi write hoặc data vượt một node mới tính đến sharding.
🇬🇧 English:
I make the application stateless by moving sessions to Redis or using JWTs, then scale out to multiple instances behind a load balancer with health checks. Static content goes to a CDN. For read-heavy traffic I add a cache-aside layer with TTLs and eviction on update. If database reads are still the bottleneck I add read replicas, keeping replication lag in mind for read-your-writes cases. Only when writes or data size outgrow a single node do I consider sharding.
Q2. What happens when a query doesn't contain the shard key?
🇻🇳 Tiếng Việt:
Router không biết data nằm ở shard nào nên phải scatter-gather qua nhiều shard rồi merge, rất đắt khi số shard lớn. Index trong mỗi shard chỉ giúp tìm nhanh trong shard đó, không giúp chọn shard. Vì vậy em chọn shard key theo access pattern quan trọng nhất, và phục vụ query khác bằng ID có chứa thông tin shard, bảng lookup, hoặc một read model riêng như search index được cập nhật qua event.
🇬🇧 English:
The router cannot tell which shard holds the data, so it has to scatter-gather across shards and merge the results, which is expensive with many shards. A per-shard index only speeds up the lookup inside that shard; it does not pick the shard. So I choose the shard key based on the most important access patterns and serve other queries with shard-aware IDs, a lookup table, or a separate read model such as a search index fed by events.
Q3. Read replica và sharding khác nhau thế nào?
🇻🇳 Tiếng Việt:
Read replica copy toàn bộ data, chủ yếu scale read; mọi write vẫn vào một primary và có replication lag. Sharding chia data sang nhiều DB độc lập, scale được cả write và dung lượng, nhưng đổi lại phải routing, cross-shard query khó và có thể có hot shard.
🇬🇧 English:
Read replicas copy all the data and mainly scale reads; all writes still go to one primary, and there is replication lag. Sharding splits the data across independent databases, so it scales writes and storage, but we pay with routing logic, hard cross-shard queries and possible hot shards.
Cách nhớ ngắn gọn
- Scale up có trần và vẫn SPOF; scale out cần stateless + LB.
- Cache giảm read, CDN cho static; Redis chết thì DB bị dội.
- Replica scale READ (cẩn thận lag); sharding scale WRITE + DATA.
- Shard key tìm đúng DB, index tìm đúng row; không có key → scatter-gather.
Tự kiểm tra
Vì sao session trong RAM làm horizontal scaling khó?
Request sau có thể vào instance khác không có session đó. Giải pháp: session ở Redis, JWT, hoặc (kém hơn) sticky session.
Cache hit rate 95%, 10,000 req/s đọc. DB chịu bao nhiêu? Nếu Redis chết thì sao?
Khoảng 500 req/s. Redis chết thì DB nhận cả 10,000 req/s, dễ chết theo (cascading failure).
Thêm 100 read replica có giải quyết write quá tải không?
Không. Mọi write vẫn vào 1 primary. Cần sharding để chia write.
Shard theo user_id, query WHERE order_id = ? chạy thế nào?
Router không biết shard, phải hỏi mọi shard (mỗi shard dùng index riêng) rồi merge. Tránh bằng ID chứa shard, bảng lookup, hoặc kèm user_id.
Hot shard là gì, ví dụ?
Một shard nhận phần lớn traffic do key phân bố lệch, ví dụ shard theo country mà USA chiếm 80% traffic.