Chương 27: DDD - vetautet.com | Flash Sale: Anh ơi, làm sao chặn được bọn BOT? 3 Kịch bản tùy chọn đặt hàng và khấu trừ hàng tồn kho của level lập trình viên

Nội dung bài viết

Video học lập trình mỗi ngày

"Anh ơi, nếu 100.000 người cùng bấm mua 1.000 vé tàu trong 1 giây thì sao?"

Bài viết này được đưa vào Series: Hành trình của INTERN đánh bại SENIOR thông qua dự án DDD - vetautet.com có lượng đồng thời CAO.

Vì cốt truyện có tính logic nên nếu như bạn là một bạn mới ghé thăm vào Blog - Con đường của một backend thì trước tiên để hiểu nội dung thì mỗi bài viết tương ứng ở đây là một kỹ thuật được áp dùng trong dự án thì bạn hãy cố gắng xem những bài viết trước về dự án có lượng đồng thời CAO để có thêm thông tin về hành trình này.

Bạn có thể bỏ qua section Lộ trình Series: Bán vé tàu Tết - Từ Zero đến Production nếu như bạn không quan tâm đến hành trình này. Và nhảy đến phần Chương 27: DDD - vetautet.com | Flash Sale - Khi 100.000 người cùng muốn mua 1.000 vé tàu để xem bài viết này.


📚 Lộ trình Series: Bán vé tàu Tết - Từ Zero đến Production

"Tôi không dạy bạn lý thuyết suông. Tôi chia sẻ những gì chúng tôi đã làm, đã sai, và đã sửa. Tù 1000 QPS -> ~40.000 QPS với chi phí một instance vps"

Nếu bạn muốn theo dõi từ đầu, đây là lộ trình mà tôi khuyến khích. Mỗi bài là một mảnh ghép, ghép đúng thứ tự thì bức tranh mới hoàn chỉnh.

🏗️ Phần 1: Nền móng - Setup & Kiến trúc (Bài 01-03)

#Tiêu đềBạn sẽ học được gì?
01Khởi đầu: Xây dựng dự án DDD bán vé tàu với kiến trúc đồng thời caoTư duy DDD, tại sao chọn kiến trúc này
02Source Code: Video 0-4 - Cách chạy projectHướng dẫn setup môi trường
03Hoàn thành SETUP dự án theo kiến trúc MicroserviceCấu trúc project, các service cần có

🛡️ Phần 2: Tuyến phòng thủ đầu tiên - Rate Limiting & Circuit Breaker (Bài 04-06)

#Tiêu đềBạn sẽ học được gì?
04Circuit Breaker vs RateLimiter - Tuyến phòng thủ đầu tiênResilience4j, chặn request quá tải
05Distributed Cache - Tuyến phòng thủ thứ hai (LUA vs Redis)Redis Cluster, Lua Script
06Tâm sự DEV: Vì sao tôi không dùng LUA Redis trong kịch bản DistributedTrade-off thực tế, kinh nghiệm xương máu

📊 Phần 3: Giám sát hệ thống - Monitoring & Observability (Bài 07-10)

#Tiêu đềBạn sẽ học được gì?
07Setup hệ thống giám sát API với Prometheus - Giúp DEV ngủ ngonPrometheus metrics, alerting
08Đại ca Grafana - Giám sát hệ thống giúp DEV ngủ ngonDashboard visualization
09Giám sát MySQL ONLINE qua hệ thống APIMySQL monitoring, slow query detection
10Hệ thống giám sát Connects vs Performance Distributed RedisRedis monitoring, connection pooling

🚀 Phần 4: Tối ưu hiệu năng - Từ 5.000 đến 25.000 req/s (Bài 11-13)

#Tiêu đềBạn sẽ học được gì?
11Họp TEAM: Vũ khí cho việc tăng tốc 20.000 req/s - 5 tiêu chí phải đạtChecklist performance
12Không những 20.000 mà là 25.000 req/s - Chúng tôi nhận thưởngLocal cache (Guava), optimization
13Triển khai hệ thống phân tán LOGs ELK cho hệ thống LỚNElasticsearch, Logstash, Kibana

🔄 Phần 5: Data Consistency - Bài toán nhất quán dữ liệu (Bài 14-17)

#Tiêu đềBạn sẽ học được gì?
14Hãy dừng Code 5 phút - Tính nhất quán (Consistency) thực sự diễn ra thế nào?CAP theorem thực tế
15Proxy Nginx vs 2 server - Kịch bản mua vé đồng thời CAO StockAvailableLoad balancing, session handling
16DEV SA (Solution Architecture) - Dữ liệu phân tán giờ đã nhất quánDistributed data consistency
17Triển khai mức độ nhất quán tốt nhất phù hợp với nhu cầu ứng dụngEventual vs Strong consistency

📦 Phần 6: Tồn kho & Đặt hàng - Bài toán cốt lõi (Bài 18-23)

#Tiêu đềBạn sẽ học được gì?
18StockDeduction - Tôi đã từng đổ máu, nhưng bạn tuyệt đối không nênPitfalls của stock deduction
193 CÁCH TRỪ HÀNG TỒN KHO trong MySQL - Optimistic lock, Pessimistic lock, Redis
20Kiểm soát đồng thời cao và khấu trừ hàng tồn kho của SeniorHigh concurrency stock management
21Sau OpenTicket, API nhất quán dữ liệu về Khấu trừ tồn kho PlaceOrderOrder flow, inventory sync
22Luận bàn về 100 trung tâm bán 5000 ticket/ngày - Chia dữ liệu OrderDatabase sharding, partitioning
23Cách Senior lưu trữ dữ liệu cho vetautet hiệu quả cao, hiệu suất tốtData modeling, indexing strategy

💳 Phần 7: Transaction & Payment - Distributed Transactions (Bài 24-25)

#Tiêu đềBạn sẽ học được gì?
24PATTERN SAGA - Distributed Transactions cho Senior BESaga pattern, compensating transactions
25PATTERN SAGA - Order, Payment is DONE ✅✅✅Complete payment flow

📈 Phần 8: Scaling & Production - Triển khai thực tế (Bài 26-27)

#Tiêu đềBạn sẽ học được gì?
26Triển khai TINH TẾ một TABLE có dữ liệu tăng CHỤC TRIỆU order/thángTable partitioning, archiving
27JAVA DDD Source Code: Bán Vé Từ Video Section 17 - 26 How to run()Source Code

💡 Lời khuyên của tôi: Đừng nhảy cóc. Series này được thiết kế theo lộ trình từ dễ đến khó, từ nền tảng đến nâng cao. Nếu bạn nhảy thẳng vào bài 24 mà chưa hiểu bài 04-06, bạn sẽ thấy khó nuốt.


Trước khi bắt đầu: "Anh ơi, làm sao chặn được bọn BOT?"

Một anh chàng hỏi tôi qua Discord: "Anh ơi, hệ thống Flash Sale của em bị BOT quét sạch hàng trong 0.3 giây. User thật không mua được gì cả. Làm sao hạn chế rủi ro từ những lệnh giả mạo vậy anh?"

Fraud Ecosystem - Hệ sinh thái gian lận. Nghe thì có vẻ xa lạ, nhưng thực tế nó đang diễn ra hàng ngày trong các hệ thống e-commerce của chúng ta.

Kẻ thù không phải script kiddie, mà là một TỔ CHỨC có hệ thống

Đừng nghĩ rằng đối thủ của bạn là mấy anh chàng rảnh rỗi viết script để "nghịch". Không. Đây là một hệ sinh thái hoàn chỉnh với 3 tầng:

┌─────────────────────────────────────────────────────────────────┐
│                    HỆ SINH THÁI ĐEN-XÁM                         │
├─────────────────────────────────────────────────────────────────┤
│  UPSTREAM (Cung cấp tài nguyên)                                 │
│  ├── SIM rác (hàng triệu số điện thoại thật)                    │
│  ├── CMND/CCCD thật (mua từ chợ đen)                            │
│  ├── Proxy xoay IP (hàng nghìn IP khác nhau)                    │
│  └── Device fingerprint giả mạo                                 │
├─────────────────────────────────────────────────────────────────┤
│  MIDSTREAM (Phát triển công cụ)                                 │
│  ├── Auto-register bot (đăng ký hàng loạt account)              │
│  ├── Flash sale sniper (bắn request trong milliseconds)         │
│  ├── Captcha bypass (AI + nhân công giải captcha)               │
│  └── Behavior simulation (giả lập hành vi người dùng)           │
├─────────────────────────────────────────────────────────────────┤
│  DOWNSTREAM (Biến thành tiền)                                   │
│  ├── Bán lại vé/hàng với giá cao                                │
│  ├── Thu thập coupon rồi bán                                    │
│  └── Rửa điểm thưởng, cashback                                  │
└─────────────────────────────────────────────────────────────────┘

Bạn thấy chưa? Đây không phải là cuộc chiến với một cá nhân, mà là với một chuỗi cung ứng hoàn chỉnh. Họ có SIM thật, CCCD thật, vượt qua mọi xác thực OTP. Họ có AI giải captcha trong 2-3 giây, nhanh hơn cả người thật. Họ có hàng nghìn IP khác nhau, rate limiting theo IP trở nên vô nghĩa.

Họ không phá hệ thống, họ "CHUI VÀO" hệ thống để kiếm lợi

Điều đáng sợ nhất là: BOT của họ không tấn công theo kiểu DDoS hay SQL Injection. Chúng giả làm user thật và thực hiện giao dịch hoàn toàn hợp lệ - chỉ là nhanh hơn, nhiều hơn, và có tổ chức hơn.

Khi Flash Sale bắt đầu, trong khi user thật còn đang load trang, thì BOT đã:

  • Gửi 1000 request trong 100ms đầu tiên
  • Từ 500 IP khác nhau
  • Với 500 account khác nhau (đều có số điện thoại thật)
  • Mỗi account mua đúng 2 vé (không vi phạm rule "tối đa 2 vé/account")

Kết quả? 1000 vé hết sạch trong 0.3 giây. User thật chưa kịp nhấn nút "Mua ngay".

Vậy chúng ta đánh lại như thế nào?

Câu trả lời ngắn gọn: Không có biện pháp nào là tối ưu hoàn toàn. Đây là cuộc chiến dài hơi, và chúng ta cần nhiều lớp phòng thủ:

Lớp phòng thủKỹ thuậtHiệu quả
Tầng 1: NetworkWAF, DDoS Protection, IP ReputationChặn được 60-70% BOT cơ bản
Tầng 2: ApplicationRate Limiting, Device FingerprintChặn thêm 15-20%
Tầng 3: BusinessHành vi bất thường, Cùng địa chỉ giao hàng, Cùng thanh toánChặn thêm 5-10%
Tầng 4: Post-purchaseReview đơn hàng bất thường, Cancel đơn của BOTXử lý phần còn lại

Trong bài viết hôm nay, chúng ta sẽ tập trung vào Tầng 2 & 3 - những gì backend developer có thể làm được. Và tin tôi đi, nếu làm tốt 2 tầng này, bạn đã chặn được 80% kẻ xấu rồi.

Được rồi, quay lại câu chuyện chính...


Một buổi chiều thứ 6 định mệnh

Chiều thứ 6, khi mọi người đang chuẩn bị tinh thần cho weekend thì email từ Product Owner bay vào inbox của team:

"Team ơi, tuần sau mình có Flash Sale vé tàu Tết. Dự kiến 100.000 người truy cập đồng thời để tranh nhau 1.000 vé. Mọi người chuẩn bị tinh thần nhé!"

Cả phòng im lặng. David buông cốc cà phê xuống bàn. Tôi nhìn sang INTERN, cậu ta đang... cười?

"Em có ý tưởng gì à?" - Tôi hỏi.

"Dạ anh, em nghĩ có 3 cách để xử lý bài toán này. Nhưng mà... để em vẽ lên bảng cho anh em xem đã."

INTERN đứng dậy, cầm bút whiteboard và bắt đầu...


Cách 1: Đồng bộ MySQL + Redis - "Cách của người mới"

"Đầu tiên, cách đơn giản nhất mà ai cũng nghĩ đến..." - INTERN nói.

User Request → Check Redis (còn vé?) → Trừ Redis → Ghi MySQL → Response

INTERN viết code lên bảng:

@Service
@RequiredArgsConstructor
public class FlashSaleServiceV1 {

    private final RedisTemplate<String, Integer> redisTemplate;
    private final OrderRepository orderRepository;

    /**
     * Cách 1: Đồng bộ - Đơn giản nhưng chậm
     * Vấn đề: Blocking tại MySQL, throughput thấp
     */
    public OrderResult purchaseTicket(Long userId, Long ticketId) {
        String stockKey = "flash:stock:" + ticketId;

        // Bước 1: Check tồn kho trong Redis
        Integer stock = redisTemplate.opsForValue().get(stockKey);
        if (stock == null || stock <= 0) {
            return OrderResult.fail("Hết vé rồi bạn ơi!");
        }

        // Bước 2: Trừ tồn kho Redis
        Long newStock = redisTemplate.opsForValue().decrement(stockKey);
        if (newStock < 0) {
            // Đã bị người khác mua mất, hoàn lại
            redisTemplate.opsForValue().increment(stockKey);
            return OrderResult.fail("Có người nhanh tay hơn bạn!");
        }

        // Bước 3: Ghi đơn hàng vào MySQL (BLOCKING!)
        try {
            Order order = Order.builder()
                .userId(userId)
                .ticketId(ticketId)
                .status(OrderStatus.PENDING)
                .createdAt(LocalDateTime.now())
                .build();
            orderRepository.save(order);

            return OrderResult.success(order.getId());
        } catch (Exception e) {
            // Rollback Redis nếu MySQL fail
            redisTemplate.opsForValue().increment(stockKey);
            return OrderResult.fail("Hệ thống bận, thử lại sau!");
        }
    }
}

David nhíu mày: "Cách này có vấn đề gì?"

INTERN gật đầu: "Dạ, vấn đề là ở bước 3 - Ghi MySQL. Khi 100.000 request cùng đến, tất cả đều phải chờ MySQL ghi xong mới trả về. MySQL sẽ nghẽn, response time tăng vọt, user thấy loading mãi... rồi timeout."

"Throughput?" - Tôi hỏi.

"Dạ, khoảng 2.000-3.000 req/s là max rồi anh. Không đủ cho 100.000 người."


Cách 2: Bất đồng bộ với Message Queue - "Cách của người biết nghĩ"

INTERN xoá bảng và vẽ tiếp:

User Request → Check Redis → Trừ Redis → Đẩy vào Kafka → Response "Đang xử lý"
                                              ↓
                              Consumer ← Kafka → Ghi MySQL (từ từ)

"Ý tưởng là: Nhận đơn thật nhanh, xử lý sau. Giống như lúc đặt bàn nhà hàng, nhân viên ghi tên bạn vào sổ rồi bảo 'Chờ chút nhé', chứ không bắt bạn đứng đợi họ dọn bàn."

@Service
@RequiredArgsConstructor
public class FlashSaleServiceV2 {

    private final RedisTemplate<String, Integer> redisTemplate;
    private final KafkaTemplate<String, OrderMessage> kafkaTemplate;

    private static final String FLASH_SALE_TOPIC = "flash-sale-orders";

    /**
     * Cách 2: Async với Kafka
     * Ưu điểm: Throughput cao, không blocking
     * Nhược điểm: Eventually consistent, user không biết ngay kết quả cuối
     */
    public OrderResult purchaseTicketAsync(Long userId, Long ticketId) {
        String stockKey = "flash:stock:" + ticketId;

        // Bước 1: Check và trừ tồn kho Redis (vẫn giữ nguyên)
        Integer stock = redisTemplate.opsForValue().get(stockKey);
        if (stock == null || stock <= 0) {
            return OrderResult.fail("Hết vé rồi bạn ơi!");
        }

        Long newStock = redisTemplate.opsForValue().decrement(stockKey);
        if (newStock < 0) {
            redisTemplate.opsForValue().increment(stockKey);
            return OrderResult.fail("Có người nhanh tay hơn bạn!");
        }

        // Bước 2: Tạo orderId trước, đẩy message vào Kafka
        String orderId = UUID.randomUUID().toString();
        OrderMessage message = OrderMessage.builder()
            .orderId(orderId)
            .userId(userId)
            .ticketId(ticketId)
            .timestamp(System.currentTimeMillis())
            .build();

        kafkaTemplate.send(FLASH_SALE_TOPIC, ticketId.toString(), message);

        // Bước 3: Trả về NGAY LẬP TỨC - không chờ MySQL
        return OrderResult.pending(orderId, "Đơn hàng đang được xử lý!");
    }
}

Và Consumer xử lý phía sau:

@Component
@RequiredArgsConstructor
@Slf4j
public class FlashSaleOrderConsumer {

    private final OrderRepository orderRepository;
    private final RedisTemplate<String, Integer> redisTemplate;
    private final NotificationService notificationService;

    @KafkaListener(topics = "flash-sale-orders", groupId = "flash-sale-consumer")
    public void processOrder(OrderMessage message) {
        try {
            // Ghi vào MySQL - từ từ, không vội
            Order order = Order.builder()
                .id(message.getOrderId())
                .userId(message.getUserId())
                .ticketId(message.getTicketId())
                .status(OrderStatus.CONFIRMED)
                .createdAt(Instant.ofEpochMilli(message.getTimestamp())
                    .atZone(ZoneId.systemDefault()).toLocalDateTime())
                .build();

            orderRepository.save(order);

            // Thông báo cho user qua WebSocket/Push Notification
            notificationService.notifyUser(message.getUserId(), 
                "Chúc mừng! Đơn hàng " + message.getOrderId() + " đã được xác nhận!");

            log.info("Order {} processed successfully", message.getOrderId());

        } catch (Exception e) {
            log.error("Failed to process order {}", message.getOrderId(), e);
            // Hoàn lại stock nếu fail
            redisTemplate.opsForValue().increment("flash:stock:" + message.getTicketId());
            // Có thể đẩy vào Dead Letter Queue để retry sau
        }
    }
}

"Throughput?" - David hỏi.

"Dạ, 15.000-20.000 req/s không vấn đề anh. Vì mình chỉ check Redis rồi đẩy message, không chờ gì cả."

Tôi gật đầu: "Nhưng có vấn đề..."

INTERN hiểu ý: "Dạ, đúng rồi anh. Race Condition. Giữa lúc check stock > 0 và lúc decrement, có thể có 10 người khác cũng check thấy còn vé. Cuối cùng trừ xuống âm."


Cách 3: Lua Script - "Cách của người đi trước"

INTERN mỉm cười: "Và đây là cách mà em đã triển khai đêm qua..."

"Vấn đề của cách 1 và 2 là: Check và Trừ không phải atomic. 2 thao tác riêng biệt = có kẽ hở."

"Redis có một vũ khí bí mật: Lua Script. Toàn bộ logic được chạy trong Redis, single-threaded, atomic."

-- flash_sale.lua
-- KEYS[1] = stock key, ví dụ: "flash:stock:123"
-- ARGV[1] = user_id
-- ARGV[2] = order_id

local stock = tonumber(redis.call('GET', KEYS[1]))

-- Không còn vé
if stock == nil or stock <= 0 then
    return {0, "SOLD_OUT"}
end

-- Trừ vé - ATOMIC!
redis.call('DECR', KEYS[1])

-- Optional: Lưu thông tin đơn hàng tạm vào Redis
local orderKey = "flash:order:" .. ARGV[2]
redis.call('HSET', orderKey, 
    'userId', ARGV[1],
    'ticketId', KEYS[1],
    'status', 'PENDING',
    'createdAt', redis.call('TIME')[1]
)
redis.call('EXPIRE', orderKey, 3600) -- TTL 1 giờ

return {1, ARGV[2]}

Và code Java gọi Lua Script:

@Service
@RequiredArgsConstructor
@Slf4j
public class FlashSaleServiceV3 {

    private final StringRedisTemplate redisTemplate;
    private final KafkaTemplate<String, OrderMessage> kafkaTemplate;

    // Load Lua script một lần khi khởi động
    private final RedisScript<List> flashSaleScript;

    @PostConstruct
    public void init() {
        // Script được load từ file hoặc define inline
    }

    /**
     * Cách 3: Lua Script + Kafka
     * Ưu điểm: 
     *   - Atomic operation (no race condition)
     *   - Cực nhanh (Redis single-threaded)
     *   - Throughput cực cao
     * Đây là cách được dùng trong các hệ thống Flash Sale thực tế
     */
    public OrderResult purchaseTicketWithLua(Long userId, Long ticketId) {
        String stockKey = "flash:stock:" + ticketId;
        String orderId = UUID.randomUUID().toString();

        // Một lần gọi duy nhất - Check + Trừ + Lưu tạm = ATOMIC
        List<Object> result = redisTemplate.execute(
            flashSaleScript,
            Collections.singletonList(stockKey),
            userId.toString(),
            orderId
        );

        Integer status = (Integer) result.get(0);
        String message = (String) result.get(1);

        if (status == 0) {
            return OrderResult.fail(message.equals("SOLD_OUT") 
                ? "Hết vé rồi bạn ơi!" 
                : "Lỗi hệ thống!");
        }

        // Đẩy vào Kafka để ghi MySQL async
        OrderMessage orderMessage = OrderMessage.builder()
            .orderId(orderId)
            .userId(userId)
            .ticketId(ticketId)
            .timestamp(System.currentTimeMillis())
            .build();

        kafkaTemplate.send("flash-sale-orders", ticketId.toString(), orderMessage);

        return OrderResult.pending(orderId, "Đặt vé thành công! Đang xác nhận...");
    }
}

Cả phòng im lặng.

David lên tiếng: "Vậy throughput...?"

INTERN mở laptop, chạy JMeter test:

10.000 req/s ✓
25.000 req/s ✓
50.000 req/s ✓
75.000 req/s ✓
...
100.000 req/s ✓

"Không một vé nào bị oversell, không một request nào bị race condition."


Vậy cuối cùng, chúng tôi chọn cách nào?

Nếu bạn hỏi tôi: "Anh ơi, vậy em nên dùng cách nào?" - Tôi sẽ trả lời thế này:

"Không có viên đạn bạc. Mỗi cách có chỗ đứng riêng của nó."

Phương phápThroughputĐộ phức tạpKhi nào tôi sẽ dùng?
Cách 1: Sync MySQL+Redis~3.000 req/sĐơn giảnKhi bạn mới bắt đầu, traffic thấp, cần ship nhanh
Cách 2: Async với MQ~20.000 req/sTrung bìnhProduction thông thường, đủ cho 80% use case
Cách 3: Lua Script~100.000+ req/sCaoFlash Sale, khi "sống chết" phụ thuộc vào performance

Nhưng với vetautet.com - một hệ thống bán vé tàu Tết có lượng đồng thời cao - thì đáp án chỉ có một: Cách 3 + Kafka. Không có lựa chọn khác.


Kiến trúc tổng thể của vetautet.com Flash Sale

                                    ┌─────────────────┐
                                    │   Load Balancer │
                                    └────────┬────────┘
                                             │
                    ┌────────────────────────┼────────────────────────┐
                    │                        │                        │
              ┌─────▼─────┐           ┌─────▼─────┐           ┌─────▼─────┐
              │  API - 1  │           │  API - 2  │           │  API - 3  │
              └─────┬─────┘           └─────┬─────┘           └─────┬─────┘
                    │                        │                        │
                    └────────────────────────┼────────────────────────┘
                                             │
                              ┌──────────────▼──────────────┐
                              │     Guava Local Cache       │
                              │  (Từ chương trước - 25k/s)  │
                              └──────────────┬──────────────┘
                                             │ Cache Miss
                              ┌──────────────▼──────────────┐
                              │        Redis Cluster        │
                              │    (Lua Script - Atomic)    │
                              └──────────────┬──────────────┘
                                             │
                              ┌──────────────▼──────────────┐
                              │      Kafka / RabbitMQ       │
                              │    (Async Processing)       │
                              └──────────────┬──────────────┘
                                             │
                              ┌──────────────▼──────────────┐
                              │     Consumer Workers        │
                              │   (Ghi MySQL từ từ...)      │
                              └──────────────┬──────────────┘
                                             │
                              ┌──────────────▼──────────────┐
                              │      MySQL (Master)         │
                              │   (Source of Truth)         │
                              └─────────────────────────────┘

Bonus: Những "chiêu" mà Senior không nói cho bạn

Nếu bạn đọc đến đây, tôi sẽ thưởng cho bạn vài thủ thuật mà chúng tôi đã học được sau nhiều đêm mất ngủ. Đây không phải lý thuyết đâu, đây là xương máu.

Chiêu 1: Pre-warm Redis - "Hâm nóng" trước khi Flash Sale bắt đầu

Đừng để Flash Sale bắt đầu mà Redis còn lạnh tanh. 5 phút trước giờ G, hãy load sẵn stock vào Redis:

@Scheduled(cron = "0 55 9 * * ?") // 9:55 sáng, trước Flash Sale 10:00
public void preWarmFlashSaleStock() {
    List<FlashSaleItem> items = flashSaleRepository.findTodayItems();

    items.forEach(item -> {
        String key = "flash:stock:" + item.getTicketId();
        redisTemplate.opsForValue().set(key, item.getStock());

        // Set TTL = thời gian Flash Sale + buffer
        redisTemplate.expire(key, Duration.ofHours(2));
    });

    log.info("Pre-warmed {} flash sale items", items.size());
}

Chiêu 2: Rate Limiting - "Gác cổng" chặn BOT

Như tôi đã nói ở đầu bài về Fraud Ecosystem - BOT sẽ spam hàng nghìn request trong vài giây. Đây là cách chúng tôi "gác cổng":

@Component
public class FlashSaleRateLimiter {

    private final RedisTemplate<String, String> redisTemplate;

    /**
     * Mỗi user chỉ được request 5 lần / 10 giây
     */
    public boolean isAllowed(Long userId) {
        String key = "rate:flash:" + userId;
        Long count = redisTemplate.opsForValue().increment(key);

        if (count == 1) {
            redisTemplate.expire(key, Duration.ofSeconds(10));
        }

        return count <= 5;
    }
}

Chiêu 3: Sentinel - Biết từ chối sớm còn hơn chết cùng

Khi traffic vượt quá khả năng chịu đựng, thà từ chối sớm còn hơn để cả hệ thống sập. Đây là bài học đau thương mà chúng tôi đã học được:

@Component
public class FlashSaleSentinel {

    private final AtomicInteger currentRequests = new AtomicInteger(0);
    private static final int MAX_CONCURRENT = 50000;

    public boolean tryAcquire() {
        int current = currentRequests.incrementAndGet();
        if (current > MAX_CONCURRENT) {
            currentRequests.decrementAndGet();
            return false; // Từ chối sớm, không cho vào
        }
        return true;
    }

    public void release() {
        currentRequests.decrementAndGet();
    }
}

Về đến nhà, tôi vẫn nghĩ...

Tối hôm đó khi về đến nhà, tôi ngồi lặng trước cửa sổ. Bóng đèn đường hắt vào căn phòng nhỏ.

Cậu INTERN đó... mới 3 tháng thôi. Vậy mà...

Có lẽ kiến thức không phân biệt tuổi tác hay số năm kinh nghiệm. Điều quan trọng là đam mêdám làm.

Cậu ta không ngủ được vì "kiến trúc nhảy múa trong đầu". Cậu ta triển khai ngay trong đêm. Cậu ta không sợ sai.

Có lẽ... đó mới chính là tinh thần của một engineer thực sự.


Chương sau: Khi 10.000 người cùng thanh toán trong 1 giây - Distributed Transaction đã cứu chúng tôi như thế nào?


Lời kết: Từ tôi đến bạn

Nếu bạn đọc đến đây, tôi muốn nói một điều:

"Không có phép màu nào trong engineering cả. Chỉ có sự hiểu biết, kinh nghiệm, và dám thử sai."

Cậu INTERN trong câu chuyện này không phải thiên tài. Cậu ấy chỉ là một người dám thức đêm để code, dám sai để học, và dám hỏi khi không biết.

Bạn cũng có thể làm được như vậy. Tin tôi đi.


Source Code

Toàn bộ code của series này đã được push lên tại: github.com/tips-anonystick/vetautet-ddd

Nếu bạn thấy bài viết hữu ích hãy làm một nhát share cho anh em cùng đánh quái và nâng cấp. Lụm vật phẩm kakaka

Còn nếu bạn có câu hỏi, cứ comment bên dưới hoặc tìm tôi trên Discord. Tôi sẽ cố gắng trả lời khi có thời gian.

Happy coding! 🚀

Có thể bạn đã bị missing