Case study Số 01 · 2026 · Học thuật · Kiến trúc hệ thống

Hệ thống quản lý kho

StockFlow Cloud

Quản lý kho cho chuỗi cửa hàng linh kiện điện tử nhiều chi nhánh. NestJS API chạy trong Lambda sau API Gateway; các job nặng như import Excel, báo cáo, đối soát chạy bằng Lambda worker do Step Functions và SQS điều phối. Không VPC, NAT, load balancer hay container chạy thường trực, nên tháng nào không ai dùng thì gần như không tốn đồng nào.

Plate I. Kho hàng dưới những đám mây
Plate I. Kho hàng dưới những đám mây
Vai trò
Backend Developer
Thời gian
4 tháng · 2026
Đội ngũ
2 người
Nền tảng
Web · Cloud · API
Stack
NestJS · Lambda · Step Functions · Neon Postgres
01

Bài toán đặt ra

Cần giải quyết điều gì, và vì sao nó khó.

Tồn kho của nhiều chi nhánh phải khớp nhau theo thời gian thực, file Excel nhập kho rất lớn phải vào được mà không làm nghẽn hệ thống, và tất cả phải chạy với ngân sách AWS bằng 0.

Mục tiêu của StockFlow Cloud là quản lý tồn kho, luồng chuyển kho giữa các chi nhánh theo thời gian thực, và tự động hóa toàn bộ quy trình nhập kho bằng Excel.

Cách làm ngây thơ là dồn hết vào một API: nhận file, parse ngay trong request, ghi thẳng vào database. Với vài trăm dòng thì ổn. Với hàng chục nghìn dòng, API bị chiếm chỗ, request bị timeout, và chỉ cần mạng chập chờn giữa chừng là tồn kho bị cộng hai lần.

Bản đầu là kiến trúc lai: NestJS trên ECS Fargate sau ALB, Aurora trong VPC private. Aurora đã scale về 0, nhưng nằm trong VPC kéo theo NAT Gateway, ALB và Fargate, cái nào cũng tính tiền theo giờ. Hệ thống chỉ dựng lên được một lúc rồi phải destroy. Vì vậy nó được làm lại thành full serverless (ADR-001): Neon Postgres qua TLS, Lambda ngoài VPC, và chính API cũng chạy trên Lambda.

02

Bên trong hệ thống

Vẽ đúng theo hệ thống đã triển khai. Số trên bản vẽ ứng với ghi chú bên dưới.

Bản vẽ đọc từ trái sang phải. Traffic hằng ngày đi đường trên: CloudFront phát web tĩnh, API Gateway chuyển request tới Lambda chạy NestJS. File nhập kho đi đường dưới: trình duyệt tải thẳng lên S3, một event khởi động state machine, nên API không bao giờ phải chạm vào file.

↑ SYNC · REQUEST / RESPONSE↓ ASYNC · EVENT-DRIVENTrình duyệtNext.js clientCloudFrontCDN · domain + ACMS3 · web tĩnhbucket privateAPI GatewayHTTP API · throttlingLambda · apiNestJS APICognitouser pool · 3 vai tròPusherChannelsNeon Postgresserverless · TLSS3 · ingestionfile Excel tải lênEventBridgeobject createdStep Functionsstate machineLambda workersValidate · Parse · WriteNeon poolerPgBouncer endpointSQS · DLQreplay / redriveHTTPSRESTverify JWTđọc / ghipush · WebSocketpresigned PUT1 conn / LambdaSendTaskSuccess1234567
Tờ1 / 2Hệ thống quản lý kho StockFlow CloudHình 1. Full serverless, toàn cảnh hệ thốngNgười vẽV. D. Anh · 2026Tỉ lệkhông theo tỉ lệ

↔ Kéo ngang để xem hết bản vẽ

  1. 1

    Web tĩnh ở biên

    Next.js xuất tĩnh vào S3 private, phát qua CloudFront với tên miền riêng và chứng chỉ ACM.

  2. 2

    API trên Lambda

    NestJS chạy trong Lambda (Node 22, arm64) sau API Gateway HTTP API, giới hạn 20 rps để chặn "denial of wallet". Cognito lo đăng nhập và ba vai trò.

  3. 3

    Tải file vòng qua API

    API chỉ ký một presigned URL. Trình duyệt đẩy file Excel thẳng vào bucket nhập liệu.

  4. 4

    Event-driven

    Event 'object created' của S3 qua EventBridge khởi chạy một execution Step Functions cho job nhập đó.

  5. 5

    Worker và database qua pooler

    Các Lambda Validator, Parser, Writer tới Neon Postgres qua endpoint PgBouncer, mỗi container một connection. URL nằm trong SSM Parameter Store.

  6. 6

    Lỗi có chỗ để về

    Execution lỗi và message báo cáo hết retry rơi vào SQS và DLQ. Màn recovery cho admin replay, discard hoặc redrive, bắt buộc ghi lý do và có audit log.

  7. 7

    Cập nhật trực tiếp

    Pusher Channels đẩy tiến độ job, yêu cầu chuyển kho mới và cảnh báo sắp hết hàng tới mọi màn hình đang mở.

File vào S3ValidateFileValidator Lambdavalid?ParseAndStageParser LambdaHaltForApprovalwaitForTaskTokenCommitInventoryWriter · batch 500COMPLETEDtrueresumeThủ khoxem bản xem trướcSendTaskSuccesslỗi hệ thốngfalselỗi parselỗi ghi → rollbackMarkJobFailedFail Handler LambdaFAILED
Tờ2 / 2Hệ thống quản lý kho StockFlow CloudHình 2. State machine nhập kho có bước con người duyệtNgười vẽV. D. Anh · 2026Tỉ lệkhông theo tỉ lệ

Pipeline nhập kho, từng bước

  1. 01

    Trigger

    S3 · EventBridge

    File vào S3 qua presigned URL. Event S3 khởi chạy state machine qua EventBridge, không tạo tải lên API chính.

  2. 02

    Validator

    Lambda

    Kiểm tra MIME và tiêu đề cột có khớp template trước khi đọc dòng nào.

  3. 03

    Parser

    Lambda

    Stream-parse file để memory không phình, kiểm từng dòng, ghi vào bảng tạm import_job_rows kèm idempotency key duy nhất.

  4. 04

    HaltForUserApproval

    waitForTaskToken

    Lưu task token theo job rồi tạm dừng. Trong lúc chờ không có gì chạy và không tốn tiền theo giây.

    ❚❚ Chờ con người
  5. 05

    Confirm

    Thủ kho · API

    Thủ kho xem (có phân trang) dòng hợp lệ, dòng lỗi và tác động tồn kho dự kiến rồi xác nhận. API gọi SendTaskSuccess.

  6. 06

    Writer

    Lambda

    Chạy tiếp và ghi theo batch 500 dòng, mỗi batch một transaction. Lỗi giữa chừng thì batch đó rollback.

Stack theo từng lớp

Backend & APIlõi nghiệp vụ trên Lambda
NestJS 10TypeScriptPrisma ORMREST APIserverless-express
Cloud · AWShạ tầng & điều phối
LambdaAPI GatewayStep FunctionsEventBridgeS3SQS (DLQ)SESCognitoCloudFrontSSM
Dữ liệu & realtimelưu trữ & đồng bộ
Neon PostgresPgBouncerPusher Channels
Quan sátkhông chạy server nào
CloudWatchX-RayLambda PowertoolsEMF metrics
Build, IaC & CI/CDđóng gói & deploy
TerraformGitHub ActionsOIDCesbuildTurborepo
03

Hệ thống làm được gì

Sáu khả năng mà kiến trúc phía trên cho phép.

Số 01

Tồn kho đa chi nhánh thời gian thực

Tách ba con số: tồn vật lý, tạm giữ và sẵn sàng bán. Cảnh báo sắp hết hàng theo ngưỡng từng chi nhánh, tính trên phần sẵn sàng bán.

Số 02

Nhập Excel bất đồng bộ

Tải thẳng lên S3, đọc dạng stream, có màn xem trước để duyệt rồi mới ghi kho.

Số 03

Chuyển kho atomic

Tạo yêu cầu là giữ hàng ngay; duyệt thì một transaction chuyển hàng từ bên gửi sang bên nhận, mọi biến động ghi vào ledger.

Số 04

Đối soát hằng đêm

Cron Lambda so tồn kho hiện tại với tổng biến động trong ledger, lệch là tự mở issue.

Số 05

Màn recovery

EventBridge đưa execution Step Functions lỗi vào recovery queue. Admin replay hoặc discard với lý do bắt buộc, giới hạn số lần replay và audit log; row đã commit không bao giờ bị ghi lại.

Số 06

Async report & RBAC

Báo cáo lớn đi SQS → Lambda → S3 → link tải, có partial batch failure và DLQ thật. Cognito với ba vai trò ADMIN, STORE_MANAGER, WAREHOUSE, kèm luồng quên mật khẩu.

Số 07

Quan sát không cần server

Log JSON có correlation ID từ API tới worker, trace X-Ray, sáu metric nghiệp vụ qua EMF, một dashboard CloudWatch và chín alarm gửi email qua SNS.

Số 08

Email vận hành mỗi sáng

07:00 mỗi sáng, EventBridge Scheduler gọi một Lambda tổng hợp traffic, lỗi, độ trễ, DLQ, số import và chi phí AWS tháng này, gửi qua SES với tiêu đề Ổn / Cần xem / Có sự cố.

Số 09

Push là deploy

Push lên main chạy kiểm tra chất lượng, rồi GitHub Actions nhận role AWS qua OIDC (không có access key), migrate Neon, apply Terraform có chốt chặn xoá resource stateful, và smoke test commit SHA mới.

04

Thách thức & cách giải

Mỗi vấn đề, cách tiếp cận, và con số cho thấy nó hiệu quả.

4.1
0record trùng khi writer retry

Retry mà không cộng kho hai lần

Vấn đề

Writer lỗi mạng giữa chừng thì Step Functions retry và các dòng cũ được giao lại. Không có guard, những dòng đã ghi sẽ bị ghi lần nữa và tồn kho tăng gấp đôi.

Cách giải

Mỗi dòng staging có idempotency key duy nhất ghép từ job và số dòng. Trước khi cộng kho, writer claim dòng bằng một conditional update; lần chạy trùng claim được 0 dòng nên không cộng gì.

TypeScript// key = `${importJobId}:${rowNumber}` (unique)
const claimed = await tx.importJobRow.updateMany({
  where: { id: row.id, validationStatus: "VALID" },
  data: { validationStatus: "COMMITTED" },
});
if (claimed.count === 0) continue; // retry → skip
4.2

Hai chi nhánh, một mặt hàng, cùng một giây

Vấn đề

Hai yêu cầu chuyển kho trên cùng một linh kiện có thể cùng đọc một số lượng khả dụng rồi cùng giữ hàng, giữ cả phần hàng không có thật.

Cách giải

Bước kiểm và bước giữ hàng là một câu UPDATE atomic. PostgreSQL khoá row; request thứ hai nhận 0 dòng bị ảnh hưởng khi không còn đủ hàng và transaction rollback. Các mặt hàng được khoá theo thứ tự cố định để tránh deadlock.

SQLUPDATE inventory
SET reserved_quantity = reserved_quantity + $qty,
    version = version + 1
WHERE branch_id = $branch
  AND component_id = $component
  AND quantity - reserved_quantity >= $qty;
-- affected_rows = 0 → not enough stock, rollback
4.3
1connection mỗi Lambda container

Lambda scale-out và connection pool

Vấn đề

Lambda scale theo lượng việc đồng thời. Một trăm container, mỗi cái mở một connection pool, sẽ làm cạn Postgres từ lâu trước khi CPU thành bottleneck.

Cách giải

Traffic lúc chạy đi qua endpoint pooled (PgBouncer) của Neon và mỗi container chỉ giữ một connection; migrate dùng endpoint direct. URL được đọc từ SSM lúc khởi động, xong mới tạo PrismaClient.

env# SSM /stockflow/database-url (SecureString)
DATABASE_URL=postgres://…-pooler.neon.tech/db
  ?sslmode=require&pgbouncer=true&connection_limit=1
# one Lambda container → at most 1 connection
4.4
0resource tính tiền theo giờ

Ngân sách 0 đồng mà vẫn chạy thường trực

Vấn đề

Bản lai trả tiền theo giờ cho NAT Gateway, ALB và Fargate dù không có ai dùng, chỉ vì Aurora nằm trong VPC. Tắt chúng đi thì không còn demo.

Cách giải

Chuyển database sang Neon qua TLS, đưa mọi Lambda ra khỏi VPC, và chạy NestJS trên Lambda sau API Gateway. Chi phí giờ đi theo request; throttling ở API Gateway và budget alert $1 / $5 chặn rủi ro.

4.5
0worker chạy trong lúc chờ duyệt

Chờ con người duyệt mà không tốn tiền chờ

Vấn đề

Thủ kho có thể duyệt sau vài phút hoặc vài giờ. Cho Lambda polling hay giữ request mở đều tốn tiền và vỡ khi timeout.

Cách giải

State machine dừng ở waitForTaskToken. Token được lưu cùng job; nút Xác nhận chỉ gọi SendTaskSuccess và execution chạy tiếp đúng chỗ đã dừng.

Kết quả

0

Record trùng khi retry: mỗi dòng staging có idempotency key riêng và được claim trước khi cộng kho

500 dòng

Mỗi batch ghi kho gói trong một DB transaction: lỗi giữa chừng là rollback cả batch

0

Resource tính tiền theo giờ: không VPC, NAT, ALB hay container, khi rảnh chi phí gần như bằng 0

05

Bài học rút ra

  1. I

    Tách thứ chậm khỏi thứ người dùng phải chờ

    API trả lời nhanh vì nó không bao giờ khuân việc nặng. Chia hệ thống theo latency thay vì theo tính năng làm cả hai nửa đơn giản hơn.

  2. II

    Thiết kế cho lần retry, không cho happy path

    Trên cloud, at-least-once delivery là mặc định. Idempotency key tốn một cột và một lần tra; sửa tồn kho bị cộng đôi tốn cả cuối tuần.

  3. III

    Mạng mới là thứ quyết định hoá đơn

    Compute vốn đã scale về 0; tiền nằm ở chỗ đặt database trong VPC, kéo theo NAT, load balancer và container. Đổi chỗ database ở là bỏ được khoảng 25 resource Terraform cùng hoá đơn theo giờ.

  4. IV

    Health check xanh vẫn có thể nói dối

    Sau khi chuyển, /api/health vẫn xanh trong khi chín worker không vào được database: PrismaClient được tạo trước khi SSM trả URL. Email báo cáo mỗi sáng phát hiện ra, không phải health check. Từ đó smoke test bắt buộc phải chạm database.

  5. V

    Đặt con người vào đúng chỗ

    Duyệt bản xem trước rồi mới ghi rẻ hơn ghi xong rồi sửa. Với waitForTaskToken, bước dừng nằm ngay trong quy trình chứ không phải một cờ mà code phải nhớ kiểm tra.