Tất cả bài viết

First-principles thinking: nghĩ từ gốc thay vì làm theo thói quen

Cách tách một vấn đề về những sự thật không thể chối cãi rồi dựng lại lời giải từ đó, kèm một ví dụ thực tế: phục vụ một triệu người dùng mà không cần một triệu thread.

Phần lớn quyết định mỗi ngày của chúng ta là suy luận bằng cách so sánh: người khác làm thế, dự án trước làm thế, nên mình cũng làm thế. Cách này nhanh và thường đủ tốt. Nhưng nó cũng chép luôn cả những giới hạn của người đi trước.

First-principles thinking (tư duy từ nguyên lý gốc) đi theo hướng ngược lại: bóc vấn đề ra cho tới khi chỉ còn những điều chắc chắn đúng, rồi từ đó dựng lại lời giải, như thể chưa ai từng giải bài này.

Nguyên lý gốc là gì

Ý tưởng này rất cũ. Aristotle gọi "nguyên lý đầu tiên" là nền tảng đầu tiên để từ đó hiểu một sự vật: điều không suy ra được từ điều gì khác. Trong toán học đó là tiên đề, trong vật lý là các định luật, còn trong công việc hằng ngày thì thường là:

  • Giới hạn vật lý: tốc độ ánh sáng, băng thông, bộ nhớ, số giờ trong ngày.
  • Dữ liệu đo được: con số thật, không phải cảm giác.
  • Nhu cầu thật của người dùng: điều họ cần làm được, không phải tính năng họ nói ra.

Những thứ còn lại, như "framework này là chuẩn", "website phải có slider", "dự án nào cũng cần microservices", là giả định. Giả định có thể đúng, nhưng phải được kiểm tra.

Một ví dụ nổi tiếng: giá pin

Elon Musk hay kể ví dụ này khi nói về first principles. Theo lời ông, vào khoảng năm 2012 pin cho xe điện có giá khoảng 600 USD cho mỗi kWh, và ai cũng mặc định là nó sẽ luôn đắt như vậy. Thay vì so với giá thị trường, ông đặt câu hỏi khác: pin được làm từ gì? Cobalt, nickel, nhôm, carbon, polymer và một vỏ thép. Nếu mua đúng những nguyên liệu đó trên sàn kim loại London thì tổng cộng chỉ khoảng 80 USD cho mỗi kWh.

Khoảng cách giữa 80 và 600 không phải là quy luật. Đó là chi phí của cách người ta đang làm pin, và vì vậy có thể làm khác đi.

Ba bước để áp dụng

  1. Viết ra giả định. Ghi rõ điều bạn đang coi là hiển nhiên. Thường thì bước này đã lộ ra vấn đề, vì "ai cũng làm vậy" không phải là một lý do.
  2. Tách cho đến khi chạm sự thật. Với mỗi giả định, hỏi "điều này có chắc đúng không, và vì sao?" cho tới khi gặp một giới hạn vật lý, một con số đo được hoặc một nhu cầu thật.
  3. Dựng lại từ những sự thật đó. Bỏ lời giải cũ sang một bên và hỏi: chỉ với những điều chắc chắn đúng này, cách đơn giản nhất để đạt mục tiêu là gì?

Ví dụ: phục vụ một triệu người dùng

Một yêu cầu rất hay gặp: "hệ thống phải chịu được một triệu người dùng". Phản xạ quen thuộc là nghĩ ngay tới lập trình đa luồng: một triệu người thì cần thật nhiều thread, càng nhiều càng tốt. Để cụ thể, giả sử backend viết bằng Python với FastAPI và chạy bằng uvicorn, một lựa chọn rất phổ biến. Hãy thử đi qua ba bước.

1. Viết ra giả định

  • Một triệu người dùng nghĩa là một triệu người truy cập cùng lúc.
  • Mỗi request cần một thread riêng, nên cần rất nhiều thread.
  • Thêm thread thì xử lý được nhiều hơn.

2. Tách cho đến khi chạm sự thật

Một triệu người dùng không phải một triệu request cùng lúc. "Một triệu người dùng" thường là số tài khoản; số người vào mỗi ngày còn ít hơn nhiều. Cứ cho là cả một triệu người đều vào mỗi ngày: một ngày có 86.400 giây, và mỗi người chỉ gửi vài chục request tới server ứng dụng (ảnh, CSS, JavaScript do CDN phục vụ). Số request đang được xử lý tại một thời điểm tuân theo định luật Little: số việc đang làm = số việc đến mỗi giây × thời gian mỗi việc.

Thời gian của một request gồm hai phần khác hẳn nhau. Phần dùng CPU (chạy code, dựng HTML) và phần chờ I/O: chờ dữ liệu đi qua mạng hay ổ đĩa, như chờ database, chờ một API khác. Việc tốn chủ yếu phần đầu gọi là CPU-bound, tốn chủ yếu phần sau gọi là I/O-bound. Chỉ phần đầu cần tới CPU; lúc chờ I/O thì CPU rảnh.

Số nhân CPU là giới hạn vật lý. Máy 4 nhân thì tại mỗi thời điểm chỉ chạy được 4 việc. Nếu 1.000 thread cùng cần CPU thì 996 thread vẫn phải xếp hàng, cộng thêm chi phí chuyển qua lại giữa chúng. Với Python còn thêm một sự thật nữa: bản CPython mặc định có GIL, chỉ cho một thread chạy code Python tại một thời điểm, nên thread không giúp gì cho phần dùng CPU: một process uvicorn chỉ dùng được khoảng một nhân, dù bên trong có bao nhiêu thread. (Từ Python 3.14 đã có bản không GIL được hỗ trợ chính thức, nhưng phải tự chọn dùng.)

Đặt các sự thật đó thành một phép tính:

import math

SECONDS_PER_DAY = 86_400


def capacity(daily_users: int, requests_per_user: int, peak_factor: float, cpu_ms: float, wait_ms: float) -> None:
    peak_rps = daily_users * requests_per_user / SECONDS_PER_DAY * peak_factor
    in_flight = peak_rps * (cpu_ms + wait_ms) / 1000  # Little's law: L = λ × W
    busy_cores = peak_rps * cpu_ms / 1000  # only CPU time needs a core; waiting doesn't
    print(f"peak requests per second : {peak_rps:6.0f}")
    print(f"requests in flight       : {in_flight:6.0f}")
    print(f"CPU cores kept busy      : {busy_cores:6.1f}")
    print(f"4-core servers at 70%    : {math.ceil(busy_cores / (4 * 0.7)):6d}")


# 1 million users a day, 30 requests each to the app server (static files come from a CDN),
# peak 10× the daily average to leave room for bursts,
# each request: 5 ms of CPU, 45 ms waiting on the database and other services
capacity(1_000_000, 30, 10, cpu_ms=5, wait_ms=45)
peak requests per second :   3472
requests in flight       :    174
CPU cores kept busy      :   17.4
4-core servers at 70%    :      7

Con số cần nhìn là 174, không phải một triệu: kể cả lúc cao điểm đã nhân rộng tay gấp 10 lần trung bình, cũng chỉ có khoảng 174 request đang được xử lý. Và giới hạn thật là 17 nhân CPU, con số mà không cách tổ chức thread nào thay đổi được. Mức 70% không phải ngẫu nhiên: khi CPU càng gần 100%, thời gian request phải xếp hàng tăng vọt, nên luôn chừa khoảng trống.

3. Dựng lại từ những sự thật đó

Phần chờ I/O quyết định có bao nhiêu request đang chờ cùng lúc, và từ đó chọn cách giữ chúng: mỗi request một thread, hay mỗi request một coroutine. Phần dùng CPU quyết định cần bao nhiêu process, bao nhiêu máy.

  • Phần chờ I/O: mỗi process uvicorn có một vòng lặp sự kiện (event loop) chạy trên một thread. Endpoint viết bằng async def là một coroutine chạy trong vòng lặp đó: khi gặp await (chờ database, chờ API), coroutine tạm dừng và nhường vòng lặp cho request khác, rồi được đánh thức khi dữ liệu về. Một request đang chờ vì vậy chỉ là một coroutine tốn vài KB bộ nhớ, không giữ thread nào (non-blocking I/O). Endpoint viết bằng def thường thì FastAPI đẩy sang một thread pool (mặc định 40 thread), và mỗi request giữ một thread suốt lúc chờ: thread đó bị chặn, không làm được gì khác (blocking I/O). Với 174 request đang xử lý chia cho 7 máy, khoảng 25 request mỗi máy, cách nào cũng đủ. Khác biệt chỉ lộ ra khi thời gian chờ dài: nếu mỗi request phải chờ một API bên ngoài 10 giây, như gọi một mô hình AI, cùng phép tính đó cho ra khoảng 35.000 request đang chờ. Thread pool 40 thread sẽ nghẽn ngay, còn vòng lặp async vẫn giữ được, miễn là trong async def không gọi thư viện đồng bộ nào (như requests hay driver database đồng bộ), vì một lời gọi như vậy chặn cả vòng lặp.
  • Phần dùng CPU: một process chỉ dùng được một nhân, nên số process phải bằng số nhân. Có hai cách: chạy uvicorn --workers 4 trên mỗi máy 4 nhân (4 process, mỗi process một vòng lặp riêng, cùng nhận kết nối trên một cổng), hoặc mỗi pod chạy một process và xin 1 nhân, rồi tăng số pod. Muốn chịu tải nhiều hơn thì thêm process, không thêm thread. Muốn nhanh hơn thì giảm thời gian CPU của mỗi request (cache, bớt việc thừa). Và đừng làm việc nặng CPU như thu nhỏ ảnh ngay trong async def: trong lúc đó vòng lặp đứng yên, mọi request khác của process đó phải chờ. (Chuyện gì xảy ra bên dưới, từ GIL tới kernel, có trong bài Python chạy đồng thời thế nào.)
  • Đo trước khi tin: các con số 30 request, 5 ms, 45 ms ở trên là giả định. Hãy thay bằng số đo thật từ log của bạn. Nếu một request thật ra tốn 50 ms CPU thì cần gấp mười lần số máy, và đó mới là chỗ cần tối ưu.

Mở rộng theo chiều ngang, và chỉ ở chỗ thật sự cần CPU. Vì giới hạn là số nhân, cách tăng sức chịu tải là thêm máy chạy song song phía sau một load balancer: 7 pod 4 nhân, cộng ít nhất một pod dự phòng để một pod hỏng không làm cả hệ thống quá tải. Muốn vậy thì app phải stateless: phiên đăng nhập, giỏ hàng, file tải lên nằm ở database, Redis hay object storage, không nằm trong bộ nhớ của một process, để request nào cũng chạy được trên process nào. Điều này đúng ngay cả khi chỉ có 4 worker trên một máy, vì các process uvicorn không chia sẻ bộ nhớ với nhau.

Nhưng "5 ms CPU trung bình" cũng là một giả định cần tách tiếp. Đo theo từng loại request, bạn hay gặp kiểu phân bố như thế này: 90% request là xem trang, mỗi cái 2 ms CPU; 10% là tải ảnh lên và thu nhỏ ảnh, mỗi cái 32 ms. Trung bình vẫn là 5 ms, nhưng:

Loại request Tỉ lệ CPU mỗi request Nhân CPU lúc cao điểm Pod 4 nhân (70%)
Xem trang 90% 2 ms 6,3 3
Xử lý ảnh 10% 32 ms 11,1 4

10% request ăn 64% CPU. Nếu tất cả nằm chung một service, mỗi lần người dùng đồng loạt tải ảnh lên thì trang web của mọi người đều chậm theo, và mỗi lần mở rộng bạn phải nhân bản cả app chỉ vì một chức năng. Tách phần xử lý ảnh ra thành một service riêng (worker nhận việc từ một hàng đợi) thì:

  • Mỗi service mở rộng theo tải của chính nó: pod web theo lượng request, pod worker theo độ dài hàng đợi.
  • Đỉnh tải của phần nặng không làm chậm phần nhẹ.
  • Việc không cần xong ngay thì xếp hàng xử lý dần. Ảnh thu nhỏ xong sau vài giây không sao, nên các đợt dồn dập ngắn chỉ làm hàng đợi dài ra một lúc. Worker có thể tính theo tải trung bình của giờ cao điểm thay vì theo đỉnh từng giây, tức là ít pod hơn con số 4 ở bảng trên.

Nút thắt tiếp theo là database. Thêm pod ứng dụng không làm database nhanh hơn, và mỗi pod mới còn mở thêm kết nối tới nó. Phép tính như trên, áp cho số truy vấn mỗi giây và thời gian mỗi truy vấn, sẽ cho biết database cần gì: chỉ mục tốt hơn, cache, hay bản sao chỉ đọc.

Đừng tách ngược lại: chia app thành chục service nhỏ "cho chuẩn microservices" khi chưa có số đo nào cho thấy phần nào nặng thì chỉ thêm độ trễ mạng và việc vận hành.

Bài toán chỉ thật sự khác khi một triệu người giữ kết nối mở cùng lúc, như ứng dụng chat dùng WebSocket. Khi đó số việc đang làm đúng là một triệu, và sự thật quyết định là chi phí của mỗi kết nối. Một thread cho mỗi kết nối đụng giới hạn của hệ điều hành từ rất sớm: mỗi thread cần một vùng nhớ stack riêng, mà Linux mặc định chỉ cho mỗi process khoảng 65.000 vùng nhớ. Còn với mô hình nhẹ hơn thread, đội Phoenix (Elixir) năm 2015 đã giữ 2 triệu kết nối WebSocket trên một máy 40 nhân, 128 GB RAM. Cũng là ba bước đó, nhưng dữ kiện khác thì kết luận khác.

Những câu hỏi nên tự hỏi

  • Mình đang tin điều gì mà chưa kiểm chứng?
  • Nếu bắt đầu lại từ con số 0, mình có làm y như vậy không?
  • Giới hạn này là giới hạn vật lý, hay chỉ là cách người ta vẫn làm?
  • Con số thật là bao nhiêu? Mình đã đo chưa?
  • Người dùng cần làm được điều gì, chứ không phải họ nói họ muốn gì?

Khi nào không nên dùng

First-principles thinking tốn thời gian và công sức. Không cần nghĩ từ gốc cho mọi việc: đặt tên biến, chọn thư viện ngày tháng hay cấu hình linter thì cứ theo cách phổ biến là đủ. Hãy dành nó cho những quyết định đắt và khó đổi: kiến trúc hệ thống, mô hình giá, cấu trúc dữ liệu cốt lõi, hoặc những vấn đề mà mọi cách quen thuộc đều đã thất bại.

Cũng đừng nhầm "nghĩ từ gốc" với "bỏ qua kinh nghiệm". Kinh nghiệm của người khác là dữ liệu quý. Mục tiêu là hiểu vì sao họ làm vậy, để biết điều đó còn đúng với bài toán của mình hay không.

Tóm lại

So sánh giúp bạn đi nhanh trên con đường có sẵn. Nghĩ từ gốc giúp bạn thấy con đường đó có thật sự cần thiết không. Lần tới khi nghe "trước giờ vẫn làm vậy", hãy thử hỏi thêm một câu: "Vì sao?", và hỏi tiếp cho tới khi chạm vào điều chắc chắn đúng.