Microservices hiệu năng cao với NATS: Vượt qua rào cản của REST

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Điểm nghẽn trong giao tiếp Microservice

Chia nhỏ một hệ thống monolith (nguyên khối) cảm giác như một chiến thắng cho đến khi bạn rơi vào giai đoạn “spaghetti phân tán”. Hầu hết các đội ngũ bắt đầu với REST hoặc gRPC vì chúng quen thuộc. Mặc dù chúng hoạt động tốt cho các API bên ngoài, nhưng việc phụ thuộc vào chúng cho mọi tương tác nội bộ sẽ tạo ra một mạng lưới kết nối chặt chẽ (tight coupling) mong manh. Nếu Service A gọi Service B qua HTTP và Service B gặp trục trặc, Service A sẽ bị treo. Đây chính là cách một sự chậm trễ nhỏ biến thành sự cố toàn hệ thống.

Tôi từng kiểm tra một nền tảng thanh toán, nơi dịch vụ đặt hàng phải đợi dịch vụ kho bãi, thông báo và vận chuyển thông qua các cuộc gọi REST đồng bộ. Một độ trễ 200ms trong dịch vụ thông báo đã kéo theo các lỗi timeout kéo dài 5 giây cho người dùng. Trong một đợt giảm giá lễ hội, lưu lượng truy cập tăng 15% đã kích hoạt một phản ứng dây chuyền làm sập toàn bộ trang web. Đó là thời điểm chúng tôi nhận ra các cuộc gọi đồng bộ là một gánh nặng chứ không phải là tài sản.

Chi phí ẩn của việc kết nối đồng bộ

Giao tiếp HTTP tiêu chuẩn buộc các dịch vụ phải biết quá nhiều về nhau. Bạn cần load balancers, sidecars để phát hiện dịch vụ (service discovery) và logic thử lại (retry logic) quyết liệt chỉ để di chuyển một gói tin JSON nhỏ. Cơ sở hạ tầng này thêm khoảng 10-50ms độ trễ cho mỗi bước nhảy (hop). Mặc dù RabbitMQ có thể giải quyết vấn đề, nhưng nó rất khó quản lý. Kafka mạnh mẽ nhưng thường là quá mức cần thiết; chạy một cụm (cluster) đầy đủ chỉ để truyền tín hiệu dịch vụ giống như dùng xe container để giao một bức thư duy nhất.

Chúng ta cần thứ gì đó nhanh hơn. Một hệ thống nhẹ, xử lý hàng triệu tin nhắn mỗi giây và hỗ trợ nhiều mô hình giao tiếp ngay khi cài đặt. NATS đáp ứng hoàn hảo khoảng trống này.

NATS: Hệ thần kinh 10 micro giây

NATS là một hệ thống nhắn tin cloud-native được phân phối dưới dạng một tệp thực thi (binary) duy nhất 20MB. Nó đóng vai trò như một hệ thần kinh trung ương cho kiến trúc của bạn. Không giống như Kafka mặc định lưu trữ nặng nề trên đĩa, NATS ưu tiên bộ nhớ. Điều này cho phép nó đạt được độ trễ thấp tới 10 micro giây. Nó xử lý ba mô hình chính bao quát hầu hết mọi kịch bản backend:

  • Pub/Sub: Nhắn tin bất đồng bộ kiểu fan-out cho các luồng hướng sự kiện.
  • Request-Reply: Logic kiểu đồng bộ được xây dựng trên nền tảng bất đồng bộ siêu nhanh.
  • JetStream: Khả năng lưu trữ (persistence) tích hợp sẵn khi bạn không thể để mất dù chỉ một byte dữ liệu.

Thực hành: Xây dựng hệ thống vận hành bởi NATS

Bạn chỉ cần Docker và Python để bắt đầu. Mặc dù chúng ta đang sử dụng thư viện nats-py, logic vẫn tương tự cho Go, Node.js hoặc Java.

1. Khởi chạy NATS Server

Khởi động server với JetStream được kích hoạt bằng một lệnh Docker duy nhất. Điều này cung cấp cho bạn cả tính năng nhắn tin cốt lõi và lớp lưu trữ ngay lập tức.

docker run -d --name nats-main -p 4222:4222 -p 8222:8222 nats:latest -js

2. Mô hình 1: Tách rời với Pub/Sub

Pub/Sub cho phép một dịch vụ phát đi một sự kiện mà không cần quan tâm ai đang lắng nghe. Đây là cách tốt nhất để xử lý các tác vụ phụ (side effects) như gửi email chào mừng hoặc cập nhật chỉ mục tìm kiếm.

Bên đăng ký (Subscriber/Listener):

import asyncio
from nats.aio.client import Client as NATS

async def run():
    nc = NATS()
    await nc.connect("nats://localhost:4222")

    async def message_handler(msg):
        print(f"Nhận được sự kiện trên '{msg.subject}': {msg.data.decode()}")

    # Lắng nghe bất kỳ sự kiện tạo người dùng nào
    await nc.subscribe("user.created", cb=message_handler)
    print("Đang chờ sự kiện...")

    while True:
        await asyncio.sleep(1)

if __name__ == '__main__':
    asyncio.run(run())

Bên phát hành (Publisher):

import asyncio
from nats.aio.client import Client as NATS

async def run():
    nc = NATS()
    await nc.connect("nats://localhost:4222")

    # Gửi và không cần chờ phản hồi (Fire and forget)
    await nc.publish("user.created", b'{"id": 101, "user": "tech_editor"}')
    print("Sự kiện đã được phát đi")
    await nc.close()

if __name__ == '__main__':
    asyncio.run(run())

3. Mô hình 2: Request-Reply tốc độ cao

NATS giúp Request-Reply nhanh hơn HTTP bằng cách sử dụng lại một kết nối TCP duy nhất tồn tại lâu dài. Nó tự động tạo một subject “reply-to” cho phản hồi, loại bỏ nhu cầu cấu hình load balancer phức tạp.

Bên phản hồi (Responder):

async def run():
    nc = NATS()
    await nc.connect("nats://localhost:4222")

    async def handle_request(msg):
        print(f"Nhận được truy vấn: {msg.data.decode()}")
        await nc.publish(msg.reply, b"Trạng thái kho hàng: OK")

    await nc.subscribe("inventory.check", cb=handle_request)

4. Mô hình 3: Đảm bảo phân phối với JetStream

NATS cốt lõi hoạt động theo kiểu “gửi và quên”. Nếu một dịch vụ bị sập trong khi phát sóng, nó sẽ bỏ lỡ tin nhắn. JetStream giải quyết vấn đề này bằng cách thêm một lớp lưu trữ. Tôi đã sử dụng lớp này trong một dự án fintech để xử lý 50.000 giao dịch mỗi giây; ngay cả khi một consumer bị sập, nó chỉ đơn giản là tiếp tục lại đúng nơi đã dừng.

Ví dụ xử lý đáng tin cậy:

async def run():
    nc = NATS()
    await nc.connect("nats://localhost:4222")
    js = nc.jetstream()

    # Định nghĩa một stream lưu trữ tin nhắn trong 24 giờ
    await js.add_stream(name="SALES", subjects=["sales.*"])

    # Phát hành với xác nhận (acknowledgement)
    ack = await js.publish("sales.new", b'Invoice #999')
    print(f"Đã lưu vào JetStream. Số thứ tự: {ack.seq}")

    # Lấy dữ liệu theo kiểu pull cho các tác vụ nặng
    sub = await js.pull_subscribe("sales.new", "invoice-processor")
    msgs = await sub.fetch(1)
    for msg in msgs:
        print(f"Đang xử lý: {msg.data.decode()}")
        await msg.ack() # Thông báo cho NATS rằng chúng ta đã xử lý xong

Góc nhìn kiến trúc

Chuyển sang NATS thay đổi tư duy của bạn. Bạn ngừng hỏi “Tôi nên gọi vào endpoint nào?” và bắt đầu hỏi “Sự kiện gì vừa xảy ra?”.

Thiết kế Subject thông minh

NATS sử dụng phân cấp ngăn cách bằng dấu chấm như orders.us.east.created. Bạn có thể sử dụng các ký tự đại diện (* cho một cấp, > cho tất cả các cấp bên dưới) để định tuyến dữ liệu hiệu quả. Một công cụ giám sát có thể đăng ký orders.>.created để theo dõi mọi đơn hàng mới trên tất cả các khu vực toàn cầu mà không cần thay đổi một dòng mã nào của bên phát hành.

Mở rộng với Queue Groups

Nếu bạn chạy năm instance của một worker, bạn không muốn tất cả chúng đều xử lý cùng một email. NATS Queue Groups tự động xử lý việc này. Khi bạn đăng ký sử dụng tên hàng đợi (queue name), NATS sẽ cân bằng tải các tin nhắn giữa tất cả các thành viên hiện có.

# NATS sẽ chọn một worker trong nhóm 'billing-service' cho mỗi tin nhắn
await nc.subscribe("payments.process", queue="billing-service", cb=handler)

Lời kết

Microservices nên nhanh và được tách rời, không bị sa lầy bởi các chi phí đồng bộ. Bằng cách chuyển giao tiếp sang NATS, bạn loại bỏ sự phức tạp của việc phát hiện dịch vụ và sự mong manh của các liên kết HTTP trực tiếp. NATS mở rộng từ một thiết bị edge nhỏ bé đến một cụm toàn cầu với cùng một API đơn giản.

Nếu nhật ký (logs) của bạn đầy lỗi timeout, hãy thử thay thế một cuộc gọi REST nội bộ bằng mô hình NATS Request-Reply. Bạn sẽ thấy độ trễ giảm ngay lập tức và độ ổn định của hệ thống tăng lên đáng kể.

Share: