PostgreSQL và vấn đề 20 năm của mình với fsync

Alex Nguyen
ZaloPay Engineering
6 min readApr 16, 2019

Developers của các hệ thống quản trị Database phải cực chú trọng về vấn đề lấy dữ liệu an toàn để lưu trữ lâu dài. Vậy mà cách đây không xa (3/2018), cộng đồng PostgreSQL đã phát hiện ra rằng cách kernel xử lý lỗi I/O có thể khiến dữ liệu bị mất mà không có lỗi nào được báo cáo lại cho user. Vấn đề càng nghiêm trọng hơn bởi cách PostgreSQL thực hiện buffer I/O hóa ra không phải duy nhất trên Linux, và sẽ không hề dễ để giải quyết triệt để.

Để biết chuyện gì đã xảy ra, trước tiên cần nhắc lại một chút về cơ chế lưu trữ của Database.

1. File I/O Buffering và File system Durability

File I/O Buffering

Khi chương trình gọi write(), kernel thay vì ghi trực tiếp dữ liệu vào file nằm trong ổ cứng sẽ copy vào một buffer trong kernel (kernel buffer cache). Các buffer đã được ghi dữ liệu này được gọi là dirty buffer. Sau đó, một kernel thread chạy background sẽ tự động thu thập và ghi dirty buffer vào ổ cứng, rồi “flush” các dirty buffer đó để tái sử dụng. Trong một số trường hợp để đảm bảo dữ liệu chắc chắn được ghi xuống ổ cứng, ta phải dùng các system call điều khiển kernel buffer:

  • sync(): đẩy tất cả thay đổi về filesystem metadata và cache file data xuống filesystem bên dưới (ổ cứng). syncfs() giống sync() nhưng chỉ đồng bộ filesystem chứa file được trỏ tới (file descriptor fd).
  • fsync(): đẩy toàn bộ data và metadata gắn với mô tả file fd xuống ổ cứng. fdatasync() giống fsync() nhưng chỉ đồng bộ data và các thuộc tính metadata cần thiết (data nào có thay đổi mới đồng bộ).

Tìm hiểu thêm tại sync(2), fsync(2).

Tính bền vững

Bền vững (Durability) là tính chất tối quan trọng của một file system. Nó đảm bảo độ tin cậy của file system đó: khi ổ cứng ngắt điện đột ngột hoặc system crash, một số tiến trình có thể chưa kịp hoàn thành, nhưng phải có khả năng recover lại dữ liệu để thực hiện việc ghi lại lần nữa, nghĩa là một khi đã thực hiện ghi, ta phải chắc chắn rằng dữ liệu thực sự được ghi vào ổ cứng.

Để hiện thực tính chất này ta cần theo dõi từng cập nhật thực hiện do transaction, trường hợp có lỗi/crash xảy ra database sẽ roll back ngay được transaction đó. Khả năng theo dõi này được thực hiện ngay trên ổ cứng nhờ việc ghi Log, từ đó database có thể rollback khi hệ thống restart trở lại. Ngoài ra, nếu transaction thành công nhưng lại crash trước khi kịp save vào ổ cứng, log sẽ được dùng để redo tất cả cập nhật, ta không phải lo các cập nhật bị mất.

Database luôn đảm bảo mỗi khi transaction hoàn thành thì log tương ứng sẽ được save xuống ổ cứng rồi mới báo thành công.

  • Việc ghi log này được thực hiện theo kiểu append-only với tốc độ nhanh hơn rất nhiều so với ghi data phân tán nhiều chỗ trên ổ cứng.
  • Cập nhật log của nhiều transaction có thể nhóm lại để ghi 1 lượt xuống ổ cứng.
  • Log có thể ở trên ổ cứng khác với database, nhờ đó mà việc ghi log không cần cạnh tranh với thao tác nào khác, thêm nữa vì không có thao tác read nào từ log (trừ khi transaction hoặc system bị fail) nên việc ghi là rất nhanh.

Tìm hiểu thêm tại How do RDBMS provide ACID properties?

Cách làm của PostgreSQL

Thành phần chính của PostgreSQL gồm shared buffers, WAL (write-ahead log) buffers, page cache (kernel buffer) đều lưu dữ liệu tạm thời trên RAM và data files, WAL segment giữ vai trò lưu dữ liệu lâu dài trên ổ cứng.

Một quá trình lưu trữ dữ liệu thay đổi của PostgreSQL diễn ra như sau:

  1. Đầu tiên một process (checkpointer) ghi lại record đánh dấu REDO point vào WAL segment (write-ahead log) ở ổ cứng.
  2. Query đầu tiên, database load table X tương ứng từ ổ cứng lên shared buffer và xử lý trên đấy.
  3. Ngay khi transaction gọi commit, database ghi lại transaction vào WAL buffer rồi flush xuống WAL segment ở ổ cứng, rồi mới return success.
  4. Checkpointer chạy nền chịu trách nhiệm gọi `write()` và `fsync()` cho dữ liệu trong shared buffer xuống kernel buffer và datafile dưới ổ cứng.
  • Giả sử có một query thứ 2 yêu cầu insert vào table X, database sẽ xử lý trực tiếp với dữ liệu có sẵn trong buffer, khi transaction này gọi commit thì thực hiện giống bước 3.
  • Nếu lúc này system crash, toàn bộ data của shared buffer và WAL buffer (trên RAM) mất hết, nhưng tất cả đã được lưu lại trong WAL segment nhờ đó dễ dàng recover lại data.

2. Nơi xuất hiện lỗi

  1. Trên lý thuyết nếu fsync xảy ra lỗi thì lần gọi fsync kế tiếp nó sẽ ‘flush’ phần data trên page cache xuống ổ cứng lần nữa.
  • Nhưng thực tế sau lần lỗi đầu tiên với `fsync` thì toàn bộ data trên page cache sẽ bị discard và lần `fsync` kế tiếp không thể phát hiện ra lỗi, nó sẽ return success về cho user.
  • Ngoài ra, việc discard page cache như thế nào là phụ thuộc vào filesystem (ext4 khác với xfs & btrfs, …) và không có cái nào trong đó flush data xuống ổ cứng lại lần nữa.

2. Vì PostgreSQL server chạy multiprocess nên trên data file sẽ có nhiều file descriptor. Nếu fsync fail ở 1 process cũng sẽ được report ở các process khác.

  • Tuy nhiên thực tế chỉ có process đầu tiên thấy được error.
  • Việc gọi fsync được thực hiện bởi process checkpointer. Process này không giữ trạng thái open với tất cả files liên quan mà chỉ mở trước khi gọi fsync lên file tương ứng cho nên nếu có lỗi xảy ra trước khi open, checkpointer cũng không thể phát hiện được.

Tìm hiểu thêm tại How is it possible that PostgreSQL used fsync incorrectly for 20 years, and what we’ll do about it.

3. Hướng tới một giải pháp

Vài tháng sau bản báo cáo lỗi của Craig Ringer tới pqsql-hackers, trong hội nghị LSFMM (Linux Storage, Filesystem, and Memory Management Summit) Jeff Layton (một contributor/maintainer của nhiều phần quan trọng trong Linux kernel) đã đưa ra một số cải tiến cho việc xử lý error ở block-layer cho Linux kernel. Sau đó vài tuần là phiên bản của các developer PostgreSQL để khắc phục vấn đề của mình.

Theo đó, 1 typedef mới sẽ được thêm vào là errseq_t, 1 số 32 bit giữ error code và sequence number (giữ vai trò như error counter), cho phép record lại error trong address space và report lại trên mỗi file descriptor, nếu sequence number không tăng khi postgre check lại nó thì không có error xảy ra.

  • Tuy đã có bản patch nhưng vẫn không thể đảm bảo error luôn được report lại. Trong trường hợp nếu inode bị xóa khỏi memory, mọi error giữ ở đây đều mất hết.
  • Một vấn đề nữa được đề cập là hàm syncfs() gặp trục trặc trong việc report lỗi của nó. Vấn đề này chỉ mới có kế hoạch khắc phục (vào tháng 4/2018) có thể bằng cách dùng 1 biến errseq_t khác trong superblock vì hàm `syncfs` cần 1 con trỏ riêng biệt cho biến error state.

Nguồn

--

--