Scale app, scale DB: từ 1 server lên nhiều server

4 câu hỏi~35 phútTừ khóa: vertical/horizontal scaling, stateless, session Redis, JWT, load balancer, cache-aside, CDN, read replica, replication lag, sharding, shard key, scatter-gather

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:

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:

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ậtGiải quyếtCái giá
Vertical scalingThêm sức cho 1 máy, đơn giảnGiới hạn phần cứng, vẫn SPOF
Horizontal + LBScale app, chịu được instance chếtApp phải stateless
Session Redis / JWTInstance nào cũng phục vụ đượcRedis thành dependency; JWT khó thu hồi
Cache (Redis)Giảm read xuống DBStale data, invalidation, Redis chết → DB bị dội
CDNStatic gần user, giảm bandwidthPurge cache khi đổi file
Read replicaScale READReplication lag
ShardingScale WRITE + DATARouting, cross-shard query/JOIN, rebalance

7. Khi nào nên / không nên

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)

À 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_id thì 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}:

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


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.