↓Chuyển đến nội dung chính
  1. Bài viết/

Bài 04: Mạng phi tỷ lệ — vì sao hệ thống cứ dồn dần về vài service ai cũng phụ thuộc

Hệ thống chia thành nhiều service nhỏ vẫn tự dồn về vài service mà gần như ai cũng gọi tới, dù không ai thiết kế như vậy. Bài này giải thích vì sao, vì sao một service như thế nghẽn là kéo cả hệ đứng theo, và ba cách sống chung với nó.

I. Tóm tắt #

Chia phần mềm thành nhiều service nhỏ đi kèm một lời hứa: tải trải đều, lỗi nằm yên tại chỗ. Mô hình mạng Barabási-Albert cho thấy điều ngược lại. Khi thành viên mới ưu tiên nối vào chỗ đông, đa số service chỉ có vài kết nối, trong khi một nhóm nhỏ ôm hàng trăm mối liên kết. Không ai chủ đích thiết kế ra nhóm đó; khi một service như vậy nghẽn, lỗi lan ngược về mọi bên đang chờ nó. Bài này đi qua phần toán, cơ chế lan ngược, và ba cách sống chung: gọi bất đồng bộ, chia cứng tài nguyên cho từng bên gọi, và ngắt sớm khi service đó chậm.

II. Lời hứa “chia nhỏ là tải trải đều” và đồ thị thật #

Sơ đồ kiến trúc trên slide có một điểm mạnh ít ai nhắc tới: ô nào cũng được vẽ to bằng nhau. Vẽ lại đúng ai gọi ai trong một hệ đã chạy vài năm, bức tranh thường khác: vài ô bị gần như mọi ô còn lại chĩa mũi tên vào. Tôi muốn nói về những ô đó.

Mỗi ô là một service: phần mềm chạy trên server, lo một việc như đăng nhập hay thanh toán. Mỗi mũi tên là một phụ thuộc: service này phải gọi service kia mới xong việc. Số mũi tên gắn vào một ô là số kết nối của ô đó.

Microservices là cách chia hệ thống thành nhiều service nhỏ gọi nhau qua mạng. Nó thay cho monolith: một khối phần mềm duy nhất chứa mọi chức năng. Lời hứa đi kèm: chia nhỏ thì tải trải đều, rủi ro trải theo.

Lời hứa đó ngầm hình dung một mạng ngẫu nhiên kiểu Erdős-Rényi: mỗi cặp service có cùng cơ hội nối nhau. Khi đó, service nào cũng có số kết nối quanh mức trung bình, và “service trung bình” là con số có nghĩa.

Hệ thống thật thường không như vậy: service mới không chọn chỗ nối ngẫu nhiên, nên một nhúm service lõi thành chỗ cả tổ chức dựa vào. Mỗi service lõi là một single point of failure: chỗ mà hễ nó hỏng thì cả hệ hỏng theo, đúng thứ microservices sinh ra để loại bỏ. Vì sao lại thế? Chỉ cần hai quy tắc.

III. Hai quy tắc đủ để sinh ra hub #

Mô hình Barabási-Albert (1999) dựng lại hiện tượng này bằng hai quy tắc:

  1. Mạng liên tục có thành viên mới. Hệ thống lúc nào cũng có service mới.
  2. Kết nối mới ưu tiên chỗ đã đông. Service mới nối vào đúng những service mà các service khác đang dùng.

Quy tắc thứ hai tên là preferential attachment: ai đang có càng nhiều kết nối thì càng dễ nhận thêm. Phiên bản đời thường của nó là người đồng nghiệp biết tuốt. Ai từng hỏi anh ấy lại giới thiệu cho người mới, nên anh càng được hỏi nhiều hơn, dù người khác chưa chắc kém hơn.

Giả sử một app giao đồ ăn có service lo đăng nhập, tên là auth. Mọi đội đều cần biết ai dùng app nên đều gọi auth. Mỗi lần như vậy, auth lại thành lựa chọn hiển nhiên hơn cho đội kế tiếp. Không có cuộc họp nào chốt “biến auth thành single point of failure”. Chỉ có bốn mươi cuộc họp nhỏ, cuộc nào cũng chốt một điều hợp lý: dùng lại cái đã có.

Trong ngôn ngữ đồ thị, mỗi service là một node, và node có số kết nối vượt xa phần còn lại gọi là hub.

Cho hai quy tắc chạy đủ lâu, số kết nối phân bố theo power law1: càng nhiều kết nối càng hiếm, nhưng không hiếm tới mức biến mất, nên luôn có vài hub khổng lồ. Ở đây, gấp 10 lần số kết nối thì hiếm hơn khoảng 1.000 lần. Mạng như vậy gọi là mạng phi tỷ lệ: không có “cỡ điển hình” nào đại diện cho các node.

Hệ quả: trung bình không còn đại diện. Nếu mỗi service mới mang theo 2 phụ thuộc, mạng 200 service có trung bình 4 kết nối và service lớn nhất cỡ 30. Mạng 20.000 service vẫn trung bình 4, nhưng service lớn nhất lên vài trăm (mô phỏng ở Mục VI: 382). Tính số máy theo “service trung bình” vì vậy sai đúng ở chỗ nguy hiểm nhất: cái hub.

Service đến trước còn giữ lợi thế lâu dài: trong mô phỏng, service thứ 100 kết thúc với 28 kết nối, service thứ 10.000 chỉ có 3.

Đào sâu: từ hai quy tắc tới cái đuôi dài
$$ \Pi(k_i) = \frac{k_i}{\sum_j k_j} $$

Đọc bằng lời: cơ hội một kết nối mới rơi vào service $i$ bằng phần của service đó trong tổng số kết nối toàn mạng.

  • $k_i$: số kết nối hiện tại của service $i$.
  • $\sum_j k_j$: tổng số kết nối, mỗi kết nối tính cho cả hai đầu.

Thay số: 40 kết nối trong tổng 400 cho 10%; service mới có 2 kết nối chỉ có 0,5%.

$$ \frac{\partial k_i}{\partial t} = \frac{m\,k_i}{2mt} = \frac{k_i}{2t} \quad\Rightarrow\quad k_i(t) = m\sqrt{t / t_i} $$

Đọc bằng lời: tốc độ hút tỉ lệ với số kết nối đang có nhưng chia cho tuổi mạng, nên số kết nối tăng theo căn bậc hai của thời gian, không theo kiểu lãi kép; service cũ giữ nguyên tỉ lệ dẫn trước.

  • $m$: số kết nối mỗi service mới mang theo; sau $t$ bước, tổng số kết nối là khoảng $2mt$.
  • $t$: số bước, cũng là số service đã có. $t_i$: bước service $i$ vào mạng.

Thay số với $m = 2$, $t = 20.000$: service thứ 100 có $2\sqrt{200} \approx 28$ kết nối, service thứ 10.000 có $2\sqrt{2} \approx 3$, khớp mô phỏng.

$$ P(k) = \frac{2m(m+1)}{k(k+1)(k+2)} \approx \frac{2m^2}{k^3} $$

Đọc bằng lời: tỉ lệ service có đúng $k$ kết nối giảm theo lũy thừa bậc ba của $k$, tức power law với số mũ 3.

  • $P(k)$: tỉ lệ service có đúng $k$ kết nối.

Thay số với $m = 2$: $P(4) = 12/120 = 10\%$; mô phỏng ra 9,6%.

Service lớn nhất có cỡ $m\sqrt{N}$ kết nối với $N$ service: 28 với 200, 283 với 20.000 (mô phỏng cho trung vị 33 và 352). Phương sai, thước đo độ tản quanh trung bình, tăng mãi theo logarit của $N$: khoảng 17, 32, 45 với 200, 2.000, 20.000 service.

IV. Từ mô hình mạng sang hệ thống của bạn: bảng quy đổi #

Bảng dưới cần thêm một khái niệm: lời gọi đồng bộ. Đó là khi service A gọi service B rồi đứng chờ B trả lời mới làm tiếp, giống gọi điện và giữ máy.

Trong mô hình mạngỞ công tyTrong hệ thống phần mềm
NodeMột ngườiMột service hoặc một database
Kết nốiMột lần phải hỏi người khácMột lời gọi đồng bộ, hoặc một truy vấn database
Số kết nối trỏ vào nodeSố người đang chờ một người trả lờiSố service đang gọi đồng bộ tới service này
Preferential attachment“Hỏi anh ấy đi” được truyền taiĐội mới nối vào đúng service mà mọi đội đã nối
HubNgười đồng nghiệp biết tuốtGod service: mọi luồng nghiệp vụ đều đi qua, mọi sự cố lớn đều ghé qua

Tuổi đời không phải lý do duy nhất. Mô hình Bianconi-Barabási (2001) thêm fitness: mức hữu dụng riêng của mỗi node, nhân vào cơ hội được chọn. Nhờ vậy, auth ra đời sau vẫn hút phụ thuộc nhanh hơn một service báo cáo ra đời trước, vì ai cũng cần đăng nhập.

Đó là lý do tách một God service thường thất bại. Tách code thì vài sprint là xong; tách lý do mọi người cần nó thì khó hơn nhiều, vì tách xong, ai cũng vẫn cần đăng nhập.

Service mới chưa ai gọi thì làm sao được gọi #

Mô hình Barabási-Albert gốc không phân biệt chiều kết nối, còn phụ thuộc phần mềm có chiều: A gọi B không có nghĩa B gọi A. Nếu chỉ đếm kết nối trỏ vào, service vừa đưa vào chạy có con số bằng 0, nên theo quy tắc thứ hai, nó mãi mãi không được ai gọi.

Price (1976) sửa bằng cách cộng cho mỗi node một chút sức hút ban đầu2. Trong hệ thống thật, đó là thứ giúp một service được biết tới trước khi nổi tiếng. Đó có thể là một dòng trong danh bạ nội bộ, một buổi demo, hoặc việc nó giải đúng bài toán ai đó đang cần.

Gộp lại, hai cơ chế này dồn bên gọi về vài node, và hub hình thành mà không ai quyết định. Chuyện gì xảy ra khi nó nghẽn?

V. Khi hub nghẽn, lỗi đi đường nào #

Mạng phi tỷ lệ chịu hỏng hóc ngẫu nhiên tốt, vì node hỏng ngẫu nhiên thường là node nhỏ; nhắm đúng hub thì mạng rã ra. Với app giao đồ ăn, service đánh giá lỗi thì chỉ phần đánh giá không hiện; auth lỗi thì không ai đặt được món.

Trần chịu tải của hub #

Hub nào cũng có trần cứng: số request xử lý được cùng lúc. Request là một lần gửi yêu cầu tới service, như app hỏi auth “người này đăng nhập chưa”. Vượt trần, request phải xếp hàng hoặc bị từ chối. Trần thường do connection pool đặt ra, hoặc do thread pool nói ở phần dưới. Connection pool là một nhóm đường nối tới database, số lượng cố định, mở sẵn và dùng chung, như phòng giao dịch có số quầy cố định.

Giả sử auth xử lý tối đa 200 request cùng lúc, và giờ cao điểm trưa, mỗi service gọi tới giữ khoảng 5 request đang chờ auth. 200 chia 5 là 40: auth chịu được 40 service gọi tới. Rồi service thứ 41 được đưa vào chạy, chẳng hạn tính năng gợi ý món. Không cảnh báo nào kêu, vì dashboard báo CPU của auth mới 45%: request đứng xếp hàng gần như không tốn CPU.

Lỗi lan ngược, không lan ngang #

Nếu phải đoán lỗi lan theo hướng nào, tôi sẽ đoán theo chiều mũi tên trên sơ đồ, như nước chảy trong ống. Tôi sẽ sai, và sai đúng 180 độ.

Trong mô hình lỗi dây chuyền của Motter và Lai (2002), vốn dùng cho những mạng như lưới điện, node hỏng thì tải dồn sang node khác theo đường đi mới: lỗi lan sang ngang. Microservices không có đường vòng đó, vì không service nào làm thay việc đăng nhập. Hub đứng hình thì request gửi tới nó đứng chờ.

Thread là một dòng việc chạy song song trong server; thread pool là nhóm thread có số lượng cố định của một service. Khi auth đứng hình, mỗi request tới service khuyến mãi lại gọi auth và giữ thêm một thread. Chẳng mấy chốc cả thread pool bị giữ hết, và service khuyến mãi không nhận thêm việc gì, kể cả việc không cần auth. Rồi các service gọi tới nó cũng cạn thread theo, cứ thế lan ngược lên.

Ở công ty, đó là buổi chiều người đồng nghiệp biết tuốt bận họp: ai chờ anh thì đứng việc, ai chờ những người đó cũng đứng theo. Ví dụ lệch ở một điểm: người thật biết nói “chiều anh trả lời nhé”, còn lời gọi đồng bộ giữ bên gọi chờ tới hết giờ chờ tối đa.

Lỗi vì vậy lan ngược chiều phụ thuộc. Có thể gọi cơ chế này là synchronous backpressure. Backpressure là áp lực dội ngược: bên nhận chậm thì bên gửi phải chậm theo, ở đây qua chính những lời gọi đồng bộ đang chờ. Với một hub, phía gọi tới nó là phần lớn hệ thống.

graph TD A[Mạng lớn dần: thêm service mới] --> B[Service mới nối vào node đã đông] B --> C["Tải từ các bên gọi vượt trần của hub"] C --> D[Hub cạn connection pool, trả lời chậm hẳn] D --> E[Thread pool của các bên gọi bị giữ hết] E --> F[Lỗi lan ngược theo chiều phụ thuộc] style C fill:#F97316,stroke:#171717,color:#FFFFFF style F fill:#171717,stroke:#F97316,color:#F97316

Mục VI kiểm tra mắt xích đầu tiên: hub có thật sự tự xuất hiện?

VI. Mô phỏng: một hub tự xuất hiện giữa 20.000 service #

Tôi dựng mô phỏng một mạng Barabási-Albert 20.000 service, mỗi service mới mang theo 2 phụ thuộc, và không chỉnh tham số nào để ép kết quả. Mạng có trung bình 4,0 kết nối và 80% service có không quá 4, nhưng service lớn nhất có tới 382 kết nối, gần gấp 100 lần trung bình. Code không có dòng nào ghi “tạo một hub”; nó vẫn tạo ra một hub, và đó là service đầu tiên được thêm vào mạng.

Cách đọc biểu đồ: hai trục dùng thang log, mỗi vạch lớn gấp 10 lần vạch trước; trục ngang là số kết nối, trục dọc là tỉ lệ service có số kết nối đó. Đường cam là dữ liệu, đường trắng đứt nét là đường thẳng khớp nhất. Power law hiện ra thành đường thẳng dốc xuống; độ dốc ở đây là −2,82, tức dốc 2,82, sát con số 3 của lý thuyết. Các nhóm đếm được chia theo thang log, vì chia đều thì phần đuôi, nơi các hub nằm, chỉ còn vài điểm nhiễu.

Barabási-Albert Scale-Free Network Simulation

Đào sâu: code mô phỏng

Code dựng mạng, in các con số trên, khớp đường thẳng trên thang log-log để lấy số mũ, rồi vẽ biểu đồ.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import numpy as np
import networkx as nx
import matplotlib.pyplot as plt

N, m = 20_000, 2          # 20.000 service, mỗi service mới mang theo 2 phụ thuộc
G = nx.barabasi_albert_graph(N, m, seed=42)
degrees = np.array([d for _, d in G.degree()])
print(f"Trung bình: {degrees.mean():.1f}, lớn nhất: {degrees.max()}, "
      f"tỉ lệ <= 4: {(degrees <= 4).mean():.0%}")

# Chia nhóm theo thang log để phần đuôi không bị nhiễu nuốt mất
bins = np.logspace(np.log10(degrees.min()), np.log10(degrees.max()), 25)
hist, edges = np.histogram(degrees, bins=bins, density=True)
centers = np.sqrt(edges[:-1] * edges[1:])
ok = hist > 0                       # bỏ nhóm trống trước khi lấy log

# Khớp đường thẳng trên thang log-log: độ dốc là -gamma
slope, intercept = np.polyfit(np.log10(centers[ok]), np.log10(hist[ok]), 1)
print(f"Số mũ khớp được: {-slope:.2f}")

k = np.logspace(np.log10(centers[ok][0]), np.log10(degrees.max()), 100)
plt.style.use("dark_background")
fig, ax = plt.subplots(figsize=(10, 6), facecolor="#171717")
ax.set_facecolor("#171717")
ax.loglog(centers[ok], hist[ok], color="#F97316", lw=2, label="Empirical Data PDF")
ax.loglog(k, 10**intercept * k**slope, "--", color="#FAFAFA",
          label=f"Power Law Fit (γ={-slope:.2f})")
ax.set_xlabel("Degree (k)")
ax.set_ylabel("Probability Density P(k)")
ax.legend()
plt.show()

Kết quả in ra: trung bình 4,0; lớn nhất 382; 80%; số mũ 2,82, thấp hơn 3 một chút vì mạng hữu hạn và phần đuôi có ít điểm.

VII. Ba cách sống chung với hub, và cái giá của từng cách #

Hub là hệ quả số học của preferential attachment, nên ba cách dưới đây không xóa được hub mà đi vòng qua nó. Câu nên hỏi đội kỹ thuật: service nào đang bị gọi đồng bộ nhiều nhất, và trần chịu tải của nó là bao nhiêu?

  1. Dời hub xuống tầng hạ tầng bằng EDA và CQRS. EDA là kiến trúc trong đó service báo cho nhau “việc này vừa xảy ra” qua một message broker thay vì gọi rồi đứng chờ. Message broker là phần mềm trung gian nhận, lưu và chuyển tin, như Kafka hay RabbitMQ.

    CQRS, thường đi cùng EDA, tách đường ghi dữ liệu khỏi đường đọc để hai việc không tranh tài nguyên. Cái giá là eventual consistency: dữ liệu ở các service sẽ khớp nhau, nhưng sau một lúc.

    Cách này nghe như dọn nhà, nhưng thật ra là chuyển nhà: hub không biến mất, nó dọn sang broker. Cái được là broker làm ra để gánh đúng việc này; Kafka chẳng hạn ghi nối đuôi xuống đĩa, chịu được trần cao hơn hẳn một service nghiệp vụ. Bên gửi thả tin rồi làm việc khác, nên không thread nào bị giữ.

  2. Cách ly bằng bulkhead. Bulkhead là vách ngăn chia thân tàu thành nhiều khoang kín, thủng một khoang thì tàu không chìm. Trong phần mềm, đó là chia cứng thread pool của hub cho từng service gọi tới, mỗi service một phần định sẵn. Một bên gọi bị lỗi và dồn request tới tấp thì chỉ dùng hết phần của nó; 39 bên kia vẫn chạy.

  3. Chặn đường lan ngược bằng circuit breaker. Circuit breaker là cầu dao đặt ở bên gọi. Khi hub trả lời chậm quá ngưỡng hoặc lỗi liên tục, cầu dao tự ngắt để bên gọi trả lỗi ngay thay vì đứng chờ. Đây là cách chặn trực tiếp nhất cơ chế lan ngược, vì nó cắt đúng chỗ thread bị giữ.

Cách làmĐược gìTrả giá gì
EDA / CQRSHub dời xuống hạ tầng, nơi trần cao hơn nhiều; không ai đứng chờChấp nhận eventual consistency; broker thành điểm phụ thuộc tập trung mới, cần vận hành kỹ
BulkheadGiới hạn chính xác phạm vi ảnh hưởng khi một bên gọi gặp sự cốTài nguyên chia sẵn có lúc dùng không hết; phải tính dung lượng cho từng bên gọi
Circuit breakerCắt đường lan ngược, giữ lại thread pool của bên gọiNgười dùng nhận lỗi nhanh thay vì chờ lâu; phải thiết kế sẵn phương án dự phòng khi cầu dao ngắt

VIII. Tài liệu tham khảo #


  1. Clauset, Shalizi và Newman (2009) chỉ ra nhiều phân bố từng được gọi là power law không qua được kiểm định thống kê. Bài này dùng power law như mô hình gần đúng cho xu hướng dồn về vài node, không khẳng định đồ thị phụ thuộc thật nào có đúng dạng đó. ↩︎

  2. Price viết mô hình cho mạng trích dẫn giữa các bài báo, một mạng có chiều, và dùng sức hút ban đầu bằng 1; dạng hằng số dương bất kỳ là cách tổng quát hóa về sau. ↩︎