Lưu trữ dữ liệu lâu bền và API tệp Linux

Trong khi nghiên cứu tính bền vững của việc lưu trữ dữ liệu trong hệ thống đám mây, tôi quyết định tự kiểm tra để đảm bảo rằng mình hiểu những điều cơ bản. TÔI bắt đầu bằng cách đọc đặc tả NVMe để hiểu những gì đảm bảo liên quan đến việc lưu trữ dữ liệu bền vững (nghĩa là đảm bảo rằng dữ liệu sẽ có sẵn sau khi hệ thống bị lỗi), hãy cung cấp cho chúng tôi các đĩa NMVe. Tôi đã đưa ra những kết luận chính sau: dữ liệu phải được coi là bị hỏng kể từ thời điểm lệnh ghi dữ liệu được đưa ra cho đến thời điểm nó được ghi vào phương tiện lưu trữ. Tuy nhiên, hầu hết các chương trình đều sử dụng lệnh gọi hệ thống để ghi dữ liệu một cách khá vui vẻ.

Trong bài đăng này, tôi khám phá các cơ chế lưu trữ liên tục được cung cấp bởi API tệp Linux. Có vẻ như mọi thứ ở đây đều đơn giản: chương trình gọi lệnh write()và sau khi lệnh này hoàn thành, dữ liệu sẽ được lưu an toàn vào đĩa. Nhưng write() chỉ sao chép dữ liệu ứng dụng vào bộ đệm kernel nằm trong RAM. Để buộc hệ thống ghi dữ liệu vào đĩa, bạn cần sử dụng một số cơ chế bổ sung.

Lưu trữ dữ liệu lâu bền và API tệp Linux

Nhìn chung, tài liệu này là tập hợp các ghi chú liên quan đến những gì tôi đã học được về một chủ đề mà tôi quan tâm. Nếu chúng ta nói rất ngắn gọn về điều quan trọng nhất, thì hóa ra để tổ chức lưu trữ dữ liệu bền vững, bạn cần sử dụng lệnh fdatasync() hoặc mở tập tin có cờ O_DSYNC. Nếu bạn muốn tìm hiểu thêm về những gì xảy ra với dữ liệu trên đường từ mã tới đĩa, hãy xem điều này bài báo.

Đặc điểm của việc sử dụng hàm write()

Cuộc gọi hệ thống write() được xác định trong tiêu chuẩn IEEE POSIX như một nỗ lực để ghi dữ liệu vào một bộ mô tả tập tin. Sau khi hoàn thành thành công write() Hoạt động đọc dữ liệu phải trả về chính xác các byte đã được ghi trước đó, thực hiện việc này ngay cả khi dữ liệu được truy cập từ các tiến trình hoặc luồng khác (đây phần liên quan của tiêu chuẩn POSIX). Здесь, trong phần về cách các luồng tương tác với các thao tác tệp thông thường, có một lưu ý cho biết rằng nếu hai luồng gọi các hàm này thì mỗi lệnh gọi phải xem tất cả các kết quả được chỉ định của lệnh gọi kia hoặc không thấy gì cả. hậu quả. Điều này dẫn đến kết luận rằng tất cả các hoạt động I/O của tệp phải giữ một khóa đối với tài nguyên mà chúng đang hoạt động.

Phải chăng điều này có nghĩa là hoạt động write() nó có phải là nguyên tử không? Từ quan điểm kỹ thuật, có. Hoạt động đọc dữ liệu phải trả về tất cả hoặc không có gì được ghi bằng write(). Nhưng hoạt động write(), theo tiêu chuẩn, không nhất thiết phải kết thúc bằng việc viết ra tất cả những gì được yêu cầu viết ra. Cô ấy chỉ được phép viết một phần dữ liệu. Ví dụ: chúng ta có thể có hai luồng, mỗi luồng nối thêm 1024 byte vào một tệp được mô tả bởi cùng một bộ mô tả tệp. Từ quan điểm của tiêu chuẩn, một kết quả có thể chấp nhận được là khi mỗi thao tác ghi chỉ có thể thêm một byte vào tệp. Các thao tác này sẽ vẫn là nguyên tử, nhưng sau khi hoàn thành, dữ liệu chúng ghi vào tệp sẽ bị trộn lẫn. Đây cuộc thảo luận rất thú vị về chủ đề này trên Stack Overflow.

Hàm fsync() và fdatasync()

Cách dễ nhất để xóa dữ liệu vào đĩa là gọi hàm fsync (). Chức năng này yêu cầu hệ điều hành chuyển tất cả các khối đã sửa đổi từ bộ đệm vào đĩa. Điều này bao gồm tất cả siêu dữ liệu tệp (thời gian truy cập, thời gian sửa đổi tệp, v.v.). Tôi tin rằng siêu dữ liệu này hiếm khi cần thiết, vì vậy nếu bạn biết rằng nó không quan trọng đối với mình, bạn có thể sử dụng chức năng fdatasync(). Trong Cứu giúp trên fdatasync() Người ta nói rằng trong quá trình vận hành chức năng này, một lượng siêu dữ liệu như vậy sẽ được lưu vào đĩa “cần thiết để thực hiện chính xác các hoạt động đọc dữ liệu sau đây”. Và đây chính xác là điều mà hầu hết các ứng dụng đều quan tâm.

Một vấn đề có thể nảy sinh ở đây là các cơ chế này không đảm bảo rằng tệp sẽ có thể được tìm thấy sau một lỗi có thể xảy ra. Đặc biệt, khi tạo một file mới, bạn cần gọi fsync() cho thư mục chứa nó. Nếu không, sau khi thất bại, có thể tệp này không tồn tại. Lý do là trong UNIX, do sử dụng liên kết cứng nên một file có thể tồn tại ở nhiều thư mục. Vì vậy, khi gọi fsync() không có cách nào để một tập tin biết được dữ liệu thư mục nào cũng sẽ được chuyển vào đĩa (đây Bạn có thể đọc thêm về điều này). Có vẻ như hệ thống tập tin ext4 có khả năng tự động áp dụng fsync() vào các thư mục chứa các tệp tương ứng, nhưng điều này có thể không xảy ra với các hệ thống tệp khác.

Cơ chế này có thể được triển khai khác nhau trên các hệ thống tệp khác nhau. tôi đã sử dụng blktrace để tìm hiểu về những thao tác đĩa nào được sử dụng trong hệ thống tệp ext4 và XFS. Cả hai đều đưa ra lệnh ghi thường xuyên vào đĩa cho cả nội dung tệp và nhật ký hệ thống tệp, xóa bộ đệm và thoát bằng cách thực hiện FUA (Truy cập đơn vị bắt buộc, ghi dữ liệu trực tiếp vào đĩa, bỏ qua bộ đệm) ghi vào nhật ký. Họ có thể làm điều này để xác nhận rằng giao dịch đã diễn ra. Trên các ổ đĩa không hỗ trợ FUA, điều này gây ra hai lần xóa bộ nhớ đệm. Thí nghiệm của tôi cho thấy rằng fdatasync() nhanh hơn một chút fsync(). Tính thiết thực blktrace chỉ ra rằng fdatasync() thường ghi ít dữ liệu vào đĩa (trong ext4 fsync() viết 20 KiB, và fdatasync() - 16 KiB). Ngoài ra, tôi phát hiện ra rằng XFS nhanh hơn một chút so với ext4. Và ở đây với sự giúp đỡ blktrace đã tìm ra được điều đó fdatasync() xóa ít dữ liệu hơn vào đĩa (4 KiB trong XFS).

Các tình huống mơ hồ phát sinh khi sử dụng fsync()

Tôi có thể nghĩ đến ba tình huống mơ hồ liên quan đến fsync()mà tôi gặp phải trong thực tế.

Trường hợp đầu tiên như vậy xảy ra vào năm 2008. Sau đó, giao diện Firefox 3 bị treo nếu một số lượng lớn tệp được ghi vào đĩa. Vấn đề là việc triển khai giao diện đã sử dụng cơ sở dữ liệu SQLite để lưu trữ thông tin về trạng thái của nó. Sau mỗi thay đổi xảy ra trong giao diện, hàm này được gọi fsync(), điều này đảm bảo tốt cho việc lưu trữ dữ liệu ổn định. Trong hệ thống tập tin ext3 được sử dụng sau đó, hàm fsync() đã đổ tất cả các trang "bẩn" trong hệ thống vào đĩa chứ không chỉ những trang có liên quan đến tệp tương ứng. Điều này có nghĩa là việc nhấp vào một nút trong Firefox có thể kích hoạt hàng megabyte dữ liệu được ghi vào đĩa từ, quá trình này có thể mất nhiều giây. Giải pháp cho vấn đề, theo như tôi hiểu từ tài liệu là chuyển công việc với cơ sở dữ liệu sang các tác vụ nền không đồng bộ. Điều này có nghĩa là Firefox trước đây đã triển khai các yêu cầu lưu trữ nghiêm ngặt hơn mức thực sự cần thiết và các tính năng của hệ thống tệp ext3 chỉ làm trầm trọng thêm vấn đề này.

Vấn đề thứ hai xảy ra vào năm 2009. Sau đó, sau một sự cố hệ thống, người dùng hệ thống tệp ext4 mới phải đối mặt với thực tế là nhiều tệp mới được tạo có độ dài bằng 3, nhưng điều này không xảy ra với hệ thống tệp ext3 cũ hơn. Trong đoạn trước, tôi đã nói về việc extXNUMX chuyển quá nhiều dữ liệu vào đĩa như thế nào, điều này làm mọi thứ chậm đi rất nhiều. fsync(). Để cải thiện tình hình, trong ext4 chỉ những trang bẩn có liên quan đến một tệp cụ thể mới được xóa vào đĩa. Và dữ liệu từ các tệp khác sẽ lưu lại trong bộ nhớ lâu hơn nhiều so với ext3. Điều này được thực hiện để cải thiện hiệu suất (theo mặc định, dữ liệu ở trạng thái này trong 30 giây, bạn có thể định cấu hình điều này bằng cách sử dụng dirty_expire_centisecs; đây Bạn có thể tìm thêm tài liệu về điều này). Điều này có nghĩa là một lượng lớn dữ liệu có thể bị mất không thể cứu vãn được sau khi xảy ra lỗi. Giải pháp cho vấn đề này là sử dụng fsync() trong các ứng dụng cần đảm bảo lưu trữ dữ liệu ổn định và bảo vệ chúng nhiều nhất có thể khỏi hậu quả của lỗi. Chức năng fsync() hoạt động hiệu quả hơn nhiều khi sử dụng ext4 so với khi sử dụng ext3. Nhược điểm của phương pháp này là việc sử dụng nó, như trước đây, sẽ làm chậm quá trình thực hiện một số thao tác, chẳng hạn như cài đặt chương trình. Xem chi tiết về điều này đây и đây.

Vấn đề thứ ba liên quan đến fsync(), bắt nguồn từ năm 2018. Sau đó, trong khuôn khổ dự án PostgreSQL, người ta nhận thấy rằng nếu hàm fsync() gặp lỗi thì nó đánh dấu trang "bẩn" là "sạch". Kết quả là, các cuộc gọi sau fsync() Họ không làm gì với những trang như vậy. Vì lý do này, các trang đã sửa đổi sẽ được lưu trữ trong bộ nhớ và không bao giờ được ghi vào đĩa. Đây là một thảm họa thực sự, vì ứng dụng sẽ nghĩ rằng một số dữ liệu được ghi vào đĩa, nhưng thực tế thì không phải vậy. Những thất bại như vậy fsync() rất hiếm, ứng dụng trong những tình huống như vậy hầu như không thể làm gì để giải quyết vấn đề. Ngày nay, khi điều này xảy ra, PostgreSQL và các ứng dụng khác sẽ gặp sự cố. Здесь, trong tài liệu “Ứng dụng có thể phục hồi sau lỗi fsync không?”, vấn đề này được khám phá chi tiết. Hiện tại giải pháp tốt nhất cho vấn đề này là sử dụng I/O trực tiếp với cờ O_SYNC hoặc với một lá cờ O_DSYNC. Với phương pháp này, hệ thống sẽ báo cáo các lỗi có thể xảy ra trong các thao tác ghi cụ thể, nhưng phương pháp này yêu cầu ứng dụng phải tự quản lý bộ đệm. Đọc thêm về điều này đây и đây.

Mở tệp bằng cờ O_SYNC và O_DSYNC

Hãy quay lại thảo luận về các cơ chế Linux cung cấp khả năng lưu trữ dữ liệu ổn định. Cụ thể là chúng ta đang nói về việc sử dụng cờ O_SYNC hoặc cờ O_DSYNC khi mở tập tin bằng lệnh gọi hệ thống mở(). Với cách tiếp cận này, mỗi thao tác ghi dữ liệu được thực hiện như thể sau mỗi lệnh write() hệ thống được đưa ra các lệnh tương ứng fsync() и fdatasync(). Trong Thông số POSIX việc này được gọi là "Hoàn thành tính toàn vẹn tệp I/O được đồng bộ hóa" và "Hoàn thành tính toàn vẹn của dữ liệu". Ưu điểm chính của phương pháp này là để đảm bảo tính toàn vẹn dữ liệu, bạn chỉ cần thực hiện một cuộc gọi hệ thống chứ không phải hai (ví dụ - write() и fdatasync()). Nhược điểm chính của phương pháp này là tất cả việc ghi bằng bộ mô tả tệp tương ứng sẽ được đồng bộ hóa, điều này có thể hạn chế khả năng cấu trúc mã ứng dụng.

Sử dụng I/O trực tiếp với cờ O_DIRECT

Cuộc gọi hệ thống open() hỗ trợ cờ O_DIRECT, được thiết kế để bỏ qua bộ nhớ đệm của hệ điều hành nhằm thực hiện các thao tác I/O bằng cách tương tác trực tiếp với đĩa. Trong nhiều trường hợp, điều này có nghĩa là các lệnh ghi do chương trình đưa ra sẽ được dịch trực tiếp thành các lệnh nhằm làm việc với đĩa. Nhưng nhìn chung cơ chế này không thay thế được chức năng fsync() hoặc fdatasync(). Thực tế là bản thân đĩa có thể trì hoãn hoặc lưu trữ lệnh ghi dữ liệu tương ứng. Và tệ hơn nữa, trong một số trường hợp đặc biệt, các thao tác I/O được thực hiện khi sử dụng cờ O_DIRECT, phát tin vào các hoạt động đệm truyền thống. Cách dễ nhất để giải quyết vấn đề này là sử dụng cờ để mở file O_DSYNC, điều này có nghĩa là mỗi thao tác ghi sẽ được theo sau bởi một lệnh gọi fdatasync().

Hóa ra hệ thống tệp XFS gần đây đã thêm một "đường dẫn nhanh" cho O_DIRECT|O_DSYNC-ghi dữ liệu. Nếu một khối được viết lại bằng cách sử dụng O_DIRECT|O_DSYNC, thì XFS, thay vì xóa bộ đệm, sẽ thực thi lệnh ghi FUA nếu thiết bị hỗ trợ. Tôi đã xác minh điều này bằng cách sử dụng tiện ích blktrace trên hệ thống Linux 5.4/Ubuntu 20.04. Cách tiếp cận này sẽ hiệu quả hơn, vì khi sử dụng, một lượng dữ liệu tối thiểu sẽ được ghi vào đĩa và một thao tác được sử dụng thay vì hai thao tác (ghi và xóa bộ đệm). Tôi tìm thấy một liên kết đến 2018 kernel, thực hiện cơ chế này. Có một số cuộc thảo luận ở đó về việc áp dụng tối ưu hóa này cho các hệ thống tệp khác, nhưng theo như tôi biết, XFS là hệ thống tệp duy nhất hỗ trợ điều này cho đến nay.

hàm sync_file_range()

Linux có một cuộc gọi hệ thống sync_file_range(), cho phép bạn chỉ chuyển một phần của tệp vào đĩa chứ không phải toàn bộ tệp. Cuộc gọi này bắt đầu quá trình xóa dữ liệu không đồng bộ và không đợi nó hoàn thành. Nhưng trong giấy chứng nhận sync_file_range() đội được cho là "rất nguy hiểm". Nó không được khuyến khích sử dụng nó. Đặc điểm và mối nguy hiểm sync_file_range() được mô tả rất tốt trong Điều này vật liệu. Cụ thể, lệnh gọi này dường như sử dụng RocksDB để kiểm soát thời điểm kernel xóa dữ liệu bẩn vào đĩa. Nhưng đồng thời, để đảm bảo việc lưu trữ dữ liệu ổn định người ta còn sử dụng fdatasync(). Trong mã số RocksDB có một số nhận xét thú vị về chủ đề này. Ví dụ, có vẻ như cuộc gọi sync_file_range() Khi sử dụng ZFS, nó không xóa dữ liệu vào đĩa. Kinh nghiệm cho tôi biết rằng mã ít được sử dụng thường có khả năng chứa lỗi. Vì vậy, tôi khuyên bạn không nên sử dụng lệnh gọi hệ thống này trừ khi thực sự cần thiết.

Các cuộc gọi hệ thống giúp đảm bảo tính bền vững của dữ liệu

Tôi đã đi đến kết luận rằng có ba cách tiếp cận có thể được sử dụng để thực hiện các thao tác I/O nhằm đảm bảo tính bền vững của dữ liệu. Tất cả đều yêu cầu một lệnh gọi hàm fsync() cho thư mục mà tập tin được tạo. Đây là những cách tiếp cận:

  1. Gọi hàm fdatasync() hoặc fsync() sau hàm write() (tốt hơn nên sử dụng fdatasync()).
  2. Làm việc với bộ mô tả tệp được mở bằng cờ O_DSYNC hoặc O_SYNC (tốt hơn - với một lá cờ O_DSYNC).
  3. Sử dụng lệnh pwritev2() với cờ RWF_DSYNC hoặc RWF_SYNC (tốt nhất là có cờ RWF_DSYNC).

Ghi chú hiệu suất

Tôi chưa đo lường cẩn thận hiệu suất của các cơ chế khác nhau mà tôi đã kiểm tra. Sự khác biệt mà tôi nhận thấy ở tốc độ làm việc của họ là rất nhỏ. Điều này có nghĩa là tôi có thể sai và trong những điều kiện khác nhau, cùng một điều có thể tạo ra những kết quả khác nhau. Đầu tiên, tôi sẽ nói về điều gì ảnh hưởng đến hiệu suất nhiều hơn và sau đó là điều gì ảnh hưởng đến hiệu suất ít hơn.

  1. Ghi đè dữ liệu tệp nhanh hơn việc thêm dữ liệu vào tệp (lợi ích hiệu suất có thể là 2-100%). Việc thêm dữ liệu vào tệp yêu cầu thay đổi bổ sung đối với siêu dữ liệu của tệp, ngay cả sau lệnh gọi hệ thống fallocate(), nhưng mức độ của hiệu ứng này có thể khác nhau. Tôi khuyên bạn nên gọi để có hiệu suất tốt nhất fallocate() để phân bổ trước không gian cần thiết. Sau đó, không gian này phải được điền rõ ràng bằng số XNUMX và được gọi fsync(). Điều này sẽ đảm bảo rằng các khối tương ứng trong hệ thống tệp được đánh dấu là "được phân bổ" thay vì "chưa được phân bổ". Điều này mang lại sự cải thiện hiệu suất nhỏ (khoảng 2%). Ngoài ra, một số ổ đĩa có thể có lần truy cập đầu tiên vào khối chậm hơn những ổ khác. Điều này có nghĩa là việc lấp đầy không gian bằng số 100 có thể dẫn đến cải thiện hiệu suất đáng kể (khoảng XNUMX%). Đặc biệt, điều này có thể xảy ra với đĩa AWS EBS (đây là dữ liệu không chính thức, tôi không thể xác nhận được). Việc lưu trữ cũng vậy Đĩa liên tục GCP (và đây đã là thông tin chính thức, được xác nhận bằng các cuộc kiểm tra). Các chuyên gia khác cũng làm như vậy quan sát, liên quan đến các đĩa khác nhau.
  2. Càng ít cuộc gọi hệ thống, hiệu suất càng cao (mức tăng có thể khoảng 5%). Có vẻ như là một thử thách open() với cờ O_DSYNC hoặc gọi pwritev2() với cờ RWF_SYNC nhanh hơn một cuộc gọi fdatasync(). Tôi nghi ngờ rằng vấn đề ở đây là cách tiếp cận này đóng một vai trò trong thực tế là phải thực hiện ít cuộc gọi hệ thống hơn để giải quyết cùng một vấn đề (một cuộc gọi thay vì hai). Nhưng sự khác biệt về hiệu suất là rất nhỏ, vì vậy bạn hoàn toàn có thể bỏ qua nó và sử dụng thứ gì đó trong ứng dụng để không làm phức tạp logic của nó.

Nếu bạn quan tâm đến chủ đề lưu trữ dữ liệu bền vững, đây là một số tài liệu hữu ích:

  • Phương thức truy cập I/O - tổng quan về các vấn đề cơ bản của cơ chế đầu vào/đầu ra.
  • Đảm bảo dữ liệu đến đĩa — câu chuyện về điều gì xảy ra với dữ liệu trên đường từ ứng dụng đến đĩa.
  • Khi nào bạn nên fsync thư mục chứa - câu trả lời cho câu hỏi khi nào nên sử dụng fsync() cho các thư mục. Nói một cách ngắn gọn, hóa ra bạn cần phải làm điều này khi tạo một tệp mới và lý do cho khuyến nghị này là trong Linux có thể có nhiều tham chiếu đến cùng một tệp.
  • Máy chủ SQL trên Linux: Nội bộ FUA - đây là mô tả về cách triển khai lưu trữ dữ liệu liên tục trong SQL Server trên nền tảng Linux. Có một số so sánh thú vị giữa các lệnh gọi hệ thống Windows và Linux ở đây. Tôi gần như chắc chắn rằng nhờ tài liệu này mà tôi đã biết được cách tối ưu hóa FUA của XFS.

Bạn có bị mất dữ liệu mà bạn cho rằng đã được lưu trữ an toàn trên đĩa không?

Lưu trữ dữ liệu lâu bền và API tệp Linux

Lưu trữ dữ liệu lâu bền và API tệp Linux

Nguồn: www.habr.com