Backpressure — Khi producer nhanh hơn consumer thì sao?

Phong Hy

Trong một hệ thống phân tán, chuyện tay trên (producer) đẻ dữ liệu nhanh hơn tay dưới (consumer) xử lý là chuyện gần như mặc định. Vấn đề không phải là "nếu" nó xảy ra, mà là "khi" nó xảy ra thì mình xử lý ra sao. Không kiểm soát, dữ liệu sẽ chất đống, memory tăng vọt, rồi cả service sập đồng loạt. Đây chính là lúc cần đến backpressure — kỹ thuật ngăn chặn xếp hàng không giới hạn bằng cách ép producer chậm lại khi consumer quá tải.

Backpressure là gì?

Backpressure (áp lực ngược) là cơ chế để hệ thống "kêu cứu" theo hướng ngược dòng dữ liệu: khi consumer không theo kịp, nó báo cho producer biết "khoan, chờ tao xíu" thay vì cứ nhận thêm vô.

Có 4 chiến lược chính, từ "quỵ queo" đến "mạnh mẽ":

  1. Drop — bỏ luôn dữ liệu dư khi hàng đợi đầy
  2. Block — ngưng producer đứng yên chờ consumer rảnh
  3. Back off — producer tạm dừng một khoảng rồi thử lại
  4. Adaptive — consumer liên tục đo mức tải và thông báo dung lượng còn lại cho producer

Hầu hết hệ thống thực tế kết hợp vài chiến lược tuỳ theo ưu tiên của nghiệp vụ.

Code ví dụ: buffer + drop trong Go

Trong Go, channel có buffer là hàng đợi cơ bản nhất. Khi buffer đầy, goroutine gửi sẽ block. Nhưng đôi khi mình không muốn block vô hạn vì sợ deadlock — mình muốn drop khi quá tải:

package main

import (
    "fmt"
    "time"
)

func main() {
    // Buffer 10 là "hàng đợi" giới hạn
    jobs := make(chan int, 10)

    consumer := func() {
        for job := range jobs {
            time.Sleep(100 * time.Millisecond) // chậm, cố ý
            fmt.Println("xử lý xong:", job)
        }
    }
    go consumer()

    // Producer cố gắng gửi tối đa 100 jobs
    for i := 0; i < 100; i++ {
        select {
        case jobs <- i:
            // gửi được, quá đẹp
        default:
            // Buffer đầy -> drop, đừng block
            fmt.Println("quá tải, drop job:", i)
        }
    }
    time.Sleep(2 * time.Second)
}

Với select + default, khi buffer đầy mình "drop" luôn thay vì block. Cách này chạy nhanh nhưng mất dữ liệu — hợp khi dữ liệu không quan trọng (metric, log, real-time feed).

Nếu dữ liệu quan trọng không thể mất, ta dùng block (bỏ default) hoặc dùng thư viện xử lý trì hoãn.

Backpressure trong hệ thống thật

Trên thực tế, backpressure điều khiển ở nhiều tầng:

  • Kafka: consumer báo offset đã xử lý; nếu lag quá cao, alert để scale thêm partition hoặc tăng consumer. Consumer group tự cân bằng số partition.
  • Redis Streams: consumer xác nhận (ack) từng message; server biết ai còn đang xử lý dở.
  • HTTP/2 & TCP: flow control của chính giao thức vận chuyển giúp thằng gửi không bao giờ tràn bộ đệm của thằng nhận.

Kinh nghiệm thực chiến của tui: khi công ty em triển khai một pipeline xử lý ảnh, ban đầu không có backpressure, hàng đợi Redis phình tới hàng trăm nghìn task, memory worker lên 90%. Chỉ cần thêm một check đơn giản: nếu channel buffer đạt 80%, trả HTTP 503 cho producer gọi tới (block kiểu "mềm"), là hệ thống ổn định ngay. Producer hiểu 503 là "bữa nay quá tải, mai gọi lại", tự retry với backoff.

Bài học rút ra

  • Đừng bao giờ dùng hàng đợi vô hạn để "dễ thở". Memory có hạn, trễ trễ rồi sập đồng loạt còn tệ hơn drop dữ liệu.
  • Chọn chiến lược theo tính chất dữ liệu: quan trọng thì block/backoff, không quan trọng thì drop.
  • Bắt đầu với buffer có giới hạn + đo lag. Sau đó mới nâng cấp lên adaptive khi đã hiểu profile tải của mình.
  • Alert khi lag vượt ngưỡng — đừng để tới lúc sập mới biết.

Backpressure nghe "kỹ thuật" nhưng bản chất là tôn trọng giới hạn của hệ thống. Hệ thống biết mình chịu được bao nhiêu và nói không với phần vượt quá sức, đó mới là hệ thống bền vững.