Tình huống: hai triệu chứng sau khi tách
Cuối tháng 1, khoảng tháng thứ mười một kể từ khi mở cửa, Chợ Phố phục vụ khoảng 70.000 người dùng mỗi ngày. Sau buổi khôi phục (restore) thử ở chương 1, nhóm kỹ thuật quyết định chuyển PostgreSQL sang một máy chủ (server) riêng trong cùng trung tâm dữ liệu. Máy cũ chỉ còn chạy Tomcat và thư mục ảnh sản phẩm. Theo quy định của trung tâm dữ liệu, hai máy nằm ở hai phân vùng mạng khác nhau, và mọi gói tin giữa chúng đi qua một tường lửa có trạng thái (stateful firewall).
- Thứ Bảy, 23:00
- Cơ sở dữ liệu (database) được chuyển sang máy mới. Ứng dụng được cấu hình trỏ tới địa chỉ mới.
- Thứ Hai, 10:00
- Biểu đồ thời gian phục vụ cho thấy trung vị của trang sản phẩm tăng từ 52 ms lên 87 ms. Các trang khác chậm thêm không đáng kể.
- Thứ Ba, 06:41
- Nhật ký ứng dụng ghi một loạt cảnh báo về kết nối (connection) hỏng trong vài phút đầu tiên có khách.
- Thứ Tư, 06:38
- Hiện tượng lặp lại. Nhật ký PostgreSQL của đêm trước có nhiều dòng báo kết nối bị đặt lại (connection reset).
Cả hai hiện tượng chưa từng xuất hiện khi ứng dụng và cơ sở dữ liệu chạy trên cùng một máy. Mã nguồn không thay đổi trong lần chuyển này.
Tách cơ sở dữ liệu và mô hình ba tầng
Tách cơ sở dữ liệu ra máy riêng giải quyết vấn đề tranh chấp tài nguyên đã phân tích ở chương 1. Sau khi tách, PostgreSQL có riêng CPU, bộ nhớ trong (RAM) và ổ đĩa (disk). Tác vụ nặng ở phía cơ sở dữ liệu không còn làm chậm ứng dụng, và ngược lại. Mỗi máy cũng được nâng cấp độc lập theo đúng tài nguyên mà nó thiếu.
Mô hình ba tầng (three-tier architecture) là cách tổ chức hệ thống thành ba tầng có trách nhiệm tách biệt. Tầng trình bày hiển thị dữ liệu cho người dùng, ở Chợ Phố là trình duyệt nhận HTML. Tầng xử lý chứa logic nghiệp vụ, ở đây là ứng dụng Java trên Tomcat. Tầng dữ liệu lưu trữ và truy xuất dữ liệu, ở đây là PostgreSQL. Mỗi tầng chỉ giao tiếp với tầng liền kề qua một giao thức xác định: trình duyệt gọi ứng dụng qua HTTP, ứng dụng gọi cơ sở dữ liệu qua giao thức của PostgreSQL.
Kéo ngang để xem hết hình
Khi ba tầng chạy trên cùng một máy, ranh giới giữa chúng chỉ tồn tại trong mã nguồn. Khi tầng dữ liệu chuyển sang máy khác, ranh giới đó trở thành một đường mạng. Đường mạng này có độ trễ (latency), có thể bị cắt, và đi qua thiết bị mà nhóm phát triển không quản lý. Phần tiếp theo phân tích từng tính chất.
Mạng trở thành một phần của hệ thống
Trước khi tách, ứng dụng kết nối tới PostgreSQL qua giao diện mạng nội bộ của chính máy đó. Một lượt khứ hồi (round trip), tức khoảng thời gian từ lúc gửi một thông điệp đến lúc nhận phản hồi, khi đó chỉ khoảng 0,03 ms. Sau khi tách, lượt khứ hồi giữa hai máy được đo bằng ping:
shell$ ping -c 20 10.0.2.20
...
--- 10.0.2.20 ping statistics ---
20 packets transmitted, 20 received, 0% packet loss, time 19027ms
rtt min/avg/max/mdev = 0.412/0.503/0.731/0.061 msGiá trị trung bình khoảng 0,5 ms phù hợp với con số kinh điển cho khứ hồi trong cùng trung tâm dữ liệu, trong bảng "Numbers Everyone Should Know" của Jeff Dean (2009). Độ trễ mạng là phần thời gian mà mỗi lượt trao đổi giữa hai máy phải trả cho đường truyền, độc lập với thời gian xử lý ở hai đầu. Con số này nhỏ, nhưng được trả lại cho từng lượt trao đổi.
Mạng còn đưa vào hai dạng lỗi mà một máy không có. Thứ nhất, một lời gọi có thể không bao giờ nhận được phản hồi, nên ứng dụng cần giới hạn thời gian chờ (timeout). Thứ hai, một kết nối đang mở có thể bị cắt bởi bất kỳ thiết bị nào trên đường đi, và hai đầu không nhất thiết biết điều đó cùng lúc. Danh sách "các ngộ nhận về điện toán phân tán" (fallacies of distributed computing), thường được gắn với L. Peter Deutsch và các đồng nghiệp ở Sun Microsystems khoảng năm 1994, mở đầu bằng hai giả định sai: mạng luôn tin cậy, và độ trễ bằng không.
Trình điều khiển JDBC của PostgreSQL, pgjdbc, giới hạn thời gian chờ ở phía ứng dụng bằng hai tham số. Tham số connectTimeout giới hạn thời gian mở kết nối, mặc định 10 giây. Tham số socketTimeout giới hạn thời gian chờ đọc dữ liệu, mặc định 0, tức không giới hạn. Với mặc định này, một truy vấn gặp sự cố mạng có thể giữ một luồng (thread) xử lý trong thời gian rất dài. Chợ Phố đặt cả hai giới hạn trong chuỗi kết nối:
properties# application.properties: giới hạn thời gian chờ của pgjdbc (đơn vị giây)
spring.datasource.url=jdbc:postgresql://10.0.2.20:5432/chopho?connectTimeout=3&socketTimeout=30sql-- Phía PostgreSQL: huỷ mọi câu lệnh chạy quá 20 giây của vai trò ứng dụng
ALTER ROLE chopho_app SET statement_timeout = '20s';Ngưỡng của statement_timeout ở phía PostgreSQL được đặt nhỏ hơn socketTimeout. Trong trường hợp bình thường, máy chủ huỷ câu lệnh trước và trả về một lỗi rõ ràng. Giới hạn socketTimeout chỉ là lưới an toàn cho trường hợp mạng có sự cố và máy chủ không thể trả lời.
Mạng cũng làm tăng chi phí mở kết nối. Với xác thực SCRAM-SHA-256, một kết nối mới không mã hoá cần ít nhất bốn lượt khứ hồi. Một lượt dành cho bắt tay của giao thức TCP (chương 26), ba lượt dành cho khởi động phiên và xác thực. Khi bật mã hoá, kết nối còn thêm các lượt thương lượng và bắt tay mã hoá. Riêng phần mạng đã là 4 × 0,5 = 2 ms, chưa tính việc PostgreSQL tạo một tiến trình (process) phục vụ riêng cho kết nối đó. Vì vậy nhóm kết nối (connection pool), đã giới thiệu ở chương 0, càng cần thiết khi cơ sở dữ liệu ở máy khác. Mỗi yêu cầu (request) mượn một kết nối đã mở và trả lại sau khi dùng xong.
Bí ẩn thứ nhất: truy vấn N+1
Trang sản phẩm có một khối "sản phẩm gợi ý". Để biết trang thực hiện bao nhiêu truy vấn, An bật thống kê của Hibernate, thư viện JPA mà ứng dụng dùng để ánh xạ bảng sang đối tượng Java. Sau mỗi lần tải trang, Hibernate ghi một khối số liệu đo ở phía ứng dụng:
log# spring.jpa.properties.hibernate.generate_statistics=true
Session Metrics {
38200 nanoseconds spent acquiring 1 JDBC connections;
0 nanoseconds spent releasing 0 JDBC connections;
611500 nanoseconds spent preparing 74 JDBC statements;
42920000 nanoseconds spent executing 74 JDBC statements;
0 nanoseconds spent executing 0 JDBC batches;
...
}Một lần tải trang thực hiện 74 câu lệnh. Mã nguồn cho thấy nguồn gốc của con số này. Truy vấn danh sách gợi ý trả về 24 sản phẩm. Với mỗi sản phẩm, đoạn mã hiển thị truy cập ba quan hệ được nạp lười (lazy loading): cửa hàng, ảnh chính và điểm đánh giá. Mỗi lần truy cập phát sinh một truy vấn riêng, nên cùng với truy vấn sản phẩm chính, tổng số là 2 + 24 × 3 = 74.
java// ProductPageService: 1 truy vấn cho danh sách gợi ý (24 sản phẩm)
List<Product> goiY = productRepo.findSuggestions(categoryId, productId, PageRequest.of(0, 24));
for (Product p : goiY) {
// mỗi getter dưới đây chạm một quan hệ LAZY, nên phát sinh một truy vấn riêng
cards.add(new Card(p.getName(),
p.getShop().getName(), // truy vấn bảng shops
p.getMainImage().getUrl(), // truy vấn bảng product_images
p.getRatingSummary().getAvgScore())); // truy vấn bảng rating_summary
}Truy vấn N+1 (N+1 selects) là mẫu truy cập dữ liệu trong đó ứng dụng chạy một truy vấn để lấy N bản ghi, rồi chạy thêm một truy vấn cho mỗi bản ghi. Trên một máy, mỗi truy vấn thêm chỉ tốn vài chục micro giây cho phần giao tiếp, nên mẫu này khó nhận ra. Khi mỗi truy vấn phải trả thêm một lượt khứ hồi qua mạng, chi phí của mẫu này tăng tuyến tính theo N.
Phép tính khớp với số đo. Phần giao tiếp của một lần tải trang trước khi tách là 74 × 0,03 ≈ 2,2 ms, sau khi tách là 74 × 0,5 = 37 ms. Chênh lệch là 37 − 2,2 = 34,8 ms, và 52 + 34,8 ≈ 87 ms, đúng bằng trung vị mới của trang sản phẩm.
Kéo ngang để xem hết hình
Phía PostgreSQL không thấy vấn đề này. Tiện ích mở rộng (extension) pg_stat_statements ghi số lần gọi và thời gian thực thi trung bình của mỗi câu lệnh, đo tại máy cơ sở dữ liệu:
sql-- pg_stat_statements_reset() được gọi một giờ trước đó
SELECT calls, round(mean_exec_time::numeric, 3) AS mean_ms, left(query, 58) AS cau_lenh
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 3;
calls | mean_ms | cau_lenh
--------+---------+------------------------------------------------------------
864000 | 0.081 | select s1_0.id,s1_0.name,s1_0.rating from shops s1_0 where
864000 | 0.079 | select i1_0.id,i1_0.product_id,i1_0.url from product_image
864000 | 0.074 | select r1_0.product_id,r1_0.avg_score,r1_0.review_count frTrong giờ đo có 36.000 lượt xem trang sản phẩm, và ba câu lệnh đứng đầu đều được gọi 36.000 × 24 = 864.000 lần. Tỉ lệ giữa số lần gọi và số lượt xem trang bằng đúng số sản phẩm gợi ý. Đây là dấu hiệu nhận diện truy vấn N+1 từ phía cơ sở dữ liệu.
Thời gian thực thi trung bình của mỗi câu lệnh chỉ khoảng 0,08 ms, nên phần thời gian của cả trang tính tại máy chủ là 74 × 0,08 ≈ 5,9 ms. Hibernate, đo ở phía ứng dụng, ghi 74 × (0,5 + 0,08) ≈ 42,9 ms cho cùng các câu lệnh đó. Phần chênh lệch là thời gian nằm trên đường mạng. Thời gian đo tại cơ sở dữ liệu không bao gồm độ trễ mạng. Muốn thấy trọn đường đi của một yêu cầu qua các tầng, cần đo ở phía ứng dụng, và về sau bằng truy vết phân tán (distributed tracing) ở chương 17.
Gộp truy vấn bằng JOIN và IN
Cách sửa là giảm số lượt khứ hồi, bằng cách lấy dữ liệu của mọi bản ghi trong cùng một câu lệnh. Kỹ thuật thứ nhất là JOIN. Truy vấn danh sách gợi ý nối luôn bảng cửa hàng và bảng ảnh, nên mỗi dòng kết quả đã chứa đủ dữ liệu để hiển thị. Trong JPA, mệnh đề join fetch yêu cầu Hibernate nạp quan hệ ngay trong câu lệnh đó:
java// Kỹ thuật JOIN: nạp cửa hàng và ảnh chính trong cùng câu lệnh với danh sách gợi ý
@Query("""
select p from Product p
join fetch p.shop
left join fetch p.mainImage
where p.category.id = :categoryId and p.id <> :productId
order by p.score desc""")
List<Product> findSuggestions(@Param("categoryId") Long categoryId,
@Param("productId") Long productId,
Pageable page);Kỹ thuật thứ hai là IN. Sau khi có danh sách mã sản phẩm, ứng dụng lấy điểm đánh giá của tất cả sản phẩm bằng một câu lệnh có điều kiện product_id IN (...). Kỹ thuật này phù hợp khi dữ liệu cần lấy không nối trực tiếp được, hoặc khi JOIN làm số dòng kết quả tăng lên.
java// Kỹ thuật IN: lấy điểm đánh giá của cả danh sách bằng một câu lệnh
@Query("select r from RatingSummary r where r.productId in :ids")
List<RatingSummary> findByProductIds(@Param("ids") Collection<Long> ids);
// SQL sinh ra có dạng:
// select ... from rating_summary r1_0 where r1_0.product_id in (?,?,?, ... ,?)Sau khi sửa, trang sản phẩm còn ba câu lệnh. Phần thời gian mạng giảm còn 3 × 0,5 = 1,5 ms, và trung vị của trang trở về khoảng 87 − 37 + 1,5 = 51,5 ms. Nguyên nhân của bí ẩn thứ nhất là số lượt trao đổi giữa hai tầng. Khi một thao tác đi qua mạng, số lượt khứ hồi của thao tác đó là một chỉ số cần kiểm soát, ngang với thời gian xử lý.
Đi sâu: chọn giữa JOIN, IN và nạp theo lô
JOIN phù hợp với quan hệ nhiều-một như sản phẩm và cửa hàng, vì mỗi sản phẩm chỉ nối với một dòng. Với quan hệ một-nhiều, mỗi dòng của bảng cha lặp lại theo số dòng con, nên lượng dữ liệu truyền qua mạng tăng. Khi nạp đồng thời hai tập hợp kiểu List bằng join fetch, Hibernate từ chối thực thi và báo MultipleBagFetchException, vì kết quả sẽ là tích Descartes của hai tập hợp.
IN tách mỗi quan hệ thành một câu lệnh riêng, nên số câu lệnh bằng số quan hệ cộng một và không phụ thuộc vào N. Hibernate có thể tự áp dụng cách nạp theo lô tương tự cho mọi quan hệ nạp lười bằng một thuộc tính cấu hình:
properties# Hibernate gom các lần nạp LAZY cùng loại thành một câu lệnh theo lô, tối đa 32 khoá mỗi câu
# (IN với danh sách tham số, hoặc = any(?) với tham số mảng, tuỳ phiên bản và dialect)
spring.jpa.properties.hibernate.default_batch_fetch_size=32Thuộc tính này giảm số câu lệnh mà không cần sửa từng truy vấn, nhưng không thay thế việc thiết kế truy vấn cho các trang quan trọng. Để mẫu N+1 không quay lại, có thể kiểm tra số câu lệnh của mỗi trang chính trong bộ kiểm thử tích hợp, dựa trên chính số liệu thống kê của Hibernate.
Bí ẩn thứ hai: kết nối nhàn rỗi bị cắt
Hiện tượng thứ hai có quy luật về thời gian. Các cảnh báo chỉ xuất hiện trong vài phút đầu mỗi sáng, sau khoảng thời gian từ 01:00 đến 06:30 gần như không có khách. Nhật ký ứng dụng có dạng:
log2027-01-26T06:41:08.512+07:00 WARN 1 --- [nio-8080-exec-3] com.zaxxer.hikari.pool.PoolBase :
HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@5b3e1a7c
(This connection has been closed.). Possibly consider using a shorter maxLifetime value.
2027-01-26T06:41:08.519+07:00 WARN 1 --- [nio-8080-exec-3] com.zaxxer.hikari.pool.PoolBase :
HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@1c94d02e
(This connection has been closed.). Possibly consider using a shorter maxLifetime value.Nhật ký PostgreSQL của đêm trước có các dòng tương ứng ở phía máy chủ, mỗi dòng ứng với một kết nối:
log2027-01-26 01:47:12.208 +07 [41822] LOG: could not receive data from client: Connection reset by peer
2027-01-26 01:47:31.540 +07 [41835] LOG: could not receive data from client: Connection reset by peerMỗi dòng cho biết PostgreSQL nhận được một gói đặt lại từ phía máy khách. Tuy vậy, ứng dụng không hề đóng kết nối vào lúc đó. Để xác định nguồn gói tin, An chạy tcpdump qua đêm trên máy ứng dụng và lọc các gói có cờ RST:
shell$ sudo tcpdump -n -i eth0 'port 5432 and tcp[tcpflags] & tcp-rst != 0'
01:47:12.207911 IP 10.0.2.20.5432 > 10.0.1.10.48122: Flags [R], seq 3171298114, win 0, length 0
01:47:31.540266 IP 10.0.2.20.5432 > 10.0.1.10.48136: Flags [R], seq 902117650, win 0, length 0Máy ứng dụng cũng nhận được gói đặt lại, mang địa chỉ của máy cơ sở dữ liệu, vào cùng thời điểm PostgreSQL ghi nhận gói đặt lại theo chiều ngược lại. Không đầu nào gửi các gói này. Bên gửi là thiết bị nằm giữa hai máy: tường lửa có trạng thái.
Tường lửa có trạng thái lưu một bảng phiên, mỗi dòng ứng với một kết nối đang mở giữa hai phân vùng mạng. Bảng này có kích thước hữu hạn, nên thiết bị xoá những phiên không có gói tin nào đi qua trong một khoảng thời gian, gọi là thời gian chờ nhàn rỗi (idle timeout). Tài liệu của trung tâm dữ liệu ghi giá trị này là 15 phút, và thiết bị được cấu hình gửi gói đặt lại cho cả hai phía khi xoá phiên. Kết nối nhàn rỗi là kết nối vẫn mở nhưng không có dữ liệu đi qua. Một kết nối nhàn rỗi lâu hơn thời gian chờ của thiết bị sẽ bị cắt, dù cả hai đầu vẫn coi nó là hợp lệ.
Phía ứng dụng, HikariCP giữ một số kết nối cố định, vì minimumIdle mặc định bằng maximumPoolSize. Ở phiên bản 5.1.0 mà ứng dụng dùng, keepaliveTime mặc định bằng 0, tức không gửi gì trên kết nối nhàn rỗi. Từ bản 6.2.1, giá trị mặc định này được đổi thành 2 phút. Cơ chế duy nhất làm mới kết nối là maxLifetime, mặc định 30 phút: HikariCP đóng kết nối đã đạt tuổi này và mở kết nối mới thay thế.
Kéo ngang để xem hết hình
Ban đêm, một kết nối vừa mở không được dùng đến. Sau thời gian chờ của tường lửa, kết nối bị cắt nhưng vẫn nằm trong nhóm kết nối cho đến khi hết tuổi. Trong mỗi vòng đời, tỉ lệ thời gian kết nối ở trạng thái đã chết là (30 − 15) ÷ 30 = 0,5. Với nhóm cũ, khi khách đầu tiên đến vào buổi sáng, trung bình 20 × 0,5 = 10 kết nối đã chết.
HikariCP kiểm tra một kết nối trước khi cho mượn, nếu kết nối đó chưa được dùng trong 500 ms gần nhất. Với kết nối đã bị đặt lại, lần kiểm tra thất bại. HikariCP ghi cảnh báo như trên, đóng kết nối và lấy kết nối khác. Vì vậy các cảnh báo tập trung vào vài phút đầu buổi sáng, khi từng kết nối cũ lần lượt được mượn lại. Trong giờ có khách, các kết nối được dùng thường xuyên nên không nhàn rỗi đủ lâu để bị xoá phiên.
Cơ chế kiểm tra đã che phần lớn hậu quả. Mỗi kết nối chết chỉ làm yêu cầu mượn nó chậm thêm một lần kiểm tra, rồi yêu cầu lấy một kết nối khác trong nhóm. Kết nối thay thế được mở ở nền. Tuy vậy, cấu hình này vẫn có hai rủi ro. Các cảnh báo lặp lại mỗi sáng làm nhiễu nhật ký và che lỗi thật. Rủi ro lớn hơn xuất hiện nếu thiết bị mạng xoá phiên mà không gửi gói đặt lại. Khi đó mỗi lần kiểm tra phải chờ hết thời hạn, như phân tích ở mục tiếp theo.
Giữ sống và giới hạn tuổi kết nối
Nguyên tắc sửa là không để kết nối nào nhàn rỗi lâu hơn thời gian chờ của bất kỳ thiết bị nào trên đường đi. HikariCP có ba tham số liên quan, mỗi tham số xử lý một khía cạnh khác nhau.
keepaliveTime. Định kỳ lấy một kết nối nhàn rỗi ra khỏi nhóm, gọiisValid()để gửi một lệnh rỗng tới PostgreSQL, rồi trả kết nối về. Gói tin của lần kiểm tra đi qua tường lửa và làm mới phiên. Giá trị phải nhỏ hơnmaxLifetimevà không dưới 30 giây. Với chu kỳ 5 phút, mỗi kết nối nhàn rỗi được làm mới 15 ÷ 5 = 3 lần trong một khoảng chờ của tường lửa.maxLifetime. Tuổi tối đa của một kết nối. Tài liệu HikariCP khuyến nghị đặt giá trị này ngắn hơn vài giây so với mọi giới hạn thời gian kết nối do cơ sở dữ liệu hoặc hạ tầng áp đặt. Tham số này xử lý giới hạn theo tuổi, ví dụ thiết bị cắt mọi kết nối sau một số giờ cố định.keepaliveTimexử lý giới hạn theo thời gian nhàn rỗi.idleTimeout. Thời gian tối đa một kết nối được nhàn rỗi trước khi bị đóng, mặc định 10 phút. Tham số này chỉ có tác dụng khiminimumIdlenhỏ hơnmaximumPoolSize. Với nhóm kích thước cố định như của Chợ Phố, nó không có hiệu lực.
Cấu hình cũ còn một lỗi tiềm ẩn. Theo tài liệu, validationTimeout, mặc định 5 giây, phải nhỏ hơn connectionTimeout, trong khi Chợ Phố đặt connectionTimeout là 3 giây từ chương 0. HikariCP không tự điều chỉnh cặp giá trị này. Cấu hình mới đặt validationTimeout bằng 1 giây:
properties# application.properties (HikariCP 5.1.0, pgjdbc)
spring.datasource.url=jdbc:postgresql://10.0.2.20:5432/chopho?connectTimeout=3&socketTimeout=30
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.connection-timeout=3000
# phải nhỏ hơn connection-timeout
spring.datasource.hikari.validation-timeout=1000
# kiểm tra kết nối nhàn rỗi mỗi 5 phút, ngắn hơn thời gian chờ của tường lửa
spring.datasource.hikari.keepalive-time=300000
# giữ mặc định 30 phút, ghi rõ để người đọc cấu hình biết giá trị đang dùng
spring.datasource.hikari.max-lifetime=1800000Chi phí của việc giữ sống không đáng kể. Với kích thước mới chọn ở mục sau, HikariCP gửi trung bình 10 ÷ 5 = 2 lệnh kiểm tra mỗi phút.
Kéo ngang để xem hết hình
Hệ điều hành cũng có cơ chế giữ sống riêng ở mức TCP (keepalive). Tham số tcpKeepAlive=true của pgjdbc chỉ bật cơ chế này cho kết nối. Thời điểm gửi gói giữ sống đầu tiên do tham số net.ipv4.tcp_keepalive_time của Linux quyết định, mặc định 7.200 giây. Vì 7.200 ÷ 60 = 120 phút, lâu hơn nhiều so với thời gian chờ của tường lửa, chỉ bật tcpKeepAlive không giải quyết được vấn đề. Muốn dùng cơ chế này phải giảm tham số của hệ điều hành, và thay đổi đó áp dụng cho mọi kết nối của máy. Vì vậy Chợ Phố dùng keepaliveTime của HikariCP làm biện pháp chính.
shell$ sysctl net.ipv4.tcp_keepalive_time net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_probes
net.ipv4.tcp_keepalive_time = 7200
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9Phía PostgreSQL có ba tham số tương ứng là tcp_keepalives_idle, tcp_keepalives_interval và tcp_keepalives_count. Giá trị 0 của mỗi tham số nghĩa là dùng mặc định của hệ điều hành. Chợ Phố đặt chúng để máy cơ sở dữ liệu tự phát hiện những kết nối mà phía ứng dụng đã mất:
ini# postgresql.conf trên máy cơ sở dữ liệu
tcp_keepalives_idle = 300 # giây không có dữ liệu trước gói giữ sống đầu tiên
tcp_keepalives_interval = 30 # giây giữa hai lần gửi lại
tcp_keepalives_count = 4 # số gói không được trả lời trước khi coi kết nối đã chếtĐi sâu: khi thiết bị mạng xoá phiên mà không báo cho hai đầu
Nhiều thiết bị xoá phiên nhàn rỗi một cách im lặng. Gói tin tiếp theo của kết nối bị bỏ đi, và không có gói đặt lại nào được gửi về. Đây là trường hợp khó chẩn đoán hơn tình huống của Chợ Phố.
Phía ứng dụng, lần kiểm tra trước khi cho mượn không nhận được phản hồi. HikariCP đặt thời hạn chờ của kết nối bằng validationTimeout trước khi gọi isValid(), nên yêu cầu mượn phải chờ trọn thời hạn đó. Với cặp giá trị cũ của Chợ Phố, thời hạn kiểm tra dài hơn thời hạn mượn. Yêu cầu đầu tiên gặp kết nối chết sẽ thất bại với lỗi hết thời gian chờ kết nối, dù cơ sở dữ liệu vẫn hoạt động bình thường.
Phía PostgreSQL, tiến trình phục vụ kết nối đó không nhận được gì và tiếp tục chờ. Tiến trình chỉ kết thúc khi cơ chế giữ sống của hệ điều hành xác định kết nối đã chết. Với mặc định của Linux, thời điểm đó là 7.200 + 75 × 9 = 7.875 giây sau lần trao đổi cuối, tức hơn hai giờ. Trong thời gian này, mỗi tiến trình phục vụ kết nối đã mất vẫn chiếm một chỗ trong max_connections. Với cấu hình mới ở trên, máy cơ sở dữ liệu phát hiện kết nối chết sau 300 + 30 × 4 = 420 giây.
Định cỡ: cần bao nhiêu kết nối
Định cỡ nhóm kết nối (pool sizing) là việc chọn số kết nối tối đa mà mỗi máy ứng dụng giữ tới cơ sở dữ liệu. Con số này nằm giữa hai giới hạn: nhu cầu của ứng dụng ở phía dưới, và khả năng xử lý song song của cơ sở dữ liệu ở phía trên.
Nhu cầu được ước lượng bằng định luật Little ở chương 0: số kết nối bận trung bình bằng tốc độ yêu cầu nhân với thời gian mỗi yêu cầu giữ kết nối. Đỉnh khuyến mãi ở chương 0 là 200 yêu cầu mỗi giây với 50.000 người dùng. Theo tỉ lệ người dùng hiện tại, đỉnh mới khoảng 200 × 70.000 ÷ 50.000 = 280 yêu cầu mỗi giây.
Bộ đo hikaricp.connections.usage của HikariCP cho biết sau khi sửa truy vấn N+1, mỗi yêu cầu giữ kết nối trung bình 12 ms. Số kết nối bận trung bình ở đỉnh là 280 × 0,012 ≈ 3,4. Trước khi sửa, mỗi lần tải trang sản phẩm giữ kết nối lâu hơn (74 − 3) × 0,5 = 35,5 ms. Trang sản phẩm chiếm khoảng 40% số yêu cầu, nên thời gian giữ trung bình là 12 + 0,4 × 35,5 = 26,2 ms, và số kết nối bận là 280 × 0,0262 ≈ 7,3. Như vậy độ trễ mạng nhân với số lượt khứ hồi làm trang chậm hơn, đồng thời làm nhu cầu kết nối tăng hơn hai lần.
Giới hạn trên đến từ cơ sở dữ liệu. Mỗi kết nối PostgreSQL được phục vụ bởi một tiến trình riêng, và số câu lệnh thực sự chạy song song bị giới hạn bởi số lõi CPU và ổ đĩa. Trang "About Pool Sizing" trong wiki của HikariCP dẫn một công thức điểm xuất phát do dự án PostgreSQL đưa ra. Theo đó, số kết nối hoạt động bằng số lõi nhân hai, cộng số ổ đĩa hiệu dụng. Số lõi không tính các luồng phần cứng do hyper-threading tạo ra, còn số ổ đĩa hiệu dụng bằng 0 khi dữ liệu thường dùng nằm trọn trong bộ nhớ.
Máy cơ sở dữ liệu của Chợ Phố có 8 vCPU, tương ứng 4 lõi vật lý, và một ổ SSD. Công thức cho 4 × 2 + 1 = 9. Wiki cũng lưu ý rằng công thức chưa được phân tích với SSD, nên con số này chỉ là điểm xuất phát cho thử tải.
Kéo ngang để xem hết hình
Wiki tóm tắt nguyên tắc bằng một tiên đề: nhóm nên nhỏ, và việc các luồng chờ kết nối là trạng thái bình thường. Thêm kết nối vượt quá khả năng xử lý song song của cơ sở dữ liệu chỉ làm tăng chuyển ngữ cảnh và tranh chấp, không làm tăng thông lượng (throughput). Chợ Phố chọn maximumPoolSize bằng 10, bằng một nửa cấu hình đặt ở chương 0. Giá trị này lớn hơn nhu cầu trung bình ở đỉnh khoảng ba lần, và xấp xỉ điểm xuất phát của công thức.
Giới hạn cứng cuối cùng là max_connections, mặc định 100, trong đó superuser_reserved_connections giữ 3 chỗ cho quản trị viên. Các vai trò thường chỉ dùng được 100 − 3 = 97 chỗ. Script sao lưu (backup) ở chương 1, hệ thống giám sát và các phiên quản trị cần khoảng 7 kết nối. Phần còn lại đủ cho (97 − 7) ÷ 10 = 9 máy ứng dụng. Ngân sách này phải được tính lại mỗi khi thêm máy ứng dụng, vì kích thước nhóm là con số của từng máy, còn max_connections là con số chung của cơ sở dữ liệu.
Object storage cho ảnh sản phẩm
Sau khi tách, ảnh sản phẩm vẫn nằm trên ổ đĩa của máy ứng dụng. Bảng sản phẩm có khoảng 1,2 triệu dòng, mỗi sản phẩm trung bình hai ảnh, mỗi ảnh khoảng 150 KB (1 KB = 1.000 byte). Tổng dung lượng là 1.200.000 × 2 × 150 KB = 360.000.000 KB = 360 GB, lớn hơn nhiều lần chính cơ sở dữ liệu. Thư mục ảnh còn gắn chặt với một máy cụ thể. Khi có thêm máy ứng dụng, ảnh do khách tải lên ở máy này sẽ không có ở máy kia.
Một phương án trung gian là ổ đĩa dùng chung qua mạng, ví dụ NFS, gắn vào mọi máy ứng dụng. Phương án này giữ nguyên mã nguồn, nhưng thêm một máy chủ tệp, và máy đó trở thành một điểm lỗi đơn (single point of failure) mới theo nghĩa ở chương 1.
Object storage (lưu trữ đối tượng) là dịch vụ (service) lưu dữ liệu dưới dạng đối tượng. Mỗi đối tượng gồm nội dung, dữ liệu mô tả (metadata) và một khoá duy nhất trong một vùng chứa (bucket), tức không gian tên cấp cao nhất chứa các đối tượng. Không gian tên là phẳng: dấu gạch chéo trong khoá như products/81234/a1f9c2e7.jpg chỉ là một phần của tên, không tạo ra thư mục. Ứng dụng đọc và ghi đối tượng qua API HTTP với các thao tác PUT, GET, DELETE và liệt kê theo tiền tố. Mỗi đối tượng thường được ghi trọn một lần. Muốn thay đổi nội dung thì ghi đè cả đối tượng, vì phần lớn dịch vụ không cho sửa một đoạn ở giữa đối tượng như với tệp trên ổ đĩa. Đổi lại, dung lượng và độ bền dữ liệu (durability) do nhà cung cấp đảm nhận, không còn phụ thuộc vào ổ đĩa của một máy.
Ở Chợ Phố, cơ sở dữ liệu chỉ lưu khoá của đối tượng. Khi khách tải ảnh lên, ứng dụng tạo một URL có chữ ký, có hiệu lực trong vài phút và chỉ cho phép ghi đúng một khoá. Trình duyệt gửi ảnh thẳng tới object storage theo URL đó:
shell# Ứng dụng cấp URL có chữ ký cho đúng một khoá, hết hạn sau 300 giây.
# Bên tải lên chỉ cần gửi nội dung tới URL đó.
curl -fsS -X PUT -H "Content-Type: image/jpeg" --upload-file anh.jpg \
"https://objects.example/chopho-images/products/81234/a1f9c2e7.jpg?expires=1800920760&signature=...Kéo ngang để xem hết hình
Trước khi xin URL, trình duyệt tính giá trị băm SHA-256 của tệp ảnh và gửi kèm trong yêu cầu ở bước đầu. Ứng dụng đặt giá trị băm này vào khoá của ảnh, nên mỗi phiên bản ảnh có một khoá riêng và không bao giờ bị sửa tại chỗ. Tính chất này làm việc phân phối ảnh qua CDN ở chương 4 đơn giản hơn. Việc chuyển đổi được thực hiện theo ba bước:
- Sao chép ảnh hiện có lên object storage. Ứng dụng vẫn đọc ảnh từ ổ đĩa trong lúc sao chép.
- Cập nhật đường dẫn ảnh trong cơ sở dữ liệu thành khoá đối tượng. Từ thời điểm này, ứng dụng hiển thị ảnh từ object storage.
- Chuyển chức năng tải ảnh lên sang URL có chữ ký. Sau bước này, máy ứng dụng không còn giữ dữ liệu nào của người dùng trên ổ đĩa của nó.
Hướng tiếp theo
Sau các thay đổi trên, Chợ Phố vận hành trên hai máy trong cùng trung tâm dữ liệu, cùng một object storage cho ảnh. Trang sản phẩm chỉ còn vài câu lệnh cho mỗi lần tải. HikariCP giữ một nhóm nhỏ có kích thước cố định, làm mới kết nối nhàn rỗi trước khi tường lửa xoá phiên, và các cảnh báo buổi sáng không còn xuất hiện.
Đợt khuyến mãi tháng 3 cho thấy giới hạn tiếp theo. Mức sử dụng (utilization) CPU của máy ứng dụng chạm trần, trong khi CPU của máy cơ sở dữ liệu vẫn còn dư. Ngân sách max_connections cho phép thêm máy ứng dụng, và máy ứng dụng không còn giữ ảnh của người dùng. Nếu thêm máy ứng dụng thứ hai, yêu cầu của khách sẽ được chia cho hai máy theo cách nào? Phiên đăng nhập và giỏ hàng đang nằm trong bộ nhớ của Tomcat sẽ ra sao khi yêu cầu kế tiếp của cùng một khách đến máy kia? Chương 3 phân tích các câu hỏi này.
Tóm tắt chương
- Mô hình ba tầng tách trình bày, xử lý và dữ liệu. Khi tầng dữ liệu sang máy khác, ranh giới giữa các tầng trở thành một đường mạng.
- Độ trễ mạng được trả cho từng lượt khứ hồi. Khứ hồi trong cùng trung tâm dữ liệu khoảng 0,5 ms, nhỏ với một truy vấn nhưng lớn khi nhân với số truy vấn.
- Truy vấn N+1 có chi phí tăng tuyến tính theo N. Nhận diện bằng thống kê của Hibernate ở phía ứng dụng và tỉ lệ số lần gọi trên số lượt xem trang trong
pg_stat_statements. Sửa bằng JOIN hoặc IN. - Thời gian đo tại cơ sở dữ liệu không bao gồm thời gian mạng. Cần đo ở phía ứng dụng để thấy trọn đường đi của yêu cầu.
- Thiết bị mạng có trạng thái xoá phiên nhàn rỗi.
keepaliveTimephải ngắn hơn thời gian chờ của mọi thiết bị trên đường đi,maxLifetimengắn hơn mọi giới hạn tuổi kết nối. validationTimeoutphải nhỏ hơnconnectionTimeout. HikariCP không tự kiểm tra điều kiện này.- Kích thước nhóm nằm giữa nhu cầu theo định luật Little và khả năng xử lý song song của cơ sở dữ liệu. Tổng kết nối của mọi máy ứng dụng phải nằm trong
max_connections. - Object storage tách dữ liệu người dùng khỏi máy ứng dụng. Trình duyệt tải ảnh thẳng lên bằng URL có chữ ký, cơ sở dữ liệu chỉ lưu khoá.
Tài liệu tham khảo
- HikariCP README, mục "Configuration (knobs, baby!)":
keepaliveTime,maxLifetime,idleTimeout,minimumIdle,validationTimeout. Tệp CHANGES cho thay đổi mặc định củakeepaliveTimeở bản 6.2.1. Mã nguồnHikariPool.javavàPoolBase.javacho hành vi kiểm tra trước khi cho mượn và nội dung cảnh báo. - HikariCP Wiki, "About Pool Sizing": công thức
connections = ((core_count * 2) + effective_spindle_count)và tiên đề về nhóm nhỏ. - PostgreSQL Documentation: "Connections and Authentication" (
max_connections,superuser_reserved_connections,tcp_keepalives_*),statement_timeout,pg_stat_statements. Thông báo "could not receive data from client" lấy từ mã nguồnsrc/backend/libpq/pqcomm.c. - PostgreSQL JDBC Driver (pgjdbc), "Connection Parameters":
connectTimeout,socketTimeout,tcpKeepAlive. - Linux kernel documentation,
ip-sysctl.rst:tcp_keepalive_time,tcp_keepalive_intvl,tcp_keepalive_probes. - Hibernate ORM 6: thống kê phiên (
hibernate.generate_statistics),hibernate.default_batch_fetch_size. - Jeff Dean, "Designs, Lessons and Advice from Building Large Distributed Systems", keynote LADIS 2009, bảng "Numbers Everyone Should Know".
- "Fallacies of distributed computing": thường gắn với L. Peter Deutsch (Sun Microsystems, khoảng 1994). Bốn ngộ nhận đầu có nguồn gán cho Bill Joy và Tom Lyon.
- Tài liệu Fortinet về tuỳ chọn
timeout-send-rst, một ví dụ thiết bị có thể gửi gói đặt lại cho hai phía khi xoá phiên nhàn rỗi.
Chợ Phố là tình huống giả định. Cấu hình tường lửa, số đo và nhật ký trong chương là minh hoạ. Các phép tính được kiểm lại bằng _build/c02_calc.py; giá trị mặc định, hành vi phần mềm và thông báo lỗi được đối chiếu tài liệu chính thức hoặc mã nguồn trong _build/c02-verify.md.