Chuyện Uber chuyển từ Postgres sang MySQL: Lý do đằng sau là gì?

Hồi Uber Bỏ PostgreSQL Chạy Sang MySQL: Bài Học Xương Máu Về Database ở Quy Mô Khủng

Này các bác, có biết hồi xưa Uber chuyển từ PostgreSQL sang MySQL là vì lý do gì không? Đơn giản là họ đang đau đầu với mấy cái vụ scaling, mà mấy cái đó cứ kìm hãm sự phát triển của họ. Nào là index bloat, write amplification, rồi replication overhead, nguy cơ data corruption, MVCC trên replicas không ra gì, lại còn khó nâng cấp nữa chứ. Chính vì mấy lý do đó mà họ phải bỏ Postgres để tìm đến MySQL, cái mà hồi đó phù hợp với họ hơn.

Nói đến giờ, các phiên bản PostgreSQL mới hơn (tính đến tháng 3 năm 2025 là đến bản 17 rồi) đã xử lý được kha khá mấy cái vấn đề này rồi đó. Nhưng mà vẫn còn một số thứ cố hữu do cái thiết kế cốt lõi của database. Trong bài này, tôi sẽ chỉ ra cho các bác cái nào đã được giải quyết, cái nào vẫn còn đó, để các bác thấy PostgreSQL nó đã lột xác thế nào nha.

8c37ecb6-d688-4b81-9258-fa713d282189

Bệnh Index Bloat Ở PostgreSQL

Một trong những cái khiến Uber đau đầu nhất với PostgreSQL hồi đó là cái bệnh index bloat. Cứ mỗi lần cập nhật dữ liệu, mấy cái index của họ cứ phình to ra mãi, dù cho số lượng hàng trong bảng vẫn y nguyên. Cái này làm truy vấn chậm đi, tốn dung lượng ổ đĩa một cách vô ích, và việc bảo trì thì cứ mệt mỏi thêm.

Hiểu về ctid và tại sao các hàng cũ vẫn còn nằm trong bảng

PostgreSQL nó gán cho mỗi hàng một cái ctid, cái này giống như tọa độ vật lý chính xác của hàng đó trong bảng vậy. Khi một hàng được cập nhật, PostgreSQL không sửa ngay trên cái hàng cũ đâu, mà nó tạo ra một phiên bản mới tinh với một cái ctid mới. Còn cái hàng cũ thì cứ nằm đó chờ đến khi nào quy trình vacuum nó ghé qua xử lý. Mình xem thử cái bảng products đơn giản này nha:

id name price quantity
1 notebook 1000 2
2 smartphone 500 5
3 tablet 750 3

Dù mình chỉ thấy có bốn cột thôi, nhưng bên trong, PostgreSQL tự động gán một cái TID cho mỗi hàng, được lưu trong cột ctid. Cái TID này nó chỉ thẳng đến vị trí vật lý của hàng trên ổ đĩa. Các bác có thể xem cái ctid của một hàng bằng câu lệnh này:

SELECT ctid, * FROM products WHERE id = 1;

Đây là kết quả của nó:

ctid id name price quantity
(126,3) 1 notebook 1000 2

Cái giá trị ctid (126,3) này cho mình biết:

  • 126: Hàng đó được lưu ở trang số 126 của file bảng.
  • 3: Nó là mục thứ ba trong trang đó.

Cái vị trí vật lý này sẽ thay đổi mỗi khi một hàng được cập nhật. Vì cái mô hình MVCC của PostgreSQL nó sẽ tạo ra một phiên bản hàng mới với ctid mới, trong khi phiên bản cũ vẫn giữ cái ctid ban đầu.

Cách Secondary Indexes dùng ctid

Để tìm kiếm nhanh hơn, Uber đã tạo một index dạng B-tree trên cột name: CREATE INDEX idx_products_name ON products(name); Cái index này giúp PostgreSQL nhanh chóng tìm ra các hàng dựa trên cột name mà không cần phải quét toàn bộ bảng. Bên trong cây B-tree, mỗi node sẽ lưu key (giá trị của name) và cái ctid tương ứng, chỉ đến vị trí vật lý của hàng. Vậy nên, khi các bác chạy một truy vấn như: SELECT * FROM products WHERE name = 'notebook'; PostgreSQL sẽ làm các bước sau:

  1. Tìm kiếm trong index B-tree để tìm cái ctid – trong trường hợp này là (126,3).
  2. Dùng cái ctid đó để lấy hàng trực tiếp từ trang 126, vị trí 3 trong bảng. Cái quy trình này giúp việc tra cứu index cực kỳ hiệu quả.

Chuyện gì xảy ra khi một hàng được cập nhật?

Vì MVCC, PostgreSQL không bao giờ cập nhật hàng ngay tại chỗ. Thay vào đó, một hàng được cập nhật sẽ được ghi thành một phiên bản mới với ctid khác, trong khi phiên bản cũ vẫn nằm đó cho đến khi được vacuum dọn dẹp. Ví dụ, nếu mình cập nhật giá của cái notebook:

UPDATE products SET price = 900 WHERE id = 1; Một phiên bản hàng mới sẽ được tạo với một ctid mới, như hình dưới đây:

ctid id name price quantity
(130,2) 1 notebook 900 2
(126,3) 1 notebook 1000 2

← Old version

Cái hàng cũ với ctid (126,3) không bị xóa ngay lập tức và vẫn trỏ đến dữ liệu cũ. Mỗi lệnh INSERT hoặc UPDATE mà có sửa đổi một cột đã được index sẽ khiến một mục index mới được thêm vào, và đây chính là nguyên nhân gây ra index bloat.

Tại sao index bloat lại xảy ra?

Đây mới là chỗ rắc rối, và cái vấn đề này nó vẫn còn tồn tại trong PostgreSQL cho đến tận bây giờ. Ngay cả sau khi quy trình vacuum đã đánh dấu hàng cũ để tái sử dụng, cái index vẫn giữ những mục trỏ đến cái Tuple đã chết đó cho đến khi mình chạy REINDEX hoặc VACUUM FULL. Cứ thế, theo thời gian, càng nhiều cập nhật xảy ra, mấy cái mục index lỗi thời này càng chất đống, làm cho index lớn hơn mức cần thiết.

Thậm chí tệ hơn, trong quá trình quét index, PostgreSQL có thể gặp phải một mục lỗi thời trước cái mục trỏ đến một hàng đã chết. Trong những trường hợp như vậy, nó sẽ tiếp tục quét index để tìm một phiên bản hàng còn sống, làm tốn thêm công sức và làm chậm truy vấn, đặc biệt là khi có quá nhiều mục chết hiện diện.

1b4b41a4-cfec-423c-bb6b-a6dd49724470

Tại sao đây lại là vấn đề lớn với Uber?

Với Uber, cái này gây ra mấy chuyện phiền phức sau:

  • Truy vấn chậm: Index phải lọc qua mấy cái mục lỗi thời, làm tăng thời gian truy vấn.
  • Tốn dung lượng lưu trữ: Index cứ phình to ra dù kích thước bảng không đổi.
  • Chi phí bảo trì cao: Hồi đó, cách duy nhất để dọn dẹp các index bị phình là chạy REINDEX thủ công. Mà cái lệnh này nó khóa bảng lại, gây ra downtime một vấn đề cực lớn với một công ty như Uber, họ không thể chấp nhận gián đoạn được.

Write Amplification

Cái quy trình cập nhật của PostgreSQL vẫn gây ra write amplification cho đến tận bây giờ, và đây là một vấn đề lớn đối với Uber. Vì các index lưu cái ctid, mỗi lần cập nhật là nó lại thêm các mục mới vào tất cả các index Nếu mình có thêm index trên nhiều cột hơn, thì những index đó cũng cần thêm các mục mới cho ctid mới sau mỗi lần cập nhật hàn, ngay cả khi chỉ có một cột thay đổi.

Điều này có nghĩa là phải ghi thêm dữ liệu vào ổ đĩa, mà ở quy mô của Uber, cái này làm chậm hiệu năng và tăng chi phí lưu trữ lên.

MySQL tránh được vấn đề này bằng cách lưu khóa chính trong các index thứ cấp thay vì vị trí vật lý, vậy nên chỉ những index trên các cột bị sửa đổi mới cần được cập nhật.

Replication Overhead

Một thách thức khác mà Uber gặp phải là replication overhead. PostgreSQL sử dụng replication dựa trên WAL, cái này ghi lại tất cả các thay đổi cấp thấp trong database mỗi lần cập nhật hàng, cập nhật index, hay xóa tuple đều được ghi vào WAL và nhân bản. Với Uber, cái này dẫn đến việc tốn băng thông cao và các hoạt động giữa các trung tâm dữ liệu không hiệu quả.

Ngược lại, row-based replication của MySQL gọn gàng hơn, vì nó chỉ nhân bản các thay đổi logic chứ không phải các thay đổi vật lý. Vấn đề này đã được giải quyết phần lớn trong các phiên bản PostgreSQL mới hơn với sự ra đời của logical replication từ PostgreSQL 10 trở đi. Cái này nhân bản các thay đổi logic và giảm đáng kể việc sử dụng băng thông.

Data Corruption

Uber còn từng gặp một lỗi replication của PostgreSQL khiến replicas áp dụng sai các bản ghi WAL, dẫn đến trùng lặp hàng và dữ liệu không nhất quán giữa các bản sao. Vì replication của PostgreSQL xảy ra ở cấp độ vật lý, nên việc hỏng dữ liệu ở một bản sao có thể lây lan sang các bản khác. Để sửa lỗi này phải đồng bộ lại các bản sao thủ công, một quá trình tốn thời gian và đầy rủi ro.

Replication logic của MySQL an toàn hơn, vì nó không nhân bản các thay đổi cấp thấp trên ổ đĩa. Các phiên bản PostgreSQL mới hơn đã khắc phục vấn đề này với logical replication và các công cụ như pg_rewind, giúp nguy cơ hỏng dữ liệu ít xảy ra hơn nhiều và việc khôi phục cũng dễ dàng hơn nhiều.

Poor Replica MVCC Support

Hồi đó, PostgreSQL không hỗ trợ MVCC đầy đủ trên các bản sao. Nếu một truy vấn chạy lâu trên bản sao, việc nhân bản sẽ bị tạm dừng cho đến khi truy vấn đó kết thúc. Nếu độ trễ quá lâu, PostgreSQL sẽ tự động giết truy vấn để cho phép nhân bản tiếp tục. Đây là một vấn đề lớn với Uber, vì hệ thống của họ thường có các truy vấn đọc chạy dài, dẫn đến độ trễ nhân bản và lỗi giao dịch.

MySQL thì lại cho phép MVCC thực sự trên các bản sao, ngăn chặn việc truy vấn bị gián đoạn. Từ PostgreSQL 9.4 trở đi, sự ra đời của Hot Standby đã giải quyết vấn đề này, cho phép các truy vấn chạy đồng thời với việc nhân bản, mặc dù các truy vấn chạy lâu vẫn có thể gây ra độ trễ nhỏ trong một số trường hợp.

Expensive Upgrade Process

Nâng cấp PostgreSQL từng là một cơn ác mộng đối với Uber vì việc nhân bản phụ thuộc vào phiên bản. Một master PostgreSQL 9.3 không thể nhân bản đến một replica 9.2, nên Uber phải tắt database, nâng cấp nó, và đồng bộ lại tất cả các bản sao. Một quá trình mất hàng giờ và gây ra downtime đáng kể.

MySQL cho phép nâng cấp cuốn chiếu (rolling upgrades), nơi các bản sao có thể được nâng cấp trước, giảm downtime. Vấn đề này đã được khắc phục trong các phiên bản PostgreSQL mới hơn với logical replication, cho phép nâng cấp không downtime bằng cách cho phép nhân bản giữa các phiên bản khác nhau trong quá trình nâng cấp.

MySQL Đã Giải Quyết Những Vấn Đề Này Như Thế Nào

Engine InnoDB của MySQL xử lý các index thứ cấp khác với PostgreSQL. Thay vì trỏ đến một vị trí hàng vật lý như ctid của PostgreSQL, các index của MySQL lưu khóa chính của hàng. Điều này có nghĩa là khi một hàng được cập nhật, các index thứ cấp không cần phải viết lại, tránh được vấn đề index bloat mà PostgreSQL vẫn đang gặp phải.

MySQL cũng mang đến khả năng nhân bản hiệu quả hơn, hỗ trợ MVCC tốt hơn trên các bản sao và nâng cấp dễ dàng hơn, khiến nó phù hợp hơn với nhu cầu mở rộng của Uber vào thời điểm đó. Đối với Uber, đây thực sự là một bước ngoặt lớn họ không còn phải liên tục quản lý index bloat, và các truy vấn của họ trở nên nhanh hơn mà không cần dọn dẹp thủ công.

Việc Uber chuyển từ PostgreSQL sang MySQL là một phản ứng trước những thách thức lớn về khả năng mở rộng. Một số vấn đề đã được giải quyết trong các phiên bản PostgreSQL mới hơn (tính đến tháng 3 năm 2025 là bản 17), trong khi những vấn đề khác vẫn còn do thiết kế cốt lõi của database. Mình cùng tóm tắt nhanh tình hình hiện tại nha:

  • Những vấn đề đã được giải quyết: Các phiên bản PostgreSQL mới hơn đã xử lý được vài điểm đau của Uber. Logical replication, ra mắt từ PostgreSQL 10, đã giải quyết vấn đề replication overhead và cho phép nâng cấp không downtime, giúp các hoạt động giữa các trung tâm dữ liệu và việc nâng cấp phiên bản mượt mà hơn nhiều. Nguy cơ hỏng dữ liệu cũng đã giảm thiểu với logical replication và các công cụ như pg_rewind, giúp việc khôi phục bản sao đơn giản hơn nhiều. Hỗ trợ MVCC kém trên các bản sao đã được giải quyết bằng Hot Standby trong PostgreSQL 9.4, cho phép các truy vấn chạy đồng thời với việc nhân bản, dù truy vấn chạy lâu vẫn có thể gây ra độ trễ nhỏ trong một số trường hợp.

  • Những thách thức còn tồn đọng: Index bloat vẫn là một thách thức trong PostgreSQL hiện nay, vì kiến trúc MVCC và ctid vẫn khiến các index phình to với các mục chết trong các khối lượng công việc có nhiều cập nhật, mặc dù đã có những cải tiến như REINDEX CONCURRENTLYautovacuum tốt hơn. Write amplification cũng vẫn còn, vì các cập nhật đòi hỏi phải sửa đổi tất cả các index, ngay cả đối với các cột không thay đổi, mặc dù các tính năng như HOT updatespartitioning giúp giảm bớt tác động.

Dù PostgreSQL đã trở thành một đối thủ mạnh hơn với những tiến bộ này, nhưng việc MySQL sử dụng index dựa trên khóa chính và MVCC mượt mà trên các bản sao vẫn mang lại lợi thế cho nó trong một số kịch bản có nhiều cập nhật. Nếu Uber xem xét lại quyết định của họ ngày hôm nay, họ có thể thấy PostgreSQL khả thi hơn, nhưng lựa chọn vẫn sẽ phụ thuộc vào nhu cầu khối lượng công việc cụ thể của họ.

Chia sẻ bài viết:
Theo dõi
Thông báo của
0 Góp ý
Được bỏ phiếu nhiều nhất
Mới nhất Cũ nhất
Phản hồi nội tuyến
Xem tất cả bình luận