Khi bạn điều hành một công ty môi giới ô tô tại Azerbaijan và nhập khẩu xe thanh lý từ Hoa Kỳ, các vấn đề về phần mềm của bạn sẽ rất khác so với một startup ở Thung lũng Silicon. Bạn không tối ưu hóa cho hàng triệu người dùng đồng thời. Bạn tối ưu hóa cho sự rõ ràng, thời gian hoạt động (uptime) và khả năng tự sửa lỗi vào lúc nửa đêm trong khi đang phối hợp với một nhà đấu giá cách đó mười hai múi giờ. Đó chính xác là tình huống tôi gặp phải khi xây dựng AutoMakler. Nền tảng này xử lý mọi thứ, từ việc cào dữ liệu đấu giá trực tiếp và tra cứu Carfax đến ước tính giao hàng và xử lý thanh toán. Đây là một hệ thống thực tế phục vụ khách hàng thực tế, và nó chạy trên một bộ công nghệ (stack) mà hầu hết các nhà phát triển sẽ gọi là đơn giản đến mức cực đoan.

Bộ công nghệ mà chẳng ai muốn quảng bá

Không có React. Không có Vue. Không có Redis, không có Celery, và cũng không có máy chủ WebSocket. Backend là FastAPI với Python thuần túy. Cơ sở dữ liệu là PostgreSQL. Frontend là HTML được render từ phía máy chủ (server-rendered) sử dụng các template Jinja2, Bootstrap và một chút vanilla JavaScript. Để cào dữ liệu, tôi sử dụng Playwright. Mọi thứ chạy như một tiến trình Python duy nhất để phục vụ HTML trực tiếp.

Không có bước build. Không có các thư mục node_modules để kiểm tra, không có trình biên dịch (transpilers) để cấu hình, và không có sự thay đổi chóng mặt của các framework frontend để phải chạy theo. Khi triển khai, tôi chỉ di chuyển các tệp Python và các template, chứ không phải điều phối một quy trình của các trình đóng gói (bundlers). Sự đơn giản đó không phải là một sự thỏa hiệp. Đó chính là mục tiêu cốt lõi.

Cách xếp hàng công việc mà không cần Message Broker

Việc cào dữ liệu từ một cuộc đấu giá ô tô trực tiếp không thể diễn ra đồng bộ. Một lần cào có thể mất vài giây khi Playwright tải trang, thực thi JavaScript và trích xuất dữ liệu. Việc chặn người dùng trong khi quá trình này diễn ra là không thể chấp nhận được. Quy trình tiêu chuẩn là cài đặt Redis, cấu hình Celery và khởi tạo một worker pool. Tôi đã bỏ qua tất cả những thứ đó.

Thay vào đó, AutoMakler sử dụng chính Postgres làm hàng đợi công việc (job queue). Khi người dùng kích hoạt một lệnh cào dữ liệu, ứng dụng sẽ ghi một dòng mới vào bảng tasks với trạng thái là pending. Một tác vụ nền asyncio sẽ lấy dòng đó và khởi chạy trình duyệt để cào dữ liệu. Trong khi đó, trình duyệt sẽ thực hiện polling một endpoint nhẹ mỗi ba giây để kiểm tra trạng thái. Khi dòng đó được cập nhật thành completed, trang web sẽ làm mới và hiển thị kết quả.

Mô hình này hoạt động hiệu quả vì khoảng thời gian polling đủ ngắn để tạo cảm giác phản hồi nhanh nhưng cũng đủ dài để tránh làm quá tải máy chủ. Ba giây là một khoảng thời gian dài đối với máy tính nhưng hầu như không thể nhận ra đối với một con người đang chờ đợi một trang web đấu giá bên ngoài. Cơ sở dữ liệu xử lý tính đồng thời (concurrency) một cách tự nhiên, và vì các công việc chỉ là các dòng trong Postgres, tôi có thể kiểm tra hàng đợi bằng một truy vấn SQL đơn giản thay vì phải lục lọi trong log của Celery hay các key của Redis.

Giữ cho máy chủ hoạt động mà không cần Worker Pool

Tự động hóa trình duyệt rất ngốn bộ nhớ. Nếu khởi chạy quá nhiều instance Playwright cùng một lúc, máy chủ của bạn sẽ sụp đổ. Cách khắc phục thông thường là sử dụng một worker pool được quản lý với các giới hạn về tính đồng thời, thường được hỗ trợ bởi sự kết hợp giữa Redis và Celery. Tôi chỉ sử dụng một dòng mã Python: một asyncio.Semaphore.

Semaphore này giới hạn số lượng instance trình duyệt có thể chạy đồng thời. Khi có một yêu cầu cào dữ liệu mới, nó sẽ chiếm ngay một slot hoặc đợi cho đến khi có slot trống. Tất cả những điều này đều diễn ra bên trong cùng một tiến trình. Không có bộ điều phối bên ngoài nào có thể gặp lỗi, không có tiến trình worker nào chết âm thầm, và không có cơ sở hạ tầng bổ sung nào cần phải giám sát. Bộ nhớ của tôi luôn ở mức có thể dự đoán được, và mã nguồn bảo vệ máy chủ nằm ngay cạnh mã nguồn sử dụng nó, chứ không bị ẩn trong một file cấu hình triển khai (deployment manifest).

Điều hướng dòng tiền chỉ với một URL callback

Việc xử lý thanh toán đã đặt ra một ràng buộc mà tôi không thể thay đổi. Cổng thanh toán của tôi cho phép chính xác một URL callback cho mỗi tài khoản merchant, nhưng tôi cần xử lý giao dịch cho hai dự án riêng biệt thông qua cùng một tài khoản đó. Việc xây dựng một hồ sơ merchant thứ hai sẽ đồng nghĩa với việc phát sinh thêm phí, thêm các thủ tục tuân thủ và thêm các giấy tờ hành chính mà một công ty môi giới nhỏ không có thời gian để thực hiện.

Giải pháp là mã hóa tên dự án trực tiếp vào chuỗi ID đơn hàng trước khi gửi khách hàng đến cổng thanh toán. Khi callback gửi đến máy chủ của tôi, AutoMakler sẽ giải mã ID đó, xác định thanh toán thuộc về dự án nào và điều hướng thông báo đến trình xử lý nội bộ chính xác. Logic hiện tại vẫn được giữ nguyên. Đây là thiết kế mang tính bổ sung (additive design): tôi không viết lại luồng thanh toán, tôi chỉ làm cho mã định danh mang thêm một chút ngữ cảnh. Đó là kiểu "hack" trông có vẻ hiển nhiên khi nhìn lại nhưng đã tiết kiệm được hàng giờ đồng hồ thực hiện những màn "nhào lộn" về mặt kiến trúc.

Chat hoạt động mà không cần WebSockets

Customer support chat is usually where engineers cave and add WebSockets. I needed in-app messaging, but I also needed to keep the infrastructure footprint tiny. So I reused the same polling strategy that powers the auction scrapes.

Messages are stored in Postgres. When a user sends a message, it writes to the table. The client polls for updates, and the UI reflects new messages and read receipts in near real-time. To keep this fast even as the conversation table grows, I added a Postgres partial index that only covers unread messages for active conversations. The database does not waste cycles scanning old history, and the query planner can satisfy most chat lookups with a tight index range scan.

For a support chat where a few seconds of latency is acceptable, this is perfectly adequate. The users get the feedback they need, and I never had to debug a stale WebSocket connection or manage a separate socket server.

The Honest Downsides

This architecture makes real trade-offs, and pretending otherwise would be dishonest. Polling is chatty. Every three seconds, every active client hits the server. The bandwidth and query load are higher than a persistent socket connection would demand. If the Python process restarts, any in-flight background task dies immediately because there is no external worker to pick it back up. I accept this because the tasks are small and the cost of a retry is low. A failed browser scrape can simply be re-triggered by the user.

There is also a ceiling to this approach. If AutoMakler ever needs to serve thousands of simultaneous scrapes, the single-process model with polling will strain. But that is not the business I am in. I need reliability for dozens of concurrent users, not