Harbor

Ứng dụng giao dịch chứng khoán

KhóGiá real-timeĐặt lệnh tin cậyState machineĐối soátGiống Robinhood, SSI iBoard, VPS SmartOne

Hiểu đề

Ứng dụng giao dịch chứng khoán là gì? App môi giới cho nhà đầu tư cá nhân: xem bảng giá nhảy liên tục, đặt lệnh mua/bán, theo dõi lệnh khớp và số dư. Công ty chứng khoán chỉ chuyển lệnh lên sàn (HOSE, HNX) và nhận kết quả; việc khớp lệnh do sàn làm.

Yêu cầu chức năng

  1. Người dùng có thể xem bảng giá các mã đang theo dõi, cập nhật theo thời gian thực
  2. Người dùng có thể đặt lệnh mua/bán (lệnh giới hạn, lệnh thị trường) và hủy lệnh chưa khớp
  3. Người dùng thấy trạng thái lệnh (chờ khớp, khớp một phần, khớp hết, đã hủy, bị từ chối) ngay khi sàn báo
  4. Người dùng có thể xem tiền mặt, sức mua và danh mục cổ phiếu đang nắm giữ

Yêu cầu phi chức năng

  • Giá từ lúc nhận feed của sàn đến màn hình p95 < 500 ms
  • Đặt lệnh: từ lúc bấm đến khi lệnh được gửi lên sàn p95 < 300 ms (phần trong hệ thống)
  • 0 lệnh gửi trùng, 0 lệnh mất; trạng thái mọi lệnh khớp với sàn sau đối soát cuối ngày
  • Sức mua không bao giờ âm dù người dùng đặt nhiều lệnh song song
  • Độ sẵn sàng 99,95% trong giờ giao dịch; đỉnh tải dồn vào phiên ATO/ATC

Ngoài phạm vi: Khớp lệnh (sàn làm); Giao dịch ký quỹ (margin), phái sinh, trái phiếu; Nạp/rút tiền qua ngân hàng và mở tài khoản eKYC; Biểu đồ kỹ thuật và dữ liệu lịch sử nhiều năm.

Thực thể và API

Instrument
Mã chứng khoán: sàn, giá tham chiếu, giá trần/sàn trong ngày, bước giá, lô giao dịch (100 cổ phiếu).
Quote
Giá hiện tại của một mã: giá khớp gần nhất, khối lượng, 3 mức giá mua/bán tốt nhất, seq tăng dần.
Order
Lệnh của khách: order_id (dùng làm ClOrdID gửi sàn), account, mã, chiều, loại, khối lượng, giá, status, filled_qty, exchange_order_id, version.
Execution
Một lần khớp do sàn báo: exec_id duy nhất, order_id, khối lượng, giá, thời điểm. Lưu cả bản tin gốc.
LedgerEntry
Bút toán tiền/cổ phiếu chỉ ghi thêm: giữ tiền, nhả tiền, trừ tiền khi khớp, phí, thuế; idempotency key theo order_id hoặc exec_id.
Account / Position
Số dư tổng hợp: tiền mặt, tiền đang giữ cho lệnh chờ, sức mua; cổ phiếu có thể bán và cổ phiếu chờ về (T+2).
MethodEndpointGhi chú
GET/v1/quotes?symbols=FPT,VNM,HPGSnapshot giá hiện tại kèm seq, đọc từ Redis.
WS/v1/streamGửi {subscribe: [mã]}; nhận frame giá đã gộp mỗi 250 ms và sự kiện lệnh của chính mình.
POST/v1/ordersHeader Idempotency-Key; body {symbol, side, type, qty, price}. Trả 201 với orderId, status PENDING_NEW; 422 nếu vượt sức mua hoặc sai bước giá.
DELETE/v1/orders/{id}Yêu cầu hủy: chuyển PENDING_CANCEL và gửi lệnh hủy lên sàn; chỉ CANCELED khi sàn xác nhận.
GET/v1/orders?status=open&cursor=Sổ lệnh trong ngày và lệnh đang mở.
GET/v1/accounts/me/portfolioTiền mặt, sức mua, danh mục, lãi/lỗ tạm tính theo giá hiện tại.

Thiết kế tổng thể

Đi lần lượt từng yêu cầu chức năng. Mục tiêu ở đây là chạy đúng, việc tối ưu để dành cho phần đào sâu.

1) Người dùng có thể xem bảng giá real-time

Công ty nhận feed giá từ sàn hoặc nhà cung cấp dữ liệu. Ingest chuẩn hóa, gộp theo mã rồi phát qua pub/sub tới các WebSocket gateway, gateway chỉ đẩy mã mà từng client đang xem.

  1. 1Ingest nhận tick từ feed, cập nhật snapshot của mã trong Redis kèm seq
  2. 2Mỗi 250 ms ingest phát một lô gồm các mã vừa đổi lên kênh pub/sub
  3. 3WS gateway nhận lô, lọc theo danh sách mã mỗi client subscribe, gửi một frame cho mỗi client
  4. 4Client mới kết nối hoặc thấy seq bị hụt thì gọi GET /quotes để lấy snapshot

Nguồn: Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "Introduction to Pub-sub", mục "Use cases of pub-sub" (push thay vì polling) ; Redis in Action ↗ · ch.3 · §3.6 Publish/subscribe (tr. 54–57) ; Grokking Modern System Design · ch.36 WhatsApp ↗ · bài "Detailed Design of WhatsApp", mục "Connection with a WebSocket server" ; Chưa có nguồn trong thư viện: giao thức FIX và quy tắc thị trường chứng khoán (ATO/ATC, T+2, lô 100) không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản

2) Người dùng có thể đặt và hủy lệnh

Order API kiểm tra lệnh và giữ tiền trong cùng một transaction với việc tạo Order. Lệnh chỉ được gửi lên sàn sau khi đã lưu bền, qua outbox và một order gateway giữ phiên kết nối FIX với sàn.

  1. 1Client gửi POST /v1/orders kèm Idempotency-Key
  2. 2Order API kiểm tra giờ giao dịch, giá trần/sàn, bước giá, lô 100
  3. 3Một transaction: giữ tiền (UPDATE có điều kiện sức mua), tạo Order PENDING_NEW, ghi outbox
  4. 4Order gateway đọc outbox, gửi NewOrderSingle lên sàn với ClOrdID = order_id
  5. 5Sàn trả ack thì Order sang NEW; bị từ chối thì REJECTED và nhả tiền

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (tr. 97–100) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.3.2 Countermeasures for handling the lack of isolation (trạng thái *_PENDING, tr. 128–132) ; Chưa có nguồn trong thư viện: giao thức FIX và quy tắc thị trường chứng khoán (ATO/ATC, T+2, lô 100) không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản

3) Người dùng thấy trạng thái lệnh ngay khi sàn báo

Sàn gửi execution report cho mỗi lần xác nhận, khớp hoặc hủy. Gateway ghi report và chuyển trạng thái theo state machine, cập nhật số dư, rồi đẩy sự kiện tới đúng người dùng.

  1. 1Order gateway nhận execution report, lưu bản tin gốc
  2. 2Ghi Execution theo exec_id (bỏ qua nếu đã có), cập nhật Order theo state machine
  3. 3Cùng transaction: bút toán trừ tiền theo giá khớp, cộng cổ phiếu chờ về
  4. 4Phát sự kiện order_updated, WS gateway đẩy tới các phiên của người đó

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.3.2 Countermeasures for handling the lack of isolation (tr. 128–132) ; Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "Design of a Pub-sub System" (đẩy sự kiện tới subscriber) ; Chưa có nguồn trong thư viện: giao thức FIX và quy tắc thị trường chứng khoán (ATO/ATC, T+2, lô 100) không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản

4) Người dùng có thể xem số dư và danh mục

Số dư được tính từ sổ cái bút toán và có bảng tổng hợp cập nhật cùng transaction. Lãi/lỗ tạm tính ghép danh mục với giá hiện tại trong Redis.

  1. 1Client gọi GET /portfolio
  2. 2API đọc bảng Account/Position của tài khoản
  3. 3Ghép với giá hiện tại từ Redis để tính giá trị và lãi/lỗ
  4. 4Khi có lệnh khớp, app nhận sự kiện qua WebSocket và tải lại phần thay đổi

Nguồn: Redis in Action ↗ · ch.3 · §3.4 Hashes (tr. 48–50) ; Grokking Modern System Design · ch.9 Databases ↗ · bài "Types of Databases", mục "Relational databases" (concurrency, tính toàn vẹn) ; Chưa có nguồn trong thư viện: mô hình sổ cái bút toán không có trong sách; giữ lại như thiết kế của khoá

Hạ tầng theo quy mô

Cùng một sản phẩm, ba mức người dùng. Chọn từng mức để xem sơ đồ, phép tính và cấu hình server thay đổi thế nào.

Người dùng
10.000 DAU (công ty chứng khoán nhỏ)
Tải đỉnh
≈ 11 lệnh/s lúc ATO/ATC, 4.000 kết nối giá
Chi phí ước lượng
≈ 400–1.000 USD/tháng (ước lượng, chưa tính phí kết nối sàn và phí feed giá)

Tải chỉ dồn vào khoảng 5 giờ giao dịch. Hai instance API, hai WS gateway, một Redis nhỏ cho giá và pub/sub, Postgres HA từ ngày đầu vì đây là tiền.

Người dùngNhà đầu tưDịch vụWS gatewayDịch vụAPI Node.jsCacheRedisDatabasePostgres HAWorkerIngest giáBên ngoàiFeed giá sànDịch vụOrder gatewayBên ngoàiSàn HOSE/HNXWebSocketHTTPSsubscribetickpublish 250 mslệnh + giữ tiềnoutboxFIX

Ước lượng

  • Giờ giao dịch tính tròn 5 h = 18.000 s; lệnh: 10.000 × 2 / 18.000 ≈ 1,1 lệnh/s, đỉnh ATO/ATC ×10 ≈ 11 lệnh/s
  • Kết nối: 40% DAU mở app trong phiên = 4.000 WebSocket → ceil(4.000 / 20.000) = 1 → 2 gateway để dự phòng
  • Đẩy giá: 4.000 × 4 frame/s (gộp 250 ms) = 16.000 frame/s × 300 B = 4,8 MB/s ≈ 38 Mbps
  • REST: 10.000 × 50 req / 18.000 ≈ 28 req/s, đỉnh ×5 ≈ 139 req/s → ceil(139 / 600) = 1 → 2 instance
  • Lưu trữ: 20.000 lệnh × 3 bản ghi (lệnh, khớp, bút toán) × 0,5 KB = 30 MB/ngày × 250 ngày ≈ 7,5 GB/năm

Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · bài "Examples of Resource Estimation", mục "Number of servers required", "Bandwidth requirements" ; Quy tắc ước lượng của khoá học · /hoc/chon-server (500 req/s mỗi vCPU, chạy 60% CPU, Redis ×1,3; số frame/s, kết nối mỗi gateway và hạn mức phiên FIX là giả định của khoá học)

Bảng hạ tầng

Thành phầnChọnSố lượngCấu hìnhVì sao
APINode.js (NestJS), Docker22 vCPU · 4 GB RAM139 req/s lúc đỉnh chỉ cần 1 instance, chạy 2 để không gián đoạn trong phiên.
WS gatewayNode.js + ws22 vCPU · 4 GB RAMMỗi instance 2.000 kết nối và 8.000 frame/s; giả sử ≈ 10.000 frame/s mỗi vCPU khi frame đã serialize sẵn.
Ingest giáNode.js, nhận feed từ sàn/nhà cung cấp dữ liệu1 active + 1 standby2 vCPU · 4 GB RAMKhoảng 1.600 mã, vài nghìn tick/s là nhẹ; standby nhận feed song song để chuyển nhanh.
RedisRedis managed11 vCPU · 2 GBSnapshot giá, pub/sub giá và sự kiện lệnh (Redis in Action §3.6), idempotency key.
DatabasePostgreSQL managed (HA)1 primary + 1 standby2 vCPU · 8 GB · 100 GB SSD11 lệnh/s và 7,5 GB/năm là nhỏ; standby đồng bộ để không mất bút toán khi primary chết.
Order gatewayNode.js, phiên FIX tới sàn1 active + 1 passive2 vCPU · 4 GB RAMGiả sử sàn cho mỗi phiên 500 lệnh/s; 11 lệnh/s chỉ cần 1 phiên.
Thay đổi so với mức trước
  • Điểm xuất phát: API và WS gateway tách riêng vì tải và cách scale khác nhau
  • Postgres HA ngay từ đầu, giữ tiền bằng UPDATE có điều kiện
  • Giá đã gộp 250 ms ngay ở ingest nên gateway nhẹ
Nút thắt tiếp theo

Khi lên hàng trăm nghìn người mở app trong phiên, WS gateway bị giới hạn bởi CPU đẩy frame, và phiên ATC đưa hàng nghìn lệnh/s vào một Postgres nhỏ. Cần nhiều gateway, DB lớn hơn và nhiều phiên FIX.

Cấu hình và chi phí là ước lượng để học và trình bày trong phỏng vấn. Xem cách tính ở bài Chọn server.

So sánh nhanh ba mức

Thành phầnMức 1Mức 2Mức 3
Người dùng10.000 DAU (công ty chứng khoán nhỏ)1 triệu DAU10 triệu DAU (giữ ở 10 triệu vì cả thị trường chỉ vài triệu tài khoản hoạt động; tải dồn vào 5 giờ và vài phút ATO/ATC)
Chi phí≈ 400–1.000 USD/tháng (ước lượng, chưa tính phí kết nối sàn và phí feed giá)≈ 20.000–40.000 USD/tháng (ước lượng, egress đẩy giá chiếm phần lớn)≈ 200.000–400.000 USD/tháng (ước lượng, egress đẩy giá chiếm gần một nửa)
API
Node.js (NestJS), Docker
2 × 2 vCPU · 4 GB RAM
Node.js (NestJS), autoscale
2–24 (autoscale, scale sẵn trước ATO) × 2 vCPU · 4 GB RAM
Node.js trên Kubernetes, 3 AZ
232 pod (HPA, scale theo lịch trước ATO/ATC) × 2 vCPU · 4 GB mỗi pod
WS gateway
Node.js + ws
2 × 2 vCPU · 4 GB RAM
Node.js + ws, autoscale
40 × 4 vCPU · 8 GB RAM
Node.js trên Kubernetes
400 pod (chia 2 region) × 4 vCPU · 8 GB mỗi pod
Ingest giá
Node.js, nhận feed từ sàn/nhà cung cấp dữ liệu
1 active + 1 standby × 2 vCPU · 4 GB RAM
Chưa cầnChưa cần
Redis
Redis managed
1 × 1 vCPU · 2 GB
ElastiCache / Memorystore
1 primary + 1 replica × 2 vCPU · 4 GB
Chưa cần
Database
PostgreSQL managed (HA)
1 primary + 1 standby × 2 vCPU · 8 GB · 100 GB SSD
PostgreSQL managed (HA), partition theo ngày
1 primary + 1 standby + 1 read replica × 16 vCPU · 64 GB · 1 TB SSD
PostgreSQL shard theo account_id (Citus hoặc tự chia)
16 shard × (1 primary + 1 standby đồng bộ) × 8 vCPU · 32 GB · 500 GB SSD mỗi node
Order gateway
Node.js, phiên FIX tới sàn
1 active + 1 passive × 2 vCPU · 4 GB RAM
Node.js, phiên FIX chia theo hash(account)
3 phiên, mỗi phiên active + passive × 2 vCPU · 4 GB RAM
Node.js/Go, đặt gần sàn
24 phiên FIX, mỗi phiên active + passive × 2 vCPU · 4 GB mỗi pod
Load balancerChưa cần
ALB / Cloud Load Balancing
1 × Managed, hỗ trợ WebSocket
Chưa cần
Queue + workerChưa cần
SQS / Pub/Sub + Node.js consumer
1 queue, 4 worker × 1 vCPU · 2 GB mỗi worker
Chưa cần
Global LBChưa cầnChưa cần
Cloudflare / Global Load Balancer, 2 region (HN, HCM)
1 × Định tuyến theo vị trí, có WAF
KafkaChưa cầnChưa cần
MSK / Confluent
3 broker (1 mỗi AZ) × Topic order-events 32 partition theo account_id, RF 3
Redis ClusterChưa cầnChưa cần
ElastiCache Redis Cluster
3 shard × (1 primary + 1 replica) × 2 vCPU · 8 GB mỗi node
Worker đối soátChưa cầnChưa cần
Node.js consumer trên Kubernetes
8 pod × 1 vCPU · 2 GB mỗi pod

Đào sâu

Mỗi câu hỏi đi từ cách đơn giản đến cách người phỏng vấn mong đợi ở cấp senior.

1) Đẩy giá real-time tới hàng trăm nghìn người đang mở app thế nào?

Cách chưa tốt
Client gọi REST lấy cả bảng giá mỗi giây

Mỗi người một request mỗi giây, đa số trả về dữ liệu không đổi. 400.000 người là 400.000 req/s chỉ để đọc giá.

Đánh đổi: Dễ làm nhưng tốn tài nguyên nhất và vẫn trễ tới 1 s.

Nguồn: Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "Introduction to Pub-sub", mục "Use cases of pub-sub" (Improved performance: push, không cần hỏi lại)

Cách tốt
WebSocket gateway, đẩy mọi tick của các mã client đang xem

Gateway subscribe pub/sub (Grokking ch.18) và gửi từng tick xuống client. Trễ thấp, không có request rỗng.

Đánh đổi: Lúc ATC một mã có thể nhảy hàng chục lần mỗi giây; gửi từng tick làm CPU gateway và mạng di động của client quá tải.

Nguồn: Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "Introduction to Pub-sub", mục "Use cases of pub-sub" ; Grokking Modern System Design · ch.36 WhatsApp ↗ · bài "Detailed Design of WhatsApp", mục "Connection with a WebSocket server"

Cách tốt nhất
Gộp tick theo mã (conflation) + frame theo client + seq và snapshot

Ingest giữ giá mới nhất mỗi mã, cứ 250 ms phát một lô các mã vừa đổi. Gateway ghép các mã một client đang xem thành một frame duy nhất, serialize một lần dùng chung. Mỗi mã có seq tăng dần; client thấy hụt seq thì lấy lại snapshot. Client chậm có buffer giới hạn, đầy thì bỏ frame cũ vì chỉ giá mới nhất có ý nghĩa.

Đánh đổi: Người dùng không thấy từng tick trung gian; với nhà đầu tư cá nhân điều này chấp nhận được vì lệnh vẫn khớp trên sàn theo giá thật.

Nguồn: Chưa có nguồn trong thư viện: conflation (gộp tick) và backpressure theo client không được sách nào mô tả; giữ lại vì là kỹ thuật cốt lõi của bảng giá ; Grokking Modern System Design · ch.18 Pub-sub ↗ · bài "Design of a Pub-sub System", mục "Broker" (broker phân phối tới subscriber)

2) Gửi lệnh lên sàn bên ngoài thế nào để không mất và không trùng?

Cách chưa tốt
Gọi sàn trước, lưu DB sau

API gửi lệnh lên sàn rồi mới ghi Order. Process chết giữa hai bước thì có lệnh sống trên sàn mà hệ thống không biết; client timeout bấm lại thì có thêm một lệnh nữa.

Đánh đổi: Khách mua gấp đôi khối lượng, công ty phải chịu phần chênh lệch.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging, mục "Using a database table as a message queue" (cập nhật DB và gửi message phải nguyên tử, tr. 97–98)

Cách tốt
Idempotency-Key + lưu Order trước + ClOrdID ổn định

Order được tạo PENDING_NEW trước khi gửi, unique theo (account, idempotency_key) nên bấm lại chỉ trả về lệnh cũ. Gửi lại lên sàn luôn dùng cùng ClOrdID = order_id để sàn nhận ra lệnh trùng.

Đánh đổi: Vẫn còn khoảng hở giữa lúc commit DB và lúc gửi; cần cơ chế chắc chắn mọi lệnh đã commit đều được gửi đúng một lần.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97), mục "Writing idempotent message handlers" (tạo order với ID do client cấp là idempotent, tr. 96) ; Building Microservices ↗ (.zip) · ch.11 Microservices at Scale · mục "Idempotency" (tr. 215)

Cách tốt nhất
Outbox + order gateway đơn luồng mỗi phiên FIX + state machine có điều kiện

Transaction tạo Order ghi luôn bản tin outbox (Microservices Patterns §3.3.7); gateway đọc outbox và gửi qua phiên FIX, mỗi phiên chạy active-passive với sequence number liên tục. Trạng thái chỉ đổi bằng UPDATE ... WHERE status IN (trạng thái hợp lệ) AND version = ?, ví dụ PENDING_NEW → NEW → PARTIALLY_FILLED → FILLED, hoặc → PENDING_CANCEL → CANCELED. Gửi xong mà không nhận phản hồi thì chuyển UNKNOWN và hỏi trạng thái lệnh từ sàn, không gửi lệnh mới.

Đánh đổi: Nhiều thành phần hơn và mỗi phiên FIX là một điểm tuần tự; số phiên bị giới hạn bởi hạn mức sàn cấp cho công ty.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (tr. 97–100), mục "Using a database table as a message queue" (transactional outbox) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.3.2 Countermeasures for handling the lack of isolation (semantic lock, *_PENDING) ; Chưa có nguồn trong thư viện: giao thức FIX và quy tắc thị trường chứng khoán (ATO/ATC, T+2, lô 100) không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản

3) Giữ trạng thái lệnh đồng bộ với sàn khi bản tin có thể đến trễ, trùng hoặc mất?

Cách chưa tốt
Tin hoàn toàn vào luồng execution report

Nhận bản tin nào áp bản tin đó. Mất kết nối vài giây là mất luôn các lần khớp trong khoảng đó; bản tin đến hai lần thì trừ tiền hai lần.

Đánh đổi: Số dư sai mà không ai phát hiện cho đến khi khách khiếu nại.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (khi lỗi, bản tin có thể bị giao nhiều lần hoặc sai thứ tự, tr. 95–96)

Cách tốt
Áp bản tin idempotent theo exec_id và cum_qty

Bảng executions có unique exec_id, bản tin trùng bị bỏ qua (Microservices Patterns §3.3.6). Chỉ cập nhật Order khi cum_qty mới lớn hơn giá trị đang lưu, nên bản tin đến sai thứ tự không làm lùi trạng thái.

Đánh đổi: Chặn được trùng và sai thứ tự nhưng vẫn không biết mình đã mất bản tin nào.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97), mục "Tracking messages and discarding duplicates" (lưu id đã xử lý, INSERT fail khi trùng, tr. 96–97)

Cách tốt nhất
Ba lớp đối soát: phát hiện hụt seq, quét lệnh mở định kỳ, đối chiếu cuối ngày

Phiên FIX đánh số mọi bản tin; thấy hụt seq thì yêu cầu sàn gửi lại đoạn thiếu. Mỗi 1 phút worker hỏi sàn trạng thái các lệnh đang mở quá lâu hoặc UNKNOWN. Cuối ngày đối chiếu toàn bộ lệnh và khớp với file kết quả từ sàn; chênh lệch nào cũng thành ticket và bút toán điều chỉnh có kiểm duyệt.

Đánh đổi: Tốn công xây quy trình và luôn cần người xử lý ngoại lệ; giữa các lần đối soát trạng thái có thể sai trong thời gian ngắn.

Nguồn: Chưa có nguồn trong thư viện: giao thức FIX và quy tắc thị trường chứng khoán (ATO/ATC, T+2, lô 100) không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản ; Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · bài "Design Considerations", mục "Task idempotency" ; Chưa có nguồn trong thư viện: quy trình đối soát với sàn không có trong sách

4) Làm sao sức mua không âm khi người dùng đặt nhiều lệnh cùng lúc từ nhiều thiết bị?

Cách chưa tốt
Đọc số dư, kiểm tra trong code, rồi ghi

Hai lệnh đến cùng lúc đều đọc thấy đủ tiền và đều được chấp nhận. Lỗi race kinh điển, rất dễ xảy ra vào phiên ATC.

Đánh đổi: Tài khoản âm tiền, công ty chứng khoán phải ứng trước.

Nguồn: Microservices Patterns ↗ (.zip) · ch.4 · §4.3.1 Overview of anomalies (lost updates, tr. 127–128)

Cách tốt
UPDATE có điều kiện để giữ tiền

UPDATE accounts SET reserved = reserved + :amt WHERE id = :id AND cash - reserved >= :amt, trong cùng transaction tạo Order. Câu lệnh nguyên tử nên chỉ lệnh nào còn đủ tiền mới qua.

Đánh đổi: Đúng và nhanh nhưng chỉ lưu con số cuối; khó giải thích vì sao số dư thay đổi và khó đối soát.

Nguồn: Grokking Modern System Design · ch.9 Databases ↗ · bài "Types of Databases", mục "Relational databases" › "Concurrency"

Cách tốt nhất
Sổ cái bút toán chỉ ghi thêm + số dư tổng hợp + shard theo tài khoản

Mọi thay đổi là một bút toán: giữ tiền khi đặt, nhả khi hủy, trừ theo giá khớp và hoàn phần chênh, phí, thuế; mỗi bút toán có idempotency key theo order_id hoặc exec_id. Bảng số dư cập nhật bằng UPDATE có điều kiện trong cùng transaction với bút toán. Toàn bộ dữ liệu một tài khoản nằm trên một shard nên mọi transaction là single-shard, không cần transaction phân tán. Cổ phiếu mua về tách cột chờ về T+2 để không bán được trước hạn.

Đánh đổi: Thêm bảng và phải đối soát tổng bút toán với số dư hằng ngày, nhưng audit được từng đồng.

Nguồn: Microservices Patterns ↗ (.zip) · ch.4 · §4.1.2 The trouble with distributed transactions (tr. 112–114) ; Grokking Modern System Design · ch.9 Databases ↗ · bài "Data Partitioning", mục "Horizontal sharding" ; Chưa có nguồn trong thư viện: giao thức FIX và quy tắc thị trường chứng khoán (ATO/ATC, T+2, lô 100) không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản

Trong phỏng vấn

Nên nói
  • “Hệ thống không khớp lệnh; phần khó là gửi lệnh tới sàn đúng một lần và giữ trạng thái khớp với sàn.”
  • “Tôi lưu Order và outbox trong cùng transaction với việc giữ tiền, gửi lên sàn với ClOrdID = order_id, và khi không chắc kết quả thì hỏi lại sàn thay vì gửi lại.”
  • “Giá được gộp 250 ms theo mã, mỗi client nhận một frame; CPU đẩy frame quyết định số gateway.”
  • “Tiền dùng sổ cái bút toán chỉ ghi thêm, shard theo tài khoản để mọi transaction nằm trên một shard.”
Bẫy hay gặp
  • Gọi sàn trước rồi mới lưu lệnh vào DB.
  • Kiểm tra số dư bằng đọc rồi ghi, để hai lệnh song song cùng lọt.
  • Áp execution report không idempotent, trừ tiền hai lần khi bản tin đến trùng.
  • Đẩy từng tick giá tới mọi client, không gộp và không xử lý client chậm.

Đọc thêm

Cập nhật realtime
Đẩy thay đổi tới client trong vài trăm mili giây: chọn giữa polling, long polling, SSE, WebSocket, rồi fan-out qua nhiều gateway, xử lý reconnect và backpressure.
Tranh chấp tài nguyên
Chống bán vượt và đặt trùng khi nhiều request cùng giành một tài nguyên: khóa bi quan, optimistic locking, UPDATE có điều kiện, distributed lock, hàng đợi theo key và giữ chỗ có hạn.
Quy trình nhiều bước
Giữ dữ liệu nhất quán khi một nghiệp vụ đi qua nhiều service: saga choreography và orchestration, hành động bù trừ, trạng thái PENDING, transactional outbox, idempotent consumer và workflow engine như Temporal.
PostgreSQL
PostgreSQL là lựa chọn mặc định an toàn cho phần lớn hệ thống: ACID, index phong phú, replication, partitioning và hệ extension mạnh. Bài này giải thích MVCC, các loại index, mức isolation, replication, connection pooling, bảng sizing theo cấu hình và dấu hiệu nên rời một node Postgres.
Sharding
Chia dữ liệu theo chiều dọc và chiều ngang, cách chọn shard key và tránh hotspot, so sánh range, hash và directory, resharding, query và transaction xuyên shard, khi nào chưa nên shard và một Postgres chịu được tới đâu.
Kafka
Kafka là log phân tán bền vững: topic chia partition, consumer đọc theo offset và có thể đọc lại. Bài này đi qua thứ tự theo partition, consumer group, replication với ISR và acks, giới hạn của exactly-once, cách chọn số partition và so sánh với SQS, Pub/Sub, RabbitMQ.

Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.