REST, SOAP, gRPC, Webhook, WebSocket, WebRTC: Khi nào nên dùng loại nào?

REST, SOAP, gRPC, Webhook, WebSocket và WebRTC — cách các hệ thống thực sự nói chuyện với nhau

Con người có nhiều cách giao tiếp: gửi thư, gọi điện, nhắn tin, họp video. Mỗi cách phù hợp với một hoàn cảnh — không ai gọi video call chỉ để hỏi "mấy giờ rồi", và cũng không ai gửi thư tay để báo cháy nhà.

Hệ thống phần mềm cũng vậy. REST, SOAP, gRPC, Webhook, WebSocketWebRTC không phải sáu "đối thủ" cạnh tranh nhau, mà là sáu kiểu hội thoại khác nhau giữa các máy tính. Chọn sai kiểu hội thoại, hệ thống vẫn chạy — nhưng sẽ chạy như một người dùng thư tay để báo cháy.

Để mọi thứ cụ thể, xuyên suốt bài này chúng ta sẽ dùng một ví dụ duy nhất: một app đặt vé xem concert "Anh Trai Vượt Ngàn Chông Gai". Người dùng xem lịch các đêm diễn, canh giờ mở bán, chọn ghế, thanh toán, nhận thông báo realtime khi ghế mình nhắm vừa bị người khác giữ hay khi đợt vé bổ sung mở bán, và sau show còn tham gia buổi giao lưu video (fan meeting online). Bạn sẽ thấy: một hệ thống như vậy trong thực tế dùng cả sáu công nghệ, mỗi thứ ở một tầng khác nhau.

Với mỗi giao thức, ta sẽ đi theo cùng một khung:

Nó giải quyết vấn đề gì → bên dưới hoạt động thế nào → flow thực tế → điểm mạnh/yếu → failure case → security → khi nào dùng, khi nào không.


1. REST — không chỉ là "HTTP trả JSON"

■ Vấn đề nó giải quyết

Đầu những năm 2000, mỗi hệ thống web nói chuyện với nhau theo một kiểu riêng: endpoint đặt tên tùy hứng, mỗi API tự nghĩ ra cách báo lỗi, cách phân trang, cách cache. Muốn tích hợp hai hệ thống là phải đọc tài liệu từ đầu như học một ngôn ngữ mới.

REST — được Roy Fielding mô tả trong luận án tiến sĩ năm 2000 — không phải là một thư viện hay framework, mà là một architectural style: một tập các ràng buộc (constraints) mà nếu tuân theo, hệ thống sẽ tự nhiên có được những tính chất tốt của Web: dễ scale, dễ cache, dễ tiến hóa độc lập.

Điều thú vị là Fielding không "phát minh" ra REST rồi áp vào Web. Ông làm ngược lại: quan sát vì sao World Wide Web — hệ phân tán lớn nhất lịch sử — lại scale được, rồi đúc kết các nguyên tắc đó thành REST.

■ Các ràng buộc cốt lõi (và vì sao chúng tồn tại)

REST có 6 ràng buộc, nhưng 4 ràng buộc sau đáng nhớ nhất:

Client–Server: giao diện và dữ liệu tách nhau. Nghe hiển nhiên, nhưng chính nhờ nó mà cùng một API /bookings có thể phục vụ web, mobile app và cả đối tác bên thứ ba.

Stateless: mỗi request tự mang đủ thông tin để server hiểu, không phụ thuộc request trước đó. (Ta sẽ đào sâu bên dưới.)

Cacheable: response phải tự khai báo nó có cache được hay không. Đây là ràng buộc hay bị bỏ quên nhất — và cũng là nguồn tăng tốc miễn phí lớn nhất.

Uniform Interface: mọi resource được thao tác qua cùng một bộ động từ chuẩn. Đây là "linh hồn" của REST.

Uniform Interface trong thực tế

Thay vì mỗi hệ thống tự nghĩ ra động từ riêng theo kiểu RPC:

/createBooking
/cancelBooking
/getBookingDetail
/getBookingList

REST tổ chức hệ thống xoay quanh resource (danh từ), và để HTTP method (động từ chuẩn) nói lên hành động:

POST   /bookings          → tạo booking
GET    /bookings/123      → xem booking
PATCH  /bookings/123      → sửa một phần
DELETE /bookings/123      → hủy

Lợi ích không nằm ở "đẹp", mà nằm ở tính dự đoán được: một developer chưa từng đọc tài liệu API của bạn, chỉ cần thấy GET /concerts/45/seats là đoán được nó làm gì, có cache được không, retry có an toàn không. Toàn bộ hạ tầng trung gian của Internet — proxy, CDN, load balancer — cũng "hiểu" các động từ này và tối ưu theo.

Một lưu ý học thuật: REST "đầy đủ" theo Fielding còn có HATEOAS (response chứa link dẫn tới các hành động tiếp theo, như trang web chứa hyperlink). Trong thực tế, phần lớn API tự gọi là REST chỉ dừng ở mức resource + HTTP method — và điều đó thường là đủ tốt. Biết ranh giới này giúp bạn không sa vào các cuộc tranh cãi "API này có phải REST thật không" vốn ít giá trị thực tiễn.

■ Một request REST thực sự đi qua những đâu?

Khi app gọi:

GET /api/bookings/123
Authorization: Bearer eyJ...
Accept: application/json

thì bên dưới:

Mobile app / Browser
   │
   │ 1. DNS lookup: api.datve.vn → 203.0.113.10
   ↓
   │ 2. TCP handshake (SYN → SYN-ACK → ACK)
   │ 3. TLS handshake (trao đổi chứng chỉ, thỏa thuận khóa mã hóa)
   ↓
Load Balancer
   │
   │ 4. HTTP request được route tới một API server bất kỳ
   ↓
API Server
   ├─ Authentication  (bạn là ai?)
   ├─ Authorization   (bạn được làm gì?)
   ├─ Validation      (dữ liệu hợp lệ không?)
   ├─ Business logic
   └─ Database query
           ↓
        Database
   ↑
   │ 5. Response JSON đi ngược về

Chi tiết đáng chú ý: bước 2 và 3 (TCP + TLS handshake) tốn 1–2 round-trip trước khi byte dữ liệu đầu tiên được gửi. Nếu mỗi API call đều mở connection mới, độ trễ sẽ rất tệ. Vì vậy trong thực tế connection được tái sử dụng: HTTP/1.1 Keep-Alive giữ connection mở, HTTP/2 cho phép nhiều request chạy song song trên một connection, còn HTTP/3 (QUIC) thậm chí gộp handshake để giảm round-trip. REST "chậm hay nhanh" phụ thuộc rất nhiều vào tầng connection này chứ không chỉ vào JSON.

■ Stateless — vì sao nó là chìa khóa để scale

Nguyên tắc: server không dựa vào trạng thái của request trước để hiểu request hiện tại.

So sánh hai cách làm:

Cách stateful (kiểu cũ): đăng nhập xong, server lưu session vào RAM: "user 123 đang đăng nhập". Request sau chỉ gửi session ID, server tra RAM để biết bạn là ai. Vấn đề: request sau bắt buộc phải quay lại đúng server đang giữ session đó (sticky session), và nếu server đó chết, người dùng bị đăng xuất.

Cách stateless (REST): đăng nhập xong, server trả một JWT — một token tự chứa thông tin "tôi là user 123, hết hạn lúc X" kèm chữ ký. Request sau tự mang token:

GET /bookings
Authorization: Bearer eyJhbGciOi...

Bất kỳ server nào cũng verify được chữ ký và phục vụ request:

                        ┌─ API Server 1
Client → Load Balancer ─┼─ API Server 2   ← request nào vào server nào cũng được
                        └─ API Server 3

Muốn chịu tải gấp đôi? Thêm server. Một server chết? Các server còn lại gánh tiếp, người dùng không nhận ra. Đây chính là lý do các hệ thống web lớn có thể scale ngang gần như vô hạn ở tầng API — và là món quà trực tiếp của ràng buộc stateless. (Với app bán vé, đây không phải lý thuyết suông: đêm mở bán, lượng truy cập có thể gấp trăm lần ngày thường — khả năng "thêm server là chịu được tải" chính là thứ quyết định app sống hay sập.)

■ Idempotency — khái niệm cứu bạn khỏi charge tiền hai lần

Một operation là idempotent khi:

Gọi 1 lần hay N lần, trạng thái cuối cùng của hệ thống là như nhau.

PUT /users/123
{ "name": "Lan" }

Gửi 1 lần: name = Lan. Gửi 10 lần: vẫn name = Lan. Idempotent.

Theo chuẩn HTTP:

GET      idempotent (và safe — không đổi trạng thái)
PUT      idempotent
DELETE   idempotent về trạng thái cuối
         (lần 2 có thể trả 404, nhưng resource vẫn "đã bị xóa" — trạng thái không đổi)
POST     KHÔNG idempotent
PATCH    KHÔNG được đảm bảo idempotent
         (ví dụ PATCH kiểu "tăng số dư thêm 10" gọi 2 lần sẽ tăng 20)

Vì sao khái niệm này quan trọng đến vậy? Vì mạng không đáng tin. Xét tình huống kinh điển với payment:

Client ── POST /payments ──> Server
                                │
                          Payment thành công,
                          tiền đã trừ.
                                │
Client <───── ✕ ────────────────┘
         response bị mất do timeout

Client giờ đứng trước một câu hỏi không thể tự trả lời: thanh toán đã thành công hay chưa? Không retry thì có thể mất đơn. Retry thì có thể trừ tiền hai lần — vì POST không idempotent.

Giải pháp chuẩn công nghiệp (Stripe, PayPal, các cổng thanh toán lớn đều dùng): Idempotency-Key.

POST /payments
Idempotency-Key: booking-123-payment-1

Server lưu lại: booking-123-payment-1 → payment #9988. Nếu client retry với cùng key, server không tạo payment mới mà trả lại kết quả cũ. Về bản chất, ta đã "biến" POST thành idempotent bằng một lớp dedup phía server.

Đây là một trong những bài học quan trọng nhất khi thiết kế API: hãy giả định mọi request đều có thể được gửi lại, và thiết kế sao cho việc đó vô hại. (Đặc biệt trong đêm mở bán — mạng nghẽn, người dùng sốt ruột bấm lại liên tục: nếu không có lớp bảo vệ này, một fan cuồng bấm 5 lần có thể bị trừ tiền 5 lần cho cùng một chiếc vé.)

■ HTTP status code — ngôn ngữ chung của lỗi

API kém trả lỗi kiểu này với mọi tình huống:

HTTP 200 OK
{ "success": false, "message": "có lỗi xảy ra" }

Cách này phá vỡ mọi tooling: monitoring không đếm được error rate, client không phân biệt được "lỗi do tôi" hay "lỗi do server", proxy cache nhầm response lỗi.

API tốt tận dụng semantic có sẵn của HTTP:

2xx — thành công
  200 OK              lấy/sửa thành công
  201 Created         tạo mới thành công
  204 No Content      xóa thành công, không có gì để trả

4xx — lỗi phía client (client sửa request thì hết lỗi)
  400 Bad Request     dữ liệu sai định dạng
  401 Unauthorized    chưa đăng nhập
  403 Forbidden       đăng nhập rồi nhưng không có quyền
  404 Not Found       resource không tồn tại
  409 Conflict        xung đột trạng thái
  422 Unprocessable   dữ liệu đúng định dạng nhưng sai nghiệp vụ
  429 Too Many Requests   bị rate limit

5xx — lỗi phía server (client retry sau có thể hết lỗi)
  500 Internal Server Error
  503 Service Unavailable

Ví dụ trong app đặt vé: khu VIP đêm diễn 20/9 chỉ còn đúng một ghế A15, và hai fan cùng bấm đặt trong tích tắc. Người A nhanh tay hơn, thành công. Người B chậm hơn một nhịp — API nên trả:

409 Conflict
{ "error": "seat_already_taken", "message": "Ghế này vừa có người đặt" }

thay vì 500. Sự khác biệt không nhỏ: client thấy 409 biết rằng retry cũng vô ích, hãy chọn ghế khác ngay kẻo hết; thấy 500 sẽ tưởng server lỗi và... retry, tạo thêm tải vô ích đúng lúc hệ thống đang gồng mình trong giờ mở bán.

■ Caching — sức mạnh bị lãng quên của REST

Vì GET là safe và cacheable, REST thừa hưởng toàn bộ hạ tầng cache của Web. Hai cơ chế đáng biết:

Cache-Control: max-age=60

→ "response này dùng lại được trong 60 giây, đừng gọi tôi nữa". Lịch các đêm diễn, sơ đồ khu ghế, bảng giá vé — những dữ liệu ít đổi — có thể được CDN phục vụ mà request không bao giờ chạm tới server của bạn. Trong đêm mở bán, đây là khác biệt sống còn: hàng trăm nghìn fan F5 trang lịch diễn, nhưng server chỉ thấy một phần rất nhỏ trong số đó. (Ngược lại, trạng thái ghế trống lúc mở bán thì tuyệt đối không được cache — hiểu rõ cái gì cache được, cái gì không, chính là một kỹ năng thiết kế.)

ETag: "abc123"

→ client lần sau gửi If-None-Match: "abc123"; nếu dữ liệu chưa đổi, server chỉ trả 304 Not Modified rỗng thay vì cả payload. Tiết kiệm băng thông đáng kể với mobile.

Đây là thứ mà gRPC hay WebSocket không có sẵn — và là lý do REST vẫn là lựa chọn mặc định cho public API đọc nhiều.

■ Điểm yếu thật sự của REST

Over-fetching

Màn hình chỉ cần hiển thị tên người đặt vé, nhưng API trả cả cụm:

{
  "id": 1,
  "name": "Lan",
  "email": "...",
  "phone": "...",
  "address": "...",
  "created_at": "...",
  "updated_at": "..."
}

Với mobile network, những byte thừa này cộng dồn thành độ trễ thật.

Under-fetching

Để render màn hình profile, frontend phải gọi lần lượt:

GET /users/123
GET /users/123/bookings
GET /users/123/reviews

Ba round-trip nối tiếp nhau — trên 4G, mỗi round-trip có thể mất 100–300ms.

Hai vấn đề này chính là động lực khiến GraphQL ra đời (client tự khai báo cần đúng field nào), và khiến pattern BFF (Backend-for-Frontend — một API riêng gộp dữ liệu cho từng loại client) trở nên phổ biến. Không có nghĩa REST "thua" — mà nghĩa là REST tối ưu cho tính đơn giản và cache, không tối ưu cho việc cắt may dữ liệu theo từng màn hình.

■ Khi nào dùng REST

Nên dùng khi: public API, CRUD, hệ thống cần cache tốt, cần dễ debug (curl là đủ), cần hệ sinh thái tooling rộng nhất.

Cân nhắc thứ khác khi: giao tiếp nội bộ giữa microservices tần suất cao (→ gRPC), server cần chủ động đẩy dữ liệu (→ WebSocket/Webhook), dữ liệu cần cắt may theo từng màn hình (→ GraphQL/BFF).


2. SOAP — vì sao ngân hàng vẫn dùng thứ "cổ" này?

■ Vấn đề nó giải quyết

SOAP thường bị tóm tắt sai thành "REST nhưng dùng XML". Thực ra SOAP ra đời trước khi REST phổ biến, và giải một bài toán khác hẳn: làm sao để hai hệ thống enterprise của hai tổ chức khác nhau tích hợp với nhau một cách chặt chẽ, có contract pháp lý-kỹ thuật rõ ràng, có bảo mật và transaction ở mức message?

REST là một style — lỏng, linh hoạt, dựa trên quy ước. SOAP là một protocol — có specification chính thức của W3C, mọi thứ được định nghĩa tường minh: cấu trúc message, cách báo lỗi, cách mã hóa, cách đảm bảo delivery.

■ Cấu trúc một SOAP message

<soap:Envelope>
    <soap:Header>
        <!-- metadata: security token, transaction ID, routing... -->
    </soap:Header>
    <soap:Body>
        <!-- dữ liệu nghiệp vụ -->
    </soap:Body>
</soap:Envelope>

Hãy hình dung nó như một bưu phẩm chuẩn hóa: Envelope là hộp, Header là các tem và giấy tờ dán ngoài hộp (ai gửi, bảo hiểm gì, cần ký nhận không), Body là món hàng bên trong. Bất kỳ "bưu cục" trung gian nào cũng đọc được tem mà không cần mở hộp.

Một điểm học thuật đáng biết: SOAP không gắn chặt với HTTP. Message SOAP có thể chạy trên HTTP, nhưng cũng chạy được trên message queue (JMS), thậm chí SMTP. HTTP với SOAP chỉ là "xe tải chở hàng" — trong khi với REST, HTTP là một phần của chính kiến trúc.

■ WSDL — contract mà máy đọc được

Sức mạnh lớn nhất của SOAP là contract-first. File WSDL định nghĩa chính xác từng operation:

Operation: GetCustomer
  Input:   customerId (string, bắt buộc)
  Output:  Customer
            ├─ id      (string)
            ├─ name    (string)
            ├─ address (Address — một type phức được định nghĩa bằng XSD)
            └─ status  (enum: ACTIVE | FROZEN | CLOSED)

Từ WSDL, tool sinh code tự động cho client:

Customer customer = bankingClient.getCustomer("123");

Developer không đụng đến XML, không thể gửi sai kiểu dữ liệu — compiler chặn từ lúc build. Với tích hợp liên ngân hàng, nơi một field sai kiểu có thể là sự cố tài chính, mức độ "cứng" này là tính năng chứ không phải phiền phức. (Bạn có thể thấy tinh thần này sống lại trong gRPC với Protobuf, và trong OpenAPI của thế giới REST — ý tưởng contract-first chưa bao giờ chết, chỉ đổi hình hài.)

■ Hệ sinh thái WS-* — thứ REST không có

SOAP đi kèm một họ chuẩn mở rộng:

WS-Security           ký + mã hóa từng message
WS-ReliableMessaging  đảm bảo message đến nơi, đúng thứ tự, không trùng
WS-AtomicTransaction  transaction phân tán (two-phase commit) giữa nhiều hệ thống
WS-Addressing         định tuyến message qua nhiều trạm trung gian
Message-level security vs transport-level security

Đây là điểm tinh tế nhất, đáng dừng lại một chút.

HTTPS (TLS) bảo vệ đường truyền:

A ═══ encrypted tunnel ═══ B

Nhưng khi message tới B, TLS kết thúc — message nằm ở dạng plaintext trong bộ nhớ của B. Nếu kiến trúc là chuỗi nhiều trạm:

Ngân hàng A → API Gateway → Middleware của đối tác → Core Banking B

thì với TLS thuần, mỗi trạm trung gian đều đọc và có thể sửa được message.

WS-Security cho phép ký và mã hóa bản thân message bằng XML Signature / XML Encryption. Message đi qua bao nhiêu trạm cũng vậy — chỉ đích cuối giải mã được, và bất kỳ ai sửa một byte thì chữ ký sẽ vỡ, kèm timestamp chống replay. Với chuỗi middleware nhiều tầng của ngân hàng và chính phủ, đây từng là (và ở nhiều nơi vẫn là) yêu cầu bắt buộc.

■ SOAP Fault — lỗi cũng được chuẩn hóa

<soap:Fault>
    <faultcode>Client.InvalidAccount</faultcode>
    <faultstring>Account does not exist</faultstring>
</soap:Fault>

Mọi SOAP service báo lỗi cùng một format — client viết một lần code xử lý lỗi, dùng cho mọi service. (So với thế giới REST, nơi phải đến chuẩn RFC 7807 "Problem Details" người ta mới bắt đầu thống nhất format lỗi — mà đến giờ vẫn chưa phổ cập.)

■ Vì sao SOAP thoái trào — và vì sao nó chưa chết

Hãy so cùng một payload:

REST:
{ "id": 1, "amount": 500000 }
SOAP:
<soap:Envelope xmlns:soap="...">
    <soap:Header>...</soap:Header>
    <soap:Body>
        <PaymentRequest>
            <Id>1</Id>
            <Amount>500000</Amount>
        </PaymentRequest>
    </soap:Body>
</soap:Envelope>

Verbose hơn, parse chậm hơn, tooling nặng nề hơn, và gần như không dùng được trực tiếp từ browser/mobile. Với web và mobile API — nơi tốc độ phát triển quan trọng hơn contract cứng — REST thắng tuyệt đối.

Nhưng SOAP không biến mất, vì một lý do rất "kỹ sư": hệ thống core banking, bảo hiểm, chính phủ đã chạy ổn định 15–20 năm trên SOAP. Rewrite một hệ thống như vậy tốn hàng năm trời, rủi ro cực lớn, mà lợi ích thu về chủ yếu là... "code trông hiện đại hơn". Bài toán chi phí/lợi ích không đứng về phía rewrite. Nếu một ngày bạn tích hợp cổng thanh toán ngân hàng nội địa hay hệ thống hải quan, đừng ngạc nhiên khi được đưa cho một file WSDL.

■ Khi nào dùng SOAP

Thực tế bạn sẽ không chọn SOAP cho hệ thống mới — bạn gặp SOAP khi tích hợp với ngân hàng, bảo hiểm, hệ thống chính phủ, hoặc enterprise middleware đời cũ. Khi đó, điều đáng làm là hiểu WSDL, dùng tool sinh code thay vì parse XML tay, và bọc SOAP service đó sau một adapter để phần còn lại của hệ thống không phải biết đến nó.


3. gRPC — vì sao nhanh hơn REST

■ Vấn đề nó giải quyết

Khi hệ thống tách thành microservices, một request của người dùng có thể kéo theo hàng chục lần gọi nội bộ giữa các service:

1 request đặt vé
   → Booking Service gọi Seat Service (kiểm tra ghế còn trống)
   → gọi Pricing Service (tính giá vé + phí)
   → gọi Payment Service (thanh toán)
   → gọi Notification Service (gửi vé điện tử)

Nếu mỗi cú gọi nội bộ tốn thêm vài chục ms cho việc serialize JSON, parse JSON, và HTTP/1.1 overhead — thì nhân với hàng chục cú gọi, hàng triệu request trong một đêm mở bán, chi phí trở nên khổng lồ. gRPC (Google, 2015, kế thừa hệ thống nội bộ Stubby) sinh ra cho đúng bài toán này: giao tiếp service-to-service tần suất cao, độ trễ thấp, có contract chặt.

Về mặt học thuật, gRPC thuộc dòng RPC (Remote Procedure Call) — ý tưởng có từ thập niên 1980: làm cho việc gọi hàm ở máy khác trông giống hệt gọi hàm local. CORBA, Java RMI, Apache Thrift đều là tổ tiên cùng dòng. gRPC là phiên bản hiện đại của ý tưởng đó, đặt trên ba trụ cột:

HTTP/2  +  Protocol Buffers  +  Strongly-typed contract

Câu "gRPC nhanh vì dùng Protobuf" chỉ đúng một phần ba. Hãy đi qua từng trụ.

■ Trụ 1: Protocol Buffers — vì sao payload nhỏ hơn

JSON là format tự mô tả: mỗi message phải chở theo cả tên field:

{ "user_id": 123, "name": "Lan" }

Chuỗi "user_id" (7 byte + dấu nháy) được gửi đi trong mọi message, hàng triệu lần mỗi ngày, dù nó không bao giờ thay đổi.

Protobuf làm khác: schema được thống nhất trước, ở cả hai đầu:

message User {
    int64  user_id = 1;
    string name    = 2;
}

Trên network, không cần gửi tên field — chỉ cần gửi số thứ tự field:

field 1 → 123
field 2 → "Lan"

và encode nhị phân. Đi sâu một chút: mỗi field được mã hóa thành cặp (tag, value), trong đó tag gộp field number và wire type vào vài bit; số nguyên dùng varint — số nhỏ chiếm ít byte (123 chỉ tốn đúng 1 byte, thay vì 3 ký tự ASCII trong JSON, chưa kể tên field). Kết quả: payload thường nhỏ hơn JSON 3–10 lần, và parse nhanh hơn nhiều vì máy đọc binary trực tiếp thay vì phân tích văn bản.

Cái giá phải trả: message trên đường truyền không tự đọc được. Nhìn vào bytes, bạn chỉ thấy 08 7B 12 03 4C 61 6E — muốn hiểu phải có file .proto. Debug bằng curl là bất khả thi; đây là đánh đổi có chủ đích giữa hiệu năng và tính minh bạch.

Một chi tiết quan trọng cho người vận hành lâu dài: schema evolution. Vì mọi thứ dựa vào field number, quy tắc sống còn là không bao giờ tái sử dụng field number cũ cho field mới. Thêm field mới với số mới thì client cũ vẫn đọc được message mới (bỏ qua field lạ) — đây là cách các hệ thống lớn deploy service mới mà không cần deploy đồng loạt mọi client.

■ Trụ 2: HTTP/2 — vì sao connection hiệu quả hơn

HTTP/1.1 có vấn đề kinh điển: trên một connection, request phải xếp hàng lần lượt — request sau phải chờ response của request trước (head-of-line blocking ở tầng HTTP). Giải pháp thời đó là mở 6–8 connection song song, tốn kém và vẫn giới hạn.

HTTP/2 giải quyết bằng multiplexing: một connection TCP được chia thành nhiều stream logic độc lập:

              1 TCP Connection
                     │
        ┌────────────┼────────────┐
        │            │            │
    Stream 1     Stream 3     Stream 5
   (GetUser)  (CreateBooking) (GetPrice)

Ba RPC chạy song song, response về theo thứ tự nào cũng được, không ai chặn ai. Kèm theo đó là binary framing (dữ liệu chia thành frame nhị phân thay vì văn bản) và HPACK (nén header — các header lặp lại như authorization chỉ gửi một lần rồi tham chiếu).

Ghi chú cho người muốn đào sâu: HTTP/2 xóa head-of-line blocking ở tầng HTTP, nhưng vẫn còn ở tầng TCP — một packet TCP bị mất sẽ chặn mọi stream phía sau nó cho tới khi được truyền lại. Đây là lý do HTTP/3 chuyển sang QUIC (chạy trên UDP) để từng stream mất packet độc lập với nhau. Biết điều này giúp bạn hiểu vì sao "chồng giao thức" cứ tiến hóa mãi: mỗi tầng giải một loại nghẽn.

■ Trụ 3: Contract — .proto là nguồn chân lý duy nhất

Giống tinh thần WSDL của SOAP nhưng nhẹ hơn nhiều: từ một file .proto, tool sinh code client + server cho hàng chục ngôn ngữ. Team viết Go, team viết Java, team viết Python — tất cả nói chuyện qua cùng một contract, sai kiểu là lỗi compile chứ không phải lỗi runtime lúc 2 giờ sáng.

■ Bốn kiểu RPC — sức mạnh thật sự nằm ở streaming

Unary — giống REST
Client ── request ──> Server
Client <── response ── Server

Dùng cho: GetUser(), CreateBooking() — chiếm phần lớn RPC trong thực tế.

Server streaming — một hỏi, nhiều trả
Client ── request ────────> Server
Client <── data 1 ────────── Server
Client <── data 2 ────────── Server
Client <── data 3 ────────── Server

Dùng cho: stream log, theo dõi trạng thái đơn hàng, tải danh sách lớn theo từng phần thay vì chờ gom đủ.

Client streaming — nhiều gửi, một chốt
Client ── data 1 ──>
Client ── data 2 ──>  Server
Client ── data 3 ──>
Client <── result ──  Server

Dùng cho: upload telemetry, đẩy metrics theo lô, upload file theo chunk.

Bidirectional streaming — hội thoại hai chiều
Client ═══════════════ Server
   (hai bên gửi bất kỳ lúc nào, độc lập nhau)

Dùng cho: chat, realtime game backend, đồng bộ trạng thái liên tục. Về khả năng, nó tương đương WebSocket — nhưng dành cho service-to-service thay vì browser-to-server.

■ Deadline & Cancellation — tính năng cứu cả hệ thống microservice

Đây là phần ít được nhắc nhưng có giá trị vận hành lớn nhất của gRPC.

Mỗi RPC có thể gắn deadline:

Booking Service ── gọi Payment Service, deadline = 500ms ──>

Nếu Payment Service xử lý mất 2 giây:

Booking Service
    │
    ├── chờ 500ms
    │
    ✕ hủy — VÀ tín hiệu cancel lan xuống downstream

Điểm hay là deadline tự động lan truyền (propagate) qua cả chuỗi gọi: nếu Payment Service đang gọi tiếp Bank Gateway, cú gọi đó cũng bị hủy theo. Không có cơ chế này, hệ thống microservice dễ rơi vào thảm họa dây chuyền:

Fan đã tắt app từ lâu, nhưng:
Service A vẫn chờ B
    B vẫn chờ C
        C vẫn chờ D
→ thread/connection bị giam giữ khắp nơi
→ request mới không còn tài nguyên để xử lý
→ toàn hệ thống nghẽn (cascading failure)

Deadline biến "sự kiên nhẫn vô hạn" — vốn là default nguy hiểm — thành lựa chọn có chủ đích. Đêm mở bán vé chính là lúc cơ chế này chứng minh giá trị: thà trả lời "hết thời gian, thử lại" trong 500ms còn hơn để hàng vạn request treo lơ lửng kéo sập cả hệ thống.

■ Failure case & những cái bẫy của gRPC

Load balancing khó hơn REST. REST: mỗi request là một đơn vị độc lập, LB chia đều dễ dàng. gRPC: connection sống lâu và chở nhiều request, nên LB tầng 4 (chia theo connection) sẽ dồn tải lệch — cần LB tầng 7 hiểu HTTP/2 (Envoy, hoặc client-side load balancing). Đây là lý do gRPC và service mesh (Istio/Linkerd) thường đi cùng nhau.

Browser không gọi gRPC trực tiếp được — browser không cho JavaScript kiểm soát frame HTTP/2 ở mức gRPC cần. Phải qua lớp chuyển đổi grpc-web hoặc một gateway dịch REST↔gRPC. Vì vậy kiến trúc phổ biến là: REST cho rìa (client-facing), gRPC cho lõi (service-to-service).

■ Security

Chuẩn mực trong nội bộ là mTLS (mutual TLS): không chỉ client xác thực server, mà server cũng xác thực client bằng chứng chỉ — mỗi service có "chứng minh thư" riêng. Trong service mesh, việc cấp phát và xoay vòng chứng chỉ được tự động hóa. Kết hợp thêm authorization theo danh tính service: "chỉ Booking Service được gọi Payment Service".

■ Khi nào dùng gRPC

Nên dùng khi: giao tiếp microservice nội bộ tần suất cao; cần streaming; hệ đa ngôn ngữ cần contract chặt; latency là tiền (ad-tech, trading, realtime).

Không nên khi: public API cho bên thứ ba (REST dễ tiếp cận hơn nhiều), API cho browser, team nhỏ với monolith (độ phức tạp vận hành không đáng), hoặc khi khả năng debug-bằng-mắt-thường quan trọng hơn vài ms hiệu năng.


4. Webhook — khi server chủ động gõ cửa bạn

■ Vấn đề nó giải quyết

Mọi thứ ở trên đều theo mô hình: client hỏi, server trả lời. Nhưng nhiều sự kiện xảy ra ở phía server mà client không biết khi nào: thanh toán thành công, đơn hàng đổi trạng thái, có người mention bạn.

Cách ngây thơ là polling — hỏi liên tục:

Client: "Thanh toán xong chưa?" → Chưa.
(3 giây sau) "Xong chưa?" → Chưa.
(3 giây sau) "Xong chưa?" → Chưa.
... × 10.000 client × 24/7

Lãng phí ở cả hai đầu: hàng triệu request trả về "chưa có gì mới", trong khi độ trễ nhận tin vẫn tệ (trung bình bằng nửa chu kỳ poll).

Webhook đảo ngược vai trò — một dạng reverse API call:

Bình thường:  Client → Server
Webhook:      Server → Server của bạn (khi và chỉ khi có chuyện xảy ra)

Bạn đăng ký trước một URL: "khi có sự kiện X, hãy POST vào đây". Nguyên tắc này trong giới lập trình gọi vui là Hollywood Principle: "Don't call us, we'll call you."

■ Lifecycle thực tế — ví dụ thanh toán vé concert

Fan bấm "Thanh toán" trên app đặt vé
 │
 ↓
Cổng thanh toán xử lý, trừ tiền
 │
 │ sinh event: payment.success
 ↓
Webhook Queue (phía cổng thanh toán)
 │
 │ POST https://api.datve.vn/webhooks/payment
 ↓
Server của bạn

Payload:

{
  "id": "evt_789",
  "type": "payment.success",
  "created_at": "2026-08-08T10:15:00Z",
  "data": { "payment_id": "pay_456", "booking_id": "bk_123" }
}

Bạn trả 200 OK → provider đánh dấu delivered. Chi tiết chuyên nghiệp: trả 200 ngay, xử lý sau. Nếu handler của bạn xử lý nghiệp vụ nặng (ghi DB, phát hành vé điện tử, gửi mail) rồi mới trả 200, và provider timeout ở giây thứ 5 — họ sẽ tưởng delivery thất bại và gửi lại, dù bạn đã xử lý xong. Pattern chuẩn: nhận → verify chữ ký → đẩy vào queue nội bộ → trả 200 → worker xử lý async.

■ Retry — và một định lý bất khả thi

Server của bạn có thể down đúng lúc webhook tới:

Payment Provider ── POST ──> Server của bạn
                              ✕ (đang deploy / mất mạng / crash)

Provider tử tế sẽ retry theo exponential backoff (giãn dần: 1s, 2s, 4s... rồi 1 phút, 5 phút, 30 phút, 1 giờ), thường kèm jitter — cộng thêm độ trễ ngẫu nhiên nhỏ để tránh hàng nghìn webhook cùng retry đúng một thời điểm và tự tạo ra một đợt "tấn công" vào server vừa hồi sinh (thundering herd).

Giờ đến phần học thuật thú vị nhất. Hệ thống phân phối message chỉ có thể chọn một trong hai:

At-most-once   gửi tối đa 1 lần  → có thể MẤT event
At-least-once  gửi ít nhất 1 lần → có thể TRÙNG event

Còn "exactly-once — đúng một lần, không mất không trùng" trên mạng không đáng tin là bất khả thi về mặt lý thuyết (họ hàng của bài toán Two Generals: không bên nào có thể chắc chắn bên kia đã nhận, vì chính lời xác nhận cũng có thể thất lạc). Với payment, mất event là thảm họa — fan đã trả tiền mà không nhận được vé, nên mọi provider nghiêm túc chọn at-least-once — nghĩa là trùng event là chuyện chắc chắn sẽ xảy ra, không phải "nếu".

Vậy consumer phải idempotent — và bạn sẽ thấy ý tưởng Idempotency-Key của phần REST quay lại, lần này ở vai người nhận:

Bảng webhook_events:
  event_id     processed
  evt_789      true

Handler:
  if event_id đã tồn tại → trả 200, không làm gì thêm
  else → xử lý, lưu event_id, trả 200

Chú ý chi tiết: event trùng vẫn phải trả 200, không phải lỗi — vì với provider, delivery đã thành công; trả lỗi chỉ khiến họ... retry tiếp. (Nếu quên lớp dedup này, một event payment.success bị gửi lại có thể khiến hệ thống phát hành hai vé điện tử cho cùng một lần thanh toán.)

■ Ordering — thứ tự cũng không được hứa hẹn

Event sinh ra theo thứ tự:

payment.created → payment.processing → payment.success

nhưng do retry và routing, bạn có thể nhận:

payment.created → payment.success → payment.processing   (!)

Nếu handler ngây thơ "nhận event nào set trạng thái đó", booking sẽ nhảy từ đã thanh toán lùi về đang xử lý — và fan cầm vé hợp lệ bỗng thấy app báo "đang chờ thanh toán". Cách xử lý đúng là mô hình hóa trạng thái như một state machine chỉ tiến, không lùi:

if trạng_thái_mới đứng SAU trạng_thái_hiện_tại trong máy trạng thái:
    update
else:
    bỏ qua (event đến muộn)

Tổng quát hơn: đừng tin thứ tự đến của event; hãy tin vào trạng thái. Khi nghi ngờ, gọi ngược API của provider để hỏi trạng thái hiện tại — webhook là tín hiệu "có gì đó thay đổi, hãy kiểm tra", không phải nguồn chân lý duy nhất.

■ Security — cánh cửa mở ra Internet

Webhook endpoint bắt buộc nằm public:

https://api.datve.vn/webhooks/payment

nghĩa là bất kỳ ai trên Internet cũng POST được vào đó — kể cả kẻ giả mạo gửi payment.success cho một booking chưa hề thanh toán để lấy vé miễn phí. Phòng thủ chuẩn là chữ ký HMAC:

Provider và bạn chia sẻ trước một secret.
Mỗi webhook, provider gửi kèm:
   X-Signature: HMAC-SHA256(secret, request_body)

Server của bạn tự tính lại HMAC từ body nhận được:
   khớp   → đúng là provider gửi, body không bị sửa
   lệch   → 401, bỏ qua

Hai lớp gia cố thêm: ký kèm timestamp và từ chối message quá cũ (chặn replay attack — kẻ xấu bắt được một webhook hợp lệ rồi gửi lại y nguyên); và so sánh chữ ký bằng hàm constant-time để tránh timing attack. IP whitelist chỉ nên là lớp phụ — IP của provider đổi thường xuyên, và tự nó không chứng minh được nội dung message toàn vẹn.

■ Khi nào dùng Webhook

Nên dùng khi: tích hợp giữa các hệ thống của các bên khác nhau, sự kiện thưa và không cần độ trễ mili-giây: thanh toán, CI/CD, đơn vận chuyển đổi trạng thái.

Không nên khi: cần realtime liên tục tần suất cao tới end-user (→ WebSocket), hoặc hai service nội bộ cùng một hệ thống (→ message queue như Kafka/RabbitMQ sẽ đáng tin và giàu tính năng hơn nhiều so với HTTP POST qua Internet).


5. WebSocket — điều gì thực sự xảy ra khi "connection được mở"?

■ Vấn đề nó giải quyết

HTTP có một bất đối xứng bẩm sinh: chỉ client được mở lời. Server dù có tin mới đến đâu cũng phải ngồi im chờ được hỏi. Trước 2011, giới web đã "hack" quanh giới hạn này bằng đủ kỹ thuật (gọi chung là Comet): short polling (hỏi liên tục), long polling (hỏi một lần, server giữ request đến khi có tin mới trả lời) — đều là mô phỏng push trên nền một giao thức không sinh ra để push, tốn kém và mong manh.

WebSocket (chuẩn hóa trong RFC 6455, 2011) giải quyết tận gốc: một kênh song công (full-duplex) thật sự giữa browser và server, trên một TCP connection duy nhất, sống lâu.

■ Cú bắt tay: mượn đường HTTP rồi "biến hình"

WebSocket khởi đầu khôn ngoan: nó giả dạng một HTTP request để đi lọt qua mọi hạ tầng sẵn có (port 80/443, proxy, firewall):

GET /realtime HTTP/1.1
Host: api.datve.vn
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

Server đồng ý:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Chi tiết học thuật thú vị: Sec-WebSocket-Accept được tính bằng cách nối Sec-WebSocket-Key với một GUID cố định ghi trong RFC rồi băm SHA-1. Mục đích không phải bảo mật, mà là chứng minh server thật sự hiểu WebSocket — chặn trường hợp một HTTP server/proxy "ngơ ngác" nào đó vô tình trả 101 mà không hề biết mình vừa hứa gì.

Sau mã 101, cuộc "biến hình" hoàn tất:

HTTP (request/response)
 │
 │ Upgrade
 ↓
WebSocket (hai chiều, message-based)
 — vẫn trên đúng TCP connection đó, giữ mở —

Từ đây, dữ liệu chảy dưới dạng các frame nhị phân nhỏ gọn (header chỉ 2–14 byte, so với hàng trăm byte header của mỗi HTTP request), và quan trọng nhất — cả hai phía đều gửi được bất cứ lúc nào:

Client ── "giữ ghế A15 khu VIP" ──────────────> Server
Client <── "ghế A15 vừa có người khác giữ" ──── Server   ← server chủ động!
Client <── "đợt vé bổ sung khu B vừa mở bán" ── Server

Đây chính là thứ làm nên trải nghiệm sơ đồ ghế "sống" trong đêm mở bán: ghế chuyển xám ngay khoảnh khắc có người khác giữ, không cần fan bấm F5.

(Một chi tiết bên lề cho người tò mò: frame từ client→server bắt buộc bị XOR với một mask ngẫu nhiên — không phải để mã hóa, mà để dữ liệu do attacker điều khiển không thể tạo thành chuỗi byte trông-giống-HTTP nhằm đầu độc các proxy cache cũ trên đường đi.)

■ Heartbeat — vì "im lặng" không có nghĩa là "vẫn sống"

Điểm yếu của mọi connection sống lâu: nó có thể chết mà không ai báo. WiFi rớt, router reset, điện thoại chuyển 4G→WiFi, NAT trung gian âm thầm xóa mapping của connection nhàn rỗi — TCP không có cơ chế tự phát hiện nhanh những cái chết kiểu này. Cả hai đầu cứ tưởng bên kia còn đó.

Giải pháp: ping/pong định kỳ (WebSocket có sẵn frame PING/PONG trong giao thức):

Server ── PING ──> Client
Server <── PONG ── Client        → còn sống

Server ── PING ──> Client
        ... 30s không có PONG ...  → coi như chết, dọn connection
                                     Client tự reconnect (kèm backoff + jitter,
                                     đúng bài học đã gặp ở phần Webhook)

Heartbeat còn một tác dụng phụ hữu ích: gói tin định kỳ giữ cho NAT/firewall không xóa mapping vì "connection nhàn rỗi".

■ Bài toán khó thật sự: scale một hệ thống có trạng thái

Đây là khác biệt kiến trúc sâu nhất giữa WebSocket và REST — và đáng để dừng lại lâu nhất.

REST stateless nên load balancer chia request tùy ý:

Request 1 → Server A
Request 2 → Server B      ← không server nào cần "nhớ" gì
Request 3 → Server C

WebSocket thì mỗi connection sống ở một server cụ thể. Fan A connect vào Server 1 — connection đó vật lý nằm trong bộ nhớ Server 1:

Fan A ═══ Server 1
Fan B ═══ Server 2

Giờ Fan B giữ ghế A15. Sự kiện tới Server 2 — nhưng những fan đang nhìn cùng sơ đồ ghế lại connect vào Server 1, và Server 2 không hề cầm connection tới họ. Hệ thống stateless đã âm thầm trở thành hệ thống có trạng thái (connection chính là state), và ta cần một tầng phát tin giữa các server, thường là pub/sub broker:

Fan B ═══ Server 2
              │  publish("concert:45:seats", "A15 taken")
              ↓
      Redis Pub/Sub / Kafka / NATS
              │
              ↓  Server 1 đang subscribe kênh sơ đồ ghế concert 45
Fan A ═══ Server 1   ← thấy ghế A15 chuyển xám

Kéo theo một chuỗi câu hỏi vận hành mà REST không bao giờ phải trả lời: server giữ connection restart thì 50.000 fan trên đó đồng loạt reconnect vào đâu (thundering herd)? Thông báo gửi lúc fan đang rớt mạng thì lưu tạm ở đâu, phát lại thế nào? Làm sao biết một fan "online" khi họ có thể có 3 connection từ 3 thiết bị? Đây là lý do một hệ realtime khó gấp nhiều lần một hệ CRUD cùng quy mô — độ khó không nằm ở giao thức, mà ở trạng thái phân tán mà giao thức kéo theo.

■ Backpressure — khi người nói nhanh hơn người nghe

Phút mở bán, trạng thái ghế đổi liên tục: server đẩy 10.000 event/giây, nhưng chiếc điện thoại cũ của một fan chỉ tiêu thụ nổi 1.000/giây. Phần chênh dồn vào buffer phía server:

buffer: 1k → 5k → 10k → 100k → ... → hết RAM, server sập

Một client chậm, nếu không được xử lý, có thể kéo sập server phục vụ hàng vạn client khỏe — đây là bài toán backpressure. Các chiến lược, tùy bản chất dữ liệu:

Drop      dữ liệu kiểu "trạng thái mới nhất": vứt bản cũ, chỉ giữ bản mới nhất
          (fan cần biết ghế nào ĐANG trống, không cần lịch sử từng thay đổi)
Batch     gom 100 thay đổi ghế thành 1 message tổng hợp
Throttle  hạ tần suất gửi cho riêng client chậm
Kick      buffer vượt ngưỡng → ngắt connection, client reconnect
          và tải lại toàn bộ sơ đồ ghế qua REST

Câu hỏi "nếu bên nhận không theo kịp thì sao?" là câu hỏi bắt buộc của mọi hệ streaming — và là câu junior ít khi hỏi còn senior luôn hỏi đầu tiên.

■ Security

Bốn nguyên tắc gọn: dùng wss:// (WebSocket trên TLS — và cũng qua proxy/firewall dễ hơn ws:// thuần); xác thực ngay tại handshake (đừng nhận connection rồi mới hỏi giấy tờ — token đặt trong cookie hoặc query của request Upgrade, tốt hơn nữa là gửi qua message đầu tiên ngay sau khi mở); kiểm tra header Origin để chặn website lạ mở WebSocket tới server của bạn bằng phiên đăng nhập của nạn nhân (tấn công Cross-Site WebSocket Hijacking — họ hàng của CSRF, vì WebSocket handshake không bị ràng buộc bởi same-origin policy như fetch); và rate limit theo từng connection, vì một connection sống lâu là chỗ trú lý tưởng cho kẻ muốn spam — hoặc cho bot săn vé.

■ Khi nào dùng WebSocket

Nên dùng khi: sơ đồ ghế realtime lúc mở bán, chat, thông báo realtime, dashboard số liệu sống, game, collaborative editing — mọi thứ cần server đẩy dữ liệu tới end-user với độ trễ thấp và tần suất cao.

Không nên khi: dữ liệu chỉ chảy một chiều server→client với tần suất vừa phải — hãy nhìn SSE (Server-Sent Events) trước: đơn giản hơn hẳn, chạy trên HTTP thuần, tự động reconnect có sẵn. Và tuyệt đối đừng dùng WebSocket để làm việc của REST — "gửi request nhận response qua WebSocket" là tự tay vứt bỏ cache, status code, tooling, rồi tự phát minh lại tất cả một cách tệ hơn.


6. WebRTC — phức tạp hơn "P2P video" rất nhiều

■ Vấn đề nó giải quyết

Mọi công nghệ phía trên chở dữ liệu, và đều chạy trên TCP — giao thức ưu tiên đúng và đủ: mất gói thì truyền lại, đến sai thứ tự thì chờ sắp lại. Với dữ liệu, đó là đức tính. Với media realtime, đó là thảm họa: một khung hình video của 500ms trước mà đến muộn thì vô giá trị — cuộc gọi cần khung hình bây giờ. Chờ TCP truyền lại gói cũ nghĩa là mọi khung hình mới phải xếp hàng sau một khung hình đã chết.

Vì vậy WebRTC chọn nền UDP: mất gói thì... kệ, đi tiếp. Thà rớt một khung hình còn hơn đóng băng cả cuộc gọi. (Codec hiện đại đủ thông minh để che lấp gói mất; tai người bỏ qua 20ms audio thiếu dễ hơn nhiều so với 2 giây đứng hình.) Cộng thêm tham vọng peer-to-peer — media đi thẳng giữa hai người dùng, không vòng qua server — để tối thiểu độ trễ và chi phí.

Sơ đồ mơ ước:

Browser A ═════ Browser B

Và gần như toàn bộ độ phức tạp của WebRTC đến từ một sự thật phũ phàng: Internet thật không cho phép vẽ đường thẳng đó một cách dễ dàng.

■ NAT — bức tường đầu tiên

Laptop của bạn không có địa chỉ công khai trên Internet:

Laptop: 192.168.1.10  (địa chỉ riêng, chỉ có nghĩa trong nhà bạn)
   │
   ↓
Router: 113.20.x.x    (địa chỉ công khai, cả xóm nhà bạn dùng chung)
   │
   ↓
Internet

Router làm NAT: dịch địa chỉ riêng ↔ công khai cho các connection đi ra. Nhưng một máy lạ từ Internet muốn chủ động gõ cửa 192.168.1.10? Không có đường — địa chỉ đó vô nghĩa bên ngoài, và router mặc định chặn mọi kết nối đến không mời. Hai browser sau hai NAT muốn nối thẳng vào nhau là bài toán thật sự.

■ ICE, STUN, TURN — nghệ thuật đục tường

WebRTC dùng framework ICE (Interactive Connectivity Establishment): thu thập mọi đường đi khả dĩ (gọi là candidate), thử tất cả, chọn đường tốt nhất:

ICE candidates (theo thứ tự ưu tiên):
 ├── Host        địa chỉ local — chỉ ăn khi 2 máy cùng mạng LAN
 ├── Reflexive   địa chỉ công khai học được qua STUN
 └── Relay       đường vòng qua TURN — phương án cuối

STUN là server tối giản chỉ trả lời một câu hỏi:

Browser ── "Từ ngoài nhìn vào, tôi là ai?" ──> STUN Server
Browser <── "113.20.x.x, port 53423" ────────── STUN Server

Hai peer trao đổi địa chỉ công khai này cho nhau (qua signaling — nói ngay bên dưới) rồi đồng thời bắn gói UDP vào nhau. Cú "bắn chéo" này gọi là hole punching: gói đi ra của A mở sẵn một "lỗ" trên NAT của A, để gói đi vào từ B lọt qua đúng lỗ đó — với đa số loại NAT, thế là đường P2P thông. STUN gần như miễn phí vì chỉ tham gia lúc thiết lập, không chở media.

TURN dành cho khi hole punching thất bại (NAT symmetric khó tính, firewall công ty chặn UDP...):

Peer A ══> TURN Server ══> Peer B
        (toàn bộ media đi vòng qua đây)

Hết "P2P" — TURN server chở từng byte media, và đây là bài toán tiền: video 2 Mbps × 1 giờ ≈ 900 MB mỗi hướng. Nhân với nghìn cuộc gọi đồng thời, hóa đơn bandwidth của TURN là khoản chi hạ tầng thật sự. Thực tế vận hành: khoảng 10–20% cuộc gọi rơi vào TURN — con số đáng đưa vào bài toán chi phí ngay từ ngày thiết kế.

■ Signaling — mảnh ghép WebRTC cố tình bỏ trống

Nghịch lý thú vị: chuẩn WebRTC không quy định cách hai peer tìm thấy nhau. Trước khi nối P2P, hai bên phải trao đổi "danh thiếp" — và việc chuyển danh thiếp đó là việc của bạn, thường xây trên... WebSocket (các mảnh ghép của bài này bắt đầu lồng vào nhau):

Peer A ── SDP Offer ──> Signaling Server (WebSocket) ──> Peer B
Peer A <── SDP Answer ── Signaling Server (WebSocket) <── Peer B
       rồi hai bên trao đổi tiếp các ICE candidate
       (trickle ICE: tìm được candidate nào gửi ngay candidate đó,
        thay vì gom đủ mới gửi — connect nhanh hơn đáng kể)

SDP (Session Description Protocol) là tấm danh thiếp đó — bản mô tả năng lực: tôi hỗ trợ codec nào, độ phân giải nào, khóa mã hóa ra sao, địa chỉ ứng viên nào. Qua cặp Offer/Answer, hai bên thương lượng mẫu số chung — ví dụ codec:

Audio: Opus (gần như mặc định tuyệt đối)
Video: VP8 / VP9 / H.264 / AV1 — chọn cái cả hai cùng hỗ trợ

Chỉ sau khi signaling và ICE xong, đường media mơ ước mới xuất hiện:

Peer A ═════ Peer B

■ Security — một mặc định đáng học hỏi

WebRTC có quyết định thiết kế rất đẹp: mã hóa là bắt buộc, không có chế độ tắt. Media chạy trên SRTP với khóa được thỏa thuận qua DTLS handshake (TLS phiên bản cho UDP) trực tiếp giữa hai peer. Hệ quả tinh tế: cả TURN server — dù chở toàn bộ traffic — cũng chỉ thấy bytes đã mã hóa, không nghe/xem được nội dung. Kênh signaling thì bạn tự bảo vệ (wss + authentication). Đây là ví dụ hiếm hoi của "secure by default" được ghi thẳng vào chuẩn — nhiều giao thức khác phải trả giá hàng thập kỷ vì để mã hóa là "tùy chọn".

■ Từ 1-1 đến N người: Mesh, SFU, MCU

P2P đẹp với 2 người. Nhưng buổi fan meeting online 8 người mà nối mesh (ai cũng nối thẳng với ai):

mỗi người: upload 7 stream + download 7 stream

Upload là tài nguyên hiếm nhất của mạng gia đình — mesh chết ở khoảng 4–5 người. Hai kiến trúc server ra đời:

SFU (Selective Forwarding Unit) — chuẩn mực hiện nay
        ┌──── Fan A
SFU ────┼──── Fan B       mỗi người upload 1 stream duy nhất lên SFU;
        ├──── Fan C       SFU *chuyển tiếp* (không giải mã) stream
        └──── Fan D       của người khác xuống — chọn lọc chất lượng
                          phù hợp từng người nhận

MCU (Multipoint Control Unit) — kiểu cũ
        MCU giải mã mọi stream, TRỘN thành 1 khung hình duy nhất,
        encode lại rồi gửi. Client cực nhẹ (nhận đúng 1 stream),
        nhưng server tốn CPU khổng lồ và cộng thêm độ trễ encode.

SFU thắng vì đứng ở điểm cân bằng: server chỉ forward gói tin (rẻ), client chịu tải vừa phải, lại linh hoạt (nhờ simulcast/SVC, người mạng khỏe nhận bản HD, người mạng yếu nhận bản nhẹ của cùng một stream). Các nền tảng họp lớn (Meet, Zoom, Teams) về bản chất đều là biến thể SFU — nghĩa là họ không phải P2P thuần túy: WebRTC ở đó là công nghệ browser-tới-SFU, còn "peer" bên kia chính là server.

■ Khi nào dùng WebRTC

Nên dùng khi: gọi audio/video, screen sharing, buổi giao lưu video nhóm nhỏ tới vừa, và cả truyền dữ liệu độ trễ cực thấp qua DataChannel (game P2P, chia sẻ file trực tiếp).

Không nên khi: livestream cả đêm concert cho hàng vạn fan cùng xem — đó là bài toán của HLS/DASH: chấp nhận trễ vài giây đổi lấy khả năng scale qua CDN. Hay bất kỳ thứ gì chỉ cần "realtime mức chat" — WebSocket đơn giản hơn hàng chục lần. WebRTC là công nghệ nặng ký; chỉ rút ra khi độ trễ dưới 500ms của media là yêu cầu thật.


Ghép cả sáu mảnh lại: một spectrum, một kiến trúc

■ Spectrum theo mô hình giao tiếp

AI KHỞI XƯỚNG? ─────────────────────────────────────────────

Client hỏi, server đáp          REST, SOAP, gRPC unary
Server chủ động báo (thưa)      Webhook
Hai chiều liên tục (dày)        WebSocket, gRPC streaming
Media thời gian thực            WebRTC


CONNECTION SỐNG BAO LÂU? ───────────────────────────────────

Ngắn / logic theo request       REST, SOAP, Webhook
Sống lâu, tái sử dụng           gRPC, WebSocket
Peer/media session              WebRTC


ĐÁNH ĐỔI CỐT LÕI? ──────────────────────────────────────────

REST        đơn giản + cache + phổ cập     đổi lấy hiệu năng
SOAP        contract + bảo mật message      đổi lấy sự nhẹ nhàng
gRPC        hiệu năng + contract            đổi lấy khả năng debug & browser
Webhook     tách rời hệ thống               đổi lấy đảm bảo delivery/ordering
WebSocket   realtime hai chiều              đổi lấy sự đơn giản khi scale
WebRTC      độ trễ media tối thiểu          đổi lấy độ phức tạp thiết lập

■ Toàn bộ trong một hệ thống thật

Quay lại app đặt vé — giờ có thể vẽ đầy đủ:

                      INTERNET
                          │
                          │ HTTPS / REST ①
                          ↓
                   ┌──────────────┐
Mobile / Browser → │ API Gateway  │
                   └──────┬───────┘
                          │ gRPC ②
          ┌───────────────┼────────────────┐
          ↓               ↓                ↓
       Booking         Payment           Seat
       Service         Service         Service
                          │
                          │ ← Webhook ③ (payment.success)
                          │
                   Payment Gateway
                   (+ SOAP ④ nếu đối tác là ngân hàng nội địa)

Seat Service ── event ──> Message Broker ──> Realtime Service
                                                     │
                                                     │ WebSocket ⑤
                                                     ↓
                                            App của các fan
                              ("Ghế A15 vừa có người giữ" /
                               "Đợt vé bổ sung đêm 20/9 vừa mở bán!")

Fan ═══════════ WebRTC ⑥ ═══════════ Fan meeting online
              (buổi giao lưu video sau show, qua SFU)

① App gọi REST vào gateway — chuẩn phổ cập, cache tốt, mọi client đều nói được.
② Bên trong, các service gọi nhau qua gRPC — nhanh, có contract, có deadline.
③ Cổng thanh toán báo kết quả về qua Webhook — hai hệ thống của hai công ty, tách rời.
④ Nếu cổng đó nối tiếp vào ngân hàng, rất có thể tầng dưới cùng là SOAP.
⑤ Sơ đồ ghế và thông báo mở bán đẩy realtime tới fan qua WebSocket.
⑥ Buổi giao lưu video sau show chạy trên WebRTC — media đi P2P hoặc qua SFU khi nhóm đông.

Sáu công nghệ, sáu tầng, không cái nào thay thế cái nào.

■ Bốn câu hỏi để chọn đúng

Đứng trước một bài toán giao tiếp mới, thay vì hỏi "công nghệ nào hot", hãy hỏi:

1. AI khởi xướng?        Client hỏi → REST/gRPC
                         Server báo → Webhook/WebSocket
2. Tần suất & độ trễ?    Thưa, chịu được giây/phút → REST/Webhook
                         Dày, cần mili-giây        → gRPC/WebSocket
3. Chở cái gì?           Dữ liệu → mọi thứ trừ WebRTC
                         Media realtime → WebRTC
4. Ai ở hai đầu dây?     Browser/mobile ↔ server   → REST/WebSocket
                         Service ↔ service nội bộ  → gRPC/message queue
                         Công ty ↔ công ty khác    → REST/Webhook (hoặc SOAP nếu họ yêu cầu)

Lời kết

Nếu chỉ được giữ lại một ý từ bài này, hãy giữ ý sau: các giao thức không cạnh tranh nhau — chúng trả lời những câu hỏi khác nhau. REST trả lời "làm sao để hỏi–đáp một cách đơn giản và scale được". Webhook trả lời "làm sao để được báo tin mà không phải hỏi mãi". WebSocket trả lời "làm sao để hội thoại liên tục". WebRTC trả lời "làm sao để độ trễ media thấp nhất vật lý cho phép".

Và xuyên suốt cả sáu phần, bạn sẽ thấy vài chủ đề cứ quay lại như điệp khúc — vì chúng là bản chất của mọi hệ phân tán, bất kể giao thức: mạng không đáng tin (nên mới cần retry, idempotency, heartbeat), exactly-once là ảo tưởng (nên consumer phải tự vệ), state là gánh nặng khi scale (nên REST stateless mới quý, và WebSocket mới khó), và mọi lựa chọn đều là đánh đổi (nhanh đổi lấy dễ debug, chặt đổi lấy nhẹ nhàng, realtime đổi lấy đơn giản).

Hiểu được các đánh đổi đó — chứ không phải thuộc lòng cú pháp — mới là thứ giúp bạn đọc hiểu một kiến trúc lạ trong 5 phút, và thiết kế hệ thống của mình mà không phải trả học phí bằng sự cố production.