Skip to content

Regional Endpoints và Cơ Chế Bảo Toàn Thứ Tự (Ordering Keys)

Why this matters in production

Trong các hệ thống phân tán, việc bảo đảm thứ tự xử lý của các thông điệp là một bài toán cực kỳ phức tạp. Ví dụ, trong một hệ thống tài chính (Fintech) hoặc thương mại điện tử (E-commerce), việc xử lý các sự kiện OrderCreated -> OrderPaid -> OrderShipped bắt buộc phải diễn ra theo đúng trình tự thời gian. Nếu thứ tự này bị đảo lộn (ví dụ OrderPaid được xử lý trước OrderCreated), hệ thống sẽ rơi vào trạng thái lỗi logic nghiệp vụ nghiêm trọng.

Google Cloud Pub/Sub cung cấp tính năng Ordering Keys để giải quyết bài toán này. Tuy nhiên, tính năng bảo toàn thứ tự này đi kèm với một điều kiện ràng buộc hạ tầng cực kỳ khắt khe: Tất cả các thông điệp có cùng một ordering key bắt buộc phải được xuất bản (publish) vào cùng một vùng địa lý (region) duy nhất.

Nếu một kiến trúc sư thiết kế hệ thống sử dụng global endpoint mặc định (pubsub.googleapis.com) cho một luồng dữ liệu cần bảo toàn thứ tự, hệ thống đó sẽ hoạt động bình thường trong môi trường kiểm thử (dev/staging) độ tải thấp, nhưng khi đưa lên production hoặc khi xảy ra sự cố sập vùng, thứ tự thông điệp sẽ bị phá vỡ hoàn toàn do cơ chế định tuyến ngẫu nhiên của Google Front End (GFE).


Internal Model: Cơ Chế Hoạt Động Của Ordering Keys và Định Tuyến Vùng

Để hiểu tại sao thứ tự thông điệp lại bị ràng buộc chặt chẽ với vùng địa lý, ta cần phân tích cơ chế phân mảnh (sharding) và lưu trữ log của Pub/Sub.

mermaid
graph TD
    Client[Publisher Client] -->|Publish with Ordering Key K| RegEndpoint[us-central1-pubsub.googleapis.com]
    RegEndpoint -->|Hash Key K| Router[Pub/Sub Router]
    Router -->|Pin to Shard S| StorageNode[Storage Node in us-central1]
    StorageNode -->|Store in order| Colossus[(Colossus Log)]
    
    subgraph Subscriber Client
        SubClient[Subscriber Client] -->|Pull from us-central1| StorageNode
        SubClient -->|Process sequentially| Handler[Application Message Handler]
    end

1. Phân Mảnh Dữ Liệu Dựa Trên Ordering Key (Key-based Sharding)

Bên dưới lớp abstraction của một Topic, Pub/Sub chia nhỏ luồng dữ liệu thành nhiều phân mảnh logic (shards/partitions) chạy trên các Storage Nodes khác nhau.

  • Khi bạn xuất bản một message không có ordering key, Pub/Sub sẽ phân phối các message này ngẫu nhiên (round-robin hoặc hash ngẫu nhiên) qua tất cả các shards để đạt được băng thông ghi (write throughput) tối đa. Các subscriber sẽ nhận tin nhắn song song từ nhiều shards mà không có cam kết thứ tự nào.
  • Khi bạn xuất bản message ordering key (ví dụ: user-12345):
    1. Client library hoặc Pub/Sub gateway sẽ chạy một thuật toán băm (hashing algorithm) trên chuỗi ký tự của ordering key để tạo ra một phân mảnh đích xác định (hashed partition assignment).
    2. Tất cả các message có cùng một key băm này sẽ được ghim (pinned) vào một Storage Node cố định trong region đó.
    3. Storage Node này sẽ ghi nhận các message vào một file log tuần tự trên Colossus theo mô hình Single-Writer. Do chỉ có một luồng ghi duy nhất vào phân mảnh đó, Pub/Sub có thể đảm bảo thứ tự ghi nhận vật lý tuyệt đối.

2. Sự Nguy Hiểm Của Global Endpoint Đối Với Message Ordering

Tại sao dùng global endpoint (pubsub.googleapis.com) lại phá vỡ thứ tự thông điệp?

  • Khi sử dụng global endpoint, request publish của bạn sẽ được định tuyến dựa trên vị trí của Publisher. Nếu Publisher của bạn chạy ở nhiều vùng khác nhau (multi-region deployment) hoặc do mạng internet định tuyến thay đổi:
    • Message 1 (Key A) gửi từ VM ở Mỹ đi vào GFE Mỹ và được ghi vào region us-central1.
    • Message 2 (Key A) gửi từ VM ở châu Âu đi vào GFE châu Âu, GFE này định tuyến ghi vào region europe-west3.
  • Do Message 1 nằm ở us-central1 và Message 2 nằm ở europe-west3, Pub/Sub không có một đồng hồ đồng bộ toàn cầu (global physical clock synchronization) hoặc một coordination layer trung tâm để sắp xếp thứ tự chéo vùng mà không làm ảnh hưởng nghiêm trọng đến hiệu năng ghi dữ liệu.
  • Khi Subscriber kết nối vào hệ thống, nó sẽ kéo dữ liệu song song từ cả hai vùng. Kết quả là Message 2 có thể được phân phối đến Subscriber trước Message 1, phá vỡ hoàn toàn nguyên tắc FIFO (First-In, First-Out).

Quy tắc bất biến: Để duy trì thứ tự thông điệp, bạn bắt buộc phải chỉ định một regional endpoint cụ thể (ví dụ: us-central1-pubsub.googleapis.com) khi khởi tạo Publisher client. Điều này bắt buộc mọi request ghi dữ liệu phải đi về cùng một vùng để thực hiện sharding và ghi log tuần tự tại một điểm duy nhất.


Constraints, Trade-offs & Failure Modes

Việc sử dụng Ordering Keys đi kèm với những ràng buộc vận hành và kịch bản lỗi cực kỳ phức tạp khi xảy ra sự cố vùng:

1. Cơ Chế Khóa Luồng Xuất Bản Khi Gặp Lỗi (Publish Blocking và Resume)

Khi một Publisher xuất bản một chuỗi tin nhắn có cùng ordering key, nếu một tin nhắn ở giữa chuỗi gặp lỗi (ví dụ: lỗi mạng, timeout, hoặc region bị sập tạm thời dẫn đến ghi thất bại):

  • Phản ứng của Client Library: Client library của Pub/Sub sẽ tự động khóa (block) toàn bộ các tin nhắn tiếp theo có cùng ordering key đó trong hàng đợi của bộ nhớ client (local memory buffer).
  • Lý do: Nếu tin nhắn N bị lỗi nhưng tin nhắn N+1 vẫn tiếp tục được gửi và ghi thành công, Subscriber sẽ nhận được tin nhắn N+1 trước tin nhắn N. Điều này vi phạm cam kết bảo toàn thứ tự.
  • Hành vi lỗi: Mọi lệnh gọi publish() tiếp theo cho key đó sẽ lập tức trả về lỗi (thường là OrderedPublishException hoặc lỗi tương đương tùy ngôn ngữ) mà không cần gửi request lên server.
  • Giải quyết trong Code: Ứng dụng phải được thiết kế để bắt lỗi này, thực hiện xử lý hoặc ghi nhận lỗi, và sau đó gọi phương thức resumePublish(orderingKey) một cách tường minh để mở khóa cho key đó tiếp tục gửi dữ liệu mới.

2. Nguy Cơ Mất Thứ Tự Khi Chuyển Vùng Dự Phòng (Failover Out-of-Order Risk)

Kịch bản lỗi điển hình trong thực tế sản xuất:

  1. Region A gặp sự cố (ví dụ: us-central1 bị sập).
  2. Publisher phát hiện lỗi ghi vào us-central1-pubsub.googleapis.com.
  3. Để đảm bảo tính sẵn sàng (availability), Publisher chuyển hướng ghi sang vùng dự phòng us-east1-pubsub.googleapis.com cho cùng một ordering key.
  4. Subscriber bắt đầu kéo dữ liệu từ vùng dự phòng us-east1 và nhận được các thông điệp mới.
  5. Đứt gãy thứ tự xảy ra: Các thông điệp cũ đã được ghi thành công vào us-central1 trước khi sập vùng nhưng chưa được Subscriber kịp xử lý sẽ bị kẹt lại. Subscriber sẽ xử lý các thông điệp mới ở us-east1 trước. Khi region us-central1 hoạt động trở lại, các thông điệp cũ mới được phân phối đến Subscriber. Kết quả: tin nhắn mới được xử lý trước tin nhắn cũ.

Production Patterns & Implementation Guidance

1. Cấu hình Publisher Client Với Regional Endpoint và Ordering Key trong Go

Dưới đây là snippet code Go chuẩn sản xuất chỉ ra cách khởi tạo Pub/Sub client trỏ trực tiếp vào một regional endpoint, kích hoạt tính năng gửi tin nhắn theo thứ tự, và cách xử lý mở khóa luồng ghi (ResumePublish) khi có lỗi xảy ra.

go
package main

import (
	"context"
	"fmt"
	"log"
	"google.golang.org/api/option"
	"cloud.google.com/go/pubsub"
)

func main() {
	ctx := context.Background()
	projectID := "my-production-project"
	topicID := "ordered-billing-events"
	
	// Bắt buộc: Cấu hình trỏ vào Regional Endpoint của vùng us-central1
	regionalEndpoint := "us-central1-pubsub.googleapis.com:443"
	
	client, err := pubsub.NewClient(ctx, projectID, 
		option.WithEndpoint(regionalEndpoint),
	)
	if err != nil {
		log.Fatalf("Failed to create client: %v", err)
	}
	defer client.Close()

	topic := client.Topic(topicID)
	
	// Bắt buộc: Kích hoạt tính năng bảo toàn thứ tự ở phía Publisher
	topic.PublishSettings.DelayThreshold = 10 * 1000 * 1000 // 10ms
	topic.EnableMessageOrdering = true

	orderingKey := "account-992381"
	messagePayload := []byte("InvoicePaid: #1002")

	msg := &pubsub.Message{
		Data:        messagePayload,
		OrderingKey: orderingKey, // Thiết lập khóa thứ tự
	}

	result := topic.Publish(ctx, msg)
	
	// Chờ đợi kết quả ghi nhận từ Storage Node
	id, err := result.Get(ctx)
	if err != nil {
		fmt.Printf("Publish failed for key %s: %v\n", orderingKey, err)
		
		// Xử lý lỗi: Khi ghi thất bại, luồng của key này sẽ bị khóa.
		// Chúng ta cần gọi ResumePublish để giải phóng hàng đợi nhớ của client.
		fmt.Printf("Resuming publish pipeline for key: %s\n", orderingKey)
		topic.ResumePublish(orderingKey)
		
		// Thực hiện logic ghi nhận lỗi vào DLQ nội bộ hoặc lưu trữ cục bộ để xử lý lại sau
		return
	}
	
	fmt.Printf("Published ordered message with ID: %s\n", id)
}

2. Thiết Kế Sắp Xếp Thứ Tự Phía Subscriber (Subscriber-Side Sequencing)

Nếu hệ thống của bạn đòi hỏi tính sẵn sàng cực cao chéo vùng (Multi-Region Failover) nhưng đồng thời không được phép xử lý sai trình tự, bạn không thể chỉ phụ thuộc vào cơ chế tự động của Pub/Sub. Bạn phải triển khai sắp xếp thứ tự ở tầng ứng dụng (Application-Level Sequencing):

  1. Thêm Metadata: Publisher sẽ tự động đính kèm một số thứ tự tăng dần (sequence number) hoặc timestamp chính xác microsecond vào thuộc tính (attributes) của message trước khi gửi.
  2. Bộ Đệm Subscriber (Stateful Buffering): Khi Subscriber nhận tin nhắn từ bất kỳ vùng nào (kể cả sau khi failover), nó không xử lý trực tiếp vào cơ sở dữ liệu ngay lập tức. Thay vào đó, nó sẽ đẩy tin nhắn vào một bộ đệm lưu trữ tạm thời có trạng thái (ví dụ: Redis Sorted Set) được phân khóa theo orderingKey.
  3. Reconciliation Loop: Một tiến trình xử lý nền (worker) sẽ đọc từ bộ đệm này, kiểm tra xem số thứ tự tiếp theo có khớp với số thứ tự mong đợi của tài khoản đó không. Nếu phát hiện bị mất quãng (ví dụ: nhận được sequence 5 nhưng sequence 4 chưa có), worker sẽ chờ đợi trong một khoảng thời gian (grace period timeout) để sequence 4 được phân phối (hoặc được kéo lại từ region cũ sau khi hồi phục) trước khi quyết định bỏ qua hoặc báo lỗi.

References