Chuyển đến nội dung
MINH VO Ghi chép từ công việc
của một kỹ sư tại Việt Nam
Cơ bản10 phút đọc

Ghi tệp thành công chưa phải xác nhận lưu bền

Tách bộ đệm Python, dữ liệu kernel đã nhận và fsync thành công; thử kết thúc tiến trình đột ngột mà không nhầm với phục hồi sau mất điện.

Mô hình ba cụm khối nhỏ nối với nhau bằng các rãnh và những viên bi gỗ

Một lời gọi hệ thống write thông thường thành công nghĩa là hệ điều hành đã nhận số byte được báo, không có nghĩa các byte ấy sẽ còn sau khi mất điện. Với lập trình viên lưu kết quả hoặc xác nhận cập nhật, phân biệt này quyết định khi nào mô tả “đã lưu” là đúng. Tiến trình thứ hai đọc được giá trị mới là bằng chứng về khả năng nhìn thấy dữ liệu, không phải phép thử độ bền lưu trữ.

Bài này giả định bạn biết tệp, tiến trình và Python cơ bản. Ta tách bộ đệm của ngôn ngữ khỏi bộ nhớ đệm tệp trong kernel, chạy ba đối chứng nhỏ bằng tiến trình con trên Linux và giải thích fsync đổi giao ước ở đâu. Thí nghiệm không ngắt hệ điều hành hay thiết bị lưu trữ. Không được trình bày kết quả như phép thử mất điện.

Xác định bộ đệm nào đã nhận byte

Đối tượng tệp Python có bộ đệm trước tiên có thể sao chép lần ghi nhỏ vào vùng nhớ của tiến trình. Hệ điều hành chưa chắc đã nhận các byte ấy. Tài liệu BufferedWriter của Python mô tả các điều kiện đẩy bộ đệm xuống luồng thô bên dưới, gồm gọi flush, bộ đệm đầy và đóng đối tượng.

Hàm cấp thấp os.write thực hiện ghi theo descriptor và trả số byte đã chuyển. Với I/O tệp cục bộ thông thường có bộ đệm như bài dùng, kernel có thể giữ trang tệp đã sửa trong bộ nhớ rồi ghi xuống sau. Tiến trình khác có thể đọc nội dung mới từ trạng thái tệp nhất quán mà hệ điều hành cung cấp, trong khi khả năng lưu bền trên thiết bị vẫn chưa được xác nhận.

Trang hướng dẫn write của Linux nói rõ trả thành công không bảo đảm dữ liệu đã được lưu xuống đĩa. Nó còn cho phép ghi ngắn: nhận ba byte của yêu cầu mười byte là một phần tiến triển thành công, chưa phải hoàn thành cả yêu cầu. Bên gọi phải tiến theo số byte trả về và tiếp tục với phần đuôi còn lại, hoặc xử lý lỗi.

Đây là các ranh giới riêng. Flush bộ đệm Python chuyển byte xuống luồng tệp thô. Lệnh ghi thông thường giao byte cho kernel theo chế độ I/O của tệp. Thao tác đồng bộ yêu cầu các tầng lưu trữ thiết lập bảo đảm bền hơn. Thiết bị cũng có thể có bộ đệm dễ mất dữ liệu khi mất điện, nên “ổ đĩa đã nhận lệnh” chưa phải ranh giới cuối.

Bộ đệm Python thuộc tiến trình chuyển dữ liệu tới bộ nhớ đệm tệp của kernel qua flush hoặc ghi thô; fsync phối hợp đồng bộ kernel và bộ đệm thiết bị, còn kết thúc tiến trình chỉ xóa tầng thuộc tiến trìnhMở ảnh kích thước đầy đủ ↗

Hình mô tả trách nhiệm logic, không khẳng định mọi lần ghi đều dừng ở mọi ô. Dữ liệu có thể đã được ghi xuống xong trước khi ứng dụng kiểm tra tệp. Điểm cần hiểu là lời gọi đã hoàn tất bảo đảm điều gì, không phải cách suy ra vị trí vật lý hiện tại của từng byte.

Kết thúc tiến trình mà không dừng hệ điều hành

Tiến trình con dưới đây ghi mười byte đã biết. Ở chế độ buffered, nội dung nhỏ nằm vừa trong bộ đệm Python có kích thước chỉ định. Ở written, vòng lặp giao mọi byte cho os.write. Ở synced, cùng vòng lặp được nối bằng os.fsync. Sau đó mọi tiến trình con gọi os._exit, cố ý bỏ qua dọn dẹp và flush bộ đệm của Python.

from pathlib import Path
import os
import subprocess
import sys
import tempfile

child = r'''
import os
import sys

mode, name = sys.argv[1:]
payload = b"answer=42\n"
if mode == "buffered":
    stream = open(name, "wb", buffering=8192)
    assert stream.write(payload) == len(payload)
    os._exit(0)  # Deliberately bypass Python buffer flushing and cleanup.

fd = os.open(name, os.O_WRONLY | os.O_TRUNC)
remaining = memoryview(payload)
while remaining:
    written = os.write(fd, remaining)
    if written <= 0:
        raise OSError("write made no progress")
    remaining = remaining[written:]
if mode == "synced":
    os.fsync(fd)
os._exit(0)
'''

with tempfile.TemporaryDirectory() as directory:
    observed = {}
    for mode in ("buffered", "written", "synced"):
        name = Path(directory) / mode
        name.touch()
        subprocess.run([sys.executable, "-c", child, mode, str(name)],
                       check=True)
        observed[mode] = name.read_bytes()
    assert observed == {
        "buffered": b"",
        "written": b"answer=42\n",
        "synced": b"answer=42\n",
    }
print({mode: len(data) for mode, data in observed.items()})

Trong lần chạy CPython/Linux đã quan sát, tiến trình cha đọc được 0 byte sau tiến trình con có bộ đệm và mười byte sau mỗi tiến trình con còn lại. subprocess.run chờ từng tiến trình con kết thúc, nên cha không tranh đọc với bên ghi còn đang chạy. Các tệp nhỏ và tên đều là dữ liệu giả định tạo trong thư mục tạm, không dùng dữ liệu ứng dụng.

Kết quả đầu cho thấy vì sao mất tiến trình có thể làm mất bộ đệm ngôn ngữ chưa flush. Kết quả thứ hai cho thấy kết thúc tiến trình không xóa byte kernel đã nhận. Kết quả thứ ba xác nhận lời gọi đồng bộ đã trả thành công trước khi tiến trình con thoát. Hai lần đọc lại cuối giống nhau không phân biệt được hành vi khi mất điện.

Python mô tả _exit là bỏ qua dọn dẹp và flush. Ở đây nó là cơ chế kết thúc đột ngột có kiểm soát, không phải khuyến nghị thoát ứng dụng thông thường. Mã không làm máy bị sự cố, ngắt điện, kiểm tra bộ đệm ổ đĩa hay chứng minh phục hồi sau khởi động lại. Đó là các phạm vi lỗi khác.

Cha quan sát 0 byte sau khi thoát với bộ đệm chưa flush, mười byte sau ghi thô hoàn tất và mười byte sau ghi thô cùng fsync thành công; không trường hợp nào thử sống sót qua mất điệnMở ảnh kích thước đầy đủ ↗

Kết quả cũng không nói mọi lần ghi có bộ đệm đều ở riêng trong tiến trình cho tới flush. Lần ghi lớn hơn hoặc bộ đệm đầy có thể kích hoạt ghi thô sớm hơn. Nội dung nhỏ có chủ ý và kích thước bộ đệm rõ ràng làm đối chứng cụ thể này tái lập được; chính sách bộ đệm của ứng dụng vẫn thuộc giao ước của nó.

Flush bộ đệm ứng dụng trước khi đồng bộ tệp

Trên Linux, fsync đồng bộ dữ liệu đã sửa của tệp cùng metadata liên quan, bao gồm việc flush bộ đệm thiết bị cần thiết. Nó chặn cho tới khi thiết bị báo hoàn tất. Thành công là xác nhận đồng bộ tệp phù hợp theo bảo đảm của hệ thống tệp và các tầng lưu trữ, không phải số đo mà script Python này tự thực hiện.

fsync không thể chuyển những byte chỉ còn trong bộ đệm Python mà kernel chưa từng thấy. Vì vậy tài liệu fsync của Python nêu thứ tự flush() rồi os.fsync(f.fileno()) khi bắt đầu từ đối tượng tệp có bộ đệm.

def sync_buffered(stream):
    stream.flush()
    os.fsync(stream.fileno())


with tempfile.TemporaryDirectory() as directory:
    name = Path(directory) / "existing"
    name.write_bytes(b"old-long\n")
    with open(name, "r+b") as stream:
        stream.write(b"new\n")
        stream.truncate()
        sync_buffered(stream)
    assert name.read_bytes() == b"new\n"

Ví dụ ghi đè rồi rút ngắn tệp tạm đã tồn tại. Cắt tệp loại phần đuôi cũ; nếu không, ghi bốn byte sẽ để lại phần còn lại của old-long. Đồng bộ sau cả hai thao tác bao phủ kích thước tệp mới lẫn nội dung vừa ghi. Đây là cập nhật tại chỗ, chỉ có một bên ghi, không có giao thức đồng thời hay phục hồi.

Hàm phụ để lỗi truyền ra ngoài. Bên gọi không được biến ngoại lệ từ flush hoặc đồng bộ thành xác nhận “đã lưu bền” thành công. Một lần ghi một phần trước đó có thể đã đổi tệp, nên thất bại cũng không có nghĩa nội dung cũ còn nguyên. Ứng dụng cần chính sách thử lại, đối chiếu hay phục hồi rõ ràng.

Tách nội dung tệp, tên trong thư mục và cập nhật nguyên tử

Nội dung tệp và mục tên trong thư mục liên quan nhau nhưng là các đối tượng lưu bền khác nhau. Trang hướng dẫn fsync lưu ý đồng bộ tệp chưa chắc đồng bộ mục của nó trong thư mục chứa. Tạo, đổi tên hoặc xóa tên có thể cần đồng bộ cả thư mục liên quan. Các ví dụ tệp tạm ở đây không cài đặt giao thức tạo hoặc thay thế tệp có bảo đảm lưu bền.

Lưu bền cũng khác tính nguyên tử. Cập nhật tại chỗ có thể đi qua trạng thái rỗng, mới một phần hoặc trộn cũ mới trước khi lần đồng bộ cuối thành công. Sự cố trong khoảng ấy không bảo đảm để lại toàn bộ giá trị cũ hoặc toàn bộ giá trị mới. Gọi fsync ở cuối không làm chuỗi thao tác trước đó bỗng trở thành giao dịch không thể chia cắt.

Phần giải thích commit nguyên tử của SQLite nêu rõ các giả định lưu trữ và đặt đồng bộ ở ranh giới cụ thể của journal và cơ sở dữ liệu. Các thao tác đó thuộc một thiết kế phục hồi. Chỉ thêm fsync cuối vào những thay đổi ứng dụng nhiều bước tùy ý không tái tạo giao thức của cơ sở dữ liệu. Bài này xác định ranh giới lưu bền, không cài đặt journal.

fdatasync là lựa chọn liên quan khi không cần đồng bộ metadata không cần thiết. Nó vẫn đồng bộ metadata cần để đọc lại dữ liệu đúng, chẳng hạn kích thước tệp đã đổi. Mô tả nó là “chỉ dữ liệu, không bao giờ metadata” là sai. Muốn chọn giữa nó và fsync phải biết ứng dụng hứa giữ metadata nào.

Định nghĩa xác nhận cùng phép thử lỗi

Giao ước lưu hữu ích chỉ rõ ranh giới đã hoàn tất. “Đã nhận vào bộ đệm tiến trình”, “đã nhìn thấy qua hệ thống tệp” và “đồng bộ tệp đã thành công” mô tả các kết quả khác nhau. Nếu dịch vụ hứa lưu bền trước khi trả lời, đường thành công phải chờ đồng bộ cần thiết và giữ nguyên lỗi, thay vì báo thành công sau lệnh ghi đầu tiên.

Đồng bộ từng cập nhật nhỏ có thể tăng độ trễ và hạn chế thông lượng. Gom nhiều cập nhật trong một lần đồng bộ có thể chia sẻ chi phí, nhưng xác nhận lưu bền cho từng cập nhật phải tôn trọng ranh giới hoàn tất chung ấy. Xác nhận gửi trước khi nhóm được đồng bộ là lời hứa khác. Thí nghiệm không có số đo thời gian để chọn khoảng gom nhóm.

Bảo đảm còn dựa vào hệ thống tệp, hệ điều hành, bộ điều khiển và thiết bị thật tuân thủ giao ước của mình. Tài liệu SQLite bàn các lỗi flush chưa đầy đủ trong lịch sử; những báo cáo ấy không khẳng định mọi hệ thống Linux hiện tại đều có lỗi. Chỉ thử kết thúc tiến trình không xác minh được toàn bộ tầng lưu trữ trước mất điện.

Để luyện tập, thêm stream.flush() ngay trước khi tiến trình con có bộ đệm thoát, rồi dự đoán dữ liệu cha đọc lại. Sau đó sửa vòng ghi thô để mỗi lượt yêu cầu tối đa ba byte và kiểm tra cả mười byte vẫn tới. Cuối cùng, chủ động gây lỗi ghi sau ba byte đầu và xem kết quả một phần mà không báo thành công. Giữ riêng những quan sát ở cấp tiến trình này với phép thử lỗi lưu trữ có kiểm soát trong tương lai.

Nguồn tham khảo & đọc thêm

  1. Linux manual: write(2)
  2. Linux manual: fsync(2)
  3. Python io: buffered streams
  4. Python os: write, fsync and immediate exit
  5. SQLite: Atomic commit and storage assumptions
← Về trang bài viết

Hình minh họa

100%