3 điểm bởi GN⁺ 3 giờ trước | 2 bình luận | Chia sẻ qua WhatsApp
  • SQLite là một RDBMS tự chứa được dùng rộng rãi cho lưu trữ cục bộ và các dự án nhúng, nhưng vì tính tương thích ngược nên vẫn giữ các giá trị mặc định bất lợi cho độ an toàn và hiệu năng
  • Mặc định, nó không ép buộc ràng buộc khóa ngoại và có thể tái sử dụng ROWID, dẫn tới vấn đề toàn vẹn dữ liệu khi bài viết của người dùng đã bị xóa có thể bị gắn sang người dùng khác nhận cùng ID
  • Bảng thông thường vẫn lưu được kiểu dữ liệu khác với cột đã khai báo; bảng STRICT ngăn điều này nhưng phải chỉ định strict cho từng bảng và còn xung đột với cách tận dụng tên kiểu do người dùng tự định nghĩa
  • Trong ghi đồng thời, lỗi SQLITE_BUSY xảy ra ngay lập tức, WAL bị tắt và thiết lập đồng bộ hóa quá thận trọng; mỗi vấn đề có thể được cải thiện bằng các pragma busy_timeout, journal_mode, synchronous
  • Nếu cho phép chọn một gói giá trị mặc định an toàn bằng edition theo năm như PRAGMA edition = 2026, SQLite có thể tiếp tục cải tiến cấu hình mặc định mà không phá vỡ hành vi cũ

Điểm mạnh của SQLite và các giá trị mặc định đã lỗi thời

  • SQLite có thể được xem là tiêu chuẩn thực tế của ngành cho cơ sở dữ liệu trong các dự án nhúng và lưu trữ dữ liệu cục bộ; thậm chí một số phần mềm máy chủ như lobste.rs cũng đang chạy trên SQLite
  • Khác với RDBMS truyền thống chạy như một tiến trình riêng biệt, đây là RDBMS dưới dạng thư viện, nên phần mềm có thể được giữ ở dạng tự chứa
  • Không cần tự viết serializer và parser như với một định dạng tệp thông thường, nên nó kết hợp được ưu điểm của RDBMS và lưu trữ dựa trên tệp
  • Tuy vậy, vẫn còn vấn đề là nhiều giá trị mặc định không phù hợp ảnh hưởng đến toàn vẹn dữ liệu, tính đồng thời và hiệu năng

Vấn đề toàn vẹn do không ép buộc khóa ngoại

  • Khóa ngoại là công cụ cốt lõi để giữ tính nhất quán của cơ sở dữ liệu và ngăn tham chiếu bị đứt, nhưng SQLite mặc định không ép buộc điều này
  • Ngay cả khi khai báo posts.user_id tham chiếu tới users.id, với cấu hình mặc định nó vẫn không ngăn được các hành vi sau
    • Tạo bài viết với ID người dùng không hợp lệ
    • Xóa người dùng nhưng vẫn để lại các bài viết của người đó
  • INTEGER PRIMARY KEY trở thành bí danh của ROWID, và thuật toán cấp phát ROWID của SQLite sẽ tái sử dụng ID trong một số tình huống
    • Bob viết bài với ID 1, rồi dù chỉ xóa tài khoản thì cũng không phát sinh lỗi khóa ngoại
    • Sau đó Alice nhận lại ID 1 đã bị tái sử dụng, và các bài viết cũ của Bob sẽ bị gắn sang Alice
    • Truy vấn vẫn thành công bình thường, nên khó phát hiện hơn cả một tham chiếu bị đứt đơn thuần
  • Có thể ép buộc khóa ngoại bằng cách bật pragma sau cho mỗi kết nối
PRAGMA foreign_keys = ON;
  • Nếu áp dụng điều này ngay từ đầu, việc xóa người dùng vẫn còn bài viết sẽ bị chặn với lỗi FOREIGN KEY constraint failed

Bảng thông thường cho phép kiểu dữ liệu khác với khai báo

  • Giá trị trong SQLite thuộc một trong các loại NULL, INTEGER, REAL, TEXT, BLOB, nhưng khai báo cột trong bảng thông thường không giới hạn nghiêm ngặt kiểu có thể lưu, mà chỉ quyết định type affinity
  • Với cột có affinity INTEGER, các quy tắc lưu sau sẽ được áp dụng
    • TEXT có thể được diễn giải thành số nguyên sẽ được chuyển thành INTEGER
    • TEXT có thể được diễn giải thành số thực sẽ được chuyển thành REAL kiểu dấu phẩy động chính xác kép
    • Các giá trị khác sẽ được lưu nguyên với kiểu ban đầu
  • Các affinity khác cũng có quy tắc chuyển đổi riêng
    • Affinity BLOB lưu nguyên giá trị
    • Affinity TEXT giữ nguyên BLOB, TEXT, NULL và chuyển số thành TEXT
    • Affinity REAL hoạt động như affinity INTEGER nhưng chuyển số nguyên thành REAL
  • Vì vậy, ngay cả cột duration_sec INTEGER cũng có thể lưu chuỗi không phải số như 'Way too long, I mean come on'
  • Trong dự án thực tế, cũng từng có trường hợp thay vì số boolean 1, 0 thì chuỗi '1', '0' lại bị ghi vào, buộc phải dọn dữ liệu và debug

Xung đột giữa bảng STRICT và kiểu do người dùng tự định nghĩa

  • Bảng STRICT sẽ phát sinh lỗi kiểu như cannot store TEXT value in INTEGER column khi đưa dữ liệu sai kiểu vào cột
CREATE TABLE music (
  id INTEGER PRIMARY KEY,
  name TEXT,
  duration_sec INTEGER
) strict;
  • Không có pragma nào để biến mọi bảng thành nghiêm ngặt trên toàn cục, nên phải thêm strict vào từng câu lệnh CREATE TABLE mà không được sót

  • SQLite có quan điểm rằng kiểu linh hoạt là hữu ích, nhưng ngay cả với bảng STRICT, nếu chỉ định kiểu ANY thì vẫn có thể tạo cột lưu mọi loại giá trị

    • Có thể phân biệt rõ ràng giữa các cột cho phép kiểu tùy ý và các cột buộc kiểu cụ thể
  • Vấn đề tương thích thực tế hơn là bảng STRICT không chỉ ép kiểu dữ liệu mà còn thay đổi cả quy tắc diễn giải type specifier

  • Cách bảng thông thường diễn giải tên kiểu

    • Bảng SQLite thông thường xác định affinity theo chuỗi kiểu đã khai báo
    • Nếu chứa "INT" thì được xử lý là INTEGER
    • Nếu chứa một trong "CHAR", "CLOB", "TEXT" thì thành TEXT
    • Nếu chứa "BLOB" hoặc bỏ qua kiểu thì được xử lý là BLOB
    • Nếu chứa một trong "REAL", "FLOA", "DOUB" thì thành REAL
    • Nếu không khớp điều kiện nào thì được xử lý là NUMERIC
    • Khi kết hợp kiểu lỏng với các quy tắc này, có thể gắn tên kiểu do người dùng tự định nghĩa như DATETIME, KEY_VALUE_SET, COLOR cho cột
    • Connector hoặc wrapper cơ sở dữ liệu có thể nhìn vào tên kiểu để tự động serialize/deserialze
    • Kể cả không có xử lý riêng, bản thân tên kiểu cũng giúp tài liệu hóa dữ liệu dự kiến của cột
    • Nếu đổi mặc định sang STRICT như hiện tại thì sẽ phải từ bỏ cách dùng này, nên cần có alias kiểu do người dùng định nghĩa một cách tường minh
    CREATE TYPE KEY_VALUE_SET = TEXT;
    
    • Nếu trong bảng nghiêm ngặt vẫn dùng được alias như KEY_VALUE_SET, có thể ghi lại trong schema mẫu dữ liệu dự kiến của cột TEXT mà ứng dụng phải tự parse
    • Tính năng gắn CHECK constraint vào kiểu tự định nghĩa cũng sẽ hữu ích
    • Chuẩn SQL:1999 đã có sẵn cú pháp alias kiểu hỗ trợ cả constraint là CREATE DOMAIN, nên SQLite có thể triển khai bằng cách hỗ trợ chính câu lệnh chuẩn này

Ghi đồng thời và SQLITE_BUSY

  • SQLite cho phép nhiều lần đọc đồng thời, nhưng chỉ xử lý một lần ghi tại một thời điểm
  • Với cấu hình mặc định, khi hai tiến trình cùng cố lấy khóa ghi thì một bên sẽ không chờ mà nhận ngay lỗi SQLITE_BUSY
    • Ghi có thực hiện I/O đĩa vốn dĩ có thể chậm, nên việc chờ khóa được nhả ra trong một khoảng thời gian sẽ tự nhiên hơn
    • Trong thực tế cũng từng có trường hợp hệ thống bị gián đoạn lẻ tẻ và phải tự viết vòng lặp retry
  • Thiết lập sau chỉ trả về SQLITE_BUSY sau khi đã thử lại việc lấy khóa trong tối đa 5 giây
PRAGMA busy_timeout = 5000;
  • Trong vận hành bình thường, phù hợp nhất là gom mọi lần ghi về một tiến trình duy nhất, lý tưởng nhất là một luồng duy nhất, và ghi đồng thời không thể nhanh hơn
  • Tuy vậy, vẫn có những trường hợp vận hành hợp lệ cần ghi đồng thời
    • Dọn dẹp cơ sở dữ liệu tương tác bằng công cụ dòng lệnh sqlite3
    • Chạy script cho các tác vụ quản trị hiếm gặp khi không có frontend riêng
  • Các công việc như vậy có thể gây ra SQLITE_BUSY ngoài dự kiến do cấu hình mặc định, và làm gián đoạn phần mềm đang chạy

WAL và các giá trị mặc định về hiệu năng

  • Nếu được cấu hình đúng, SQLite có thể đủ nhanh để đảm nhận một phần vai trò vốn dành cho các máy chủ lớn như PostgreSQL hay MySQL, nhưng hiệu năng mặc định lại không tốt
  • Có thể xem thêm phần tinh chỉnh chi tiết cho môi trường máy chủ tại Optimizing SQLite for servers
  • Vấn đề giá trị mặc định quan trọng nhất là Write-Ahead Log, tức WAL đang bị tắt
PRAGMA journal_mode = WAL;
  • WAL giúp tăng đáng kể tốc độ ghi trong phần lớn tình huống
  • Nếu dùng cùng thiết lập sau, có thể giảm mạnh số lần đồng bộ hóa đĩa mà không làm tăng rủi ro hỏng dữ liệu
PRAGMA synchronous = NORMAL;

SQLite edition theo năm

  • Nếu thay đổi trực tiếp các giá trị mặc định hiện tại, phần mềm cũ có thể bị hỏng và người dùng có thể sợ nâng cấp SQLite về sau
  • Có thể thêm một super pragma duy nhất để chọn các giá trị mặc định đã được cải thiện mà vẫn giữ tương thích ngược
PRAGMA edition = 2026;
  • Edition 2026 sẽ hoạt động ít nhất như alias của các thiết lập sau
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
  • Trong edition này, chế độ strict cho bảng mới cũng được áp dụng làm mặc định
  • Cách làm này giống Rust editions: bảo toàn hành vi của mã cũ trong khi cho phép chọn tường minh hành vi mặc định mới
  • Khác với một chế độ đơn lẻ như "use strict"; của JavaScript, edition theo năm có thể thay đổi gói giá trị mặc định hợp lý theo thời đại
  • Ví dụ, nếu WAL2 của Hctree được đưa vào nhánh chính năm 2034, thì PRAGMA edition = 2034 trong tương lai có thể được cấu hình để chọn journal_mode = WAL2
  • Hệ thống edition có thể là một giải pháp dung hòa để phát triển các giá trị mặc định về an toàn và hiệu năng của SQLite mà không phá vỡ hành vi mặc định của các chương trình hiện có

2 bình luận

 
tomskang 2 giờ trước

Về lý thuyết thì nghe có vẻ hay, nhưng nếu nghĩ đến xung đột tương thích và overhead thì tôi thấy có lẽ sẽ lợi bất cập hại.
SQLite là một DBMS ra đời để chạy trong các môi trường nhúng như Aegis,
thế giới nhúng là nơi người ta cứ thoải mái thao tác với void**, khai báo __weak__ rồi ghi đè hàm. Trong một thế giới như vậy, tôi lại nghĩ: có nhất thiết phải làm chuyện đó không?

 
Các ý kiến trên Lobste.rs
  • Hôm qua tôi viết bài này sau khi thấy tin lobste.rs đã chuyển sang SQLite (https://lobste.rs/s/ko1ji1/lobste_rs_is_now_running_on_sqlite)
    Khi đọc cuộc thảo luận lúc đó, tôi nhớ đến những bất tiện nhỏ trong các giá trị mặc định của SQLite nên đã tổng hợp thành bài blog

    • Tôi cũng đọc cuộc thảo luận đó và đi đến cùng kết luận
      Nếu có thời gian, cũng đáng chia sẻ bài này lên diễn đàn SQLite: https://sqlite.org/forum/forum
  • Trong SQL, CREATE TYPE được dùng để tạo kiểu phức hợp, và Postgres hỗ trợ thêm cả range, enum, array, cũng như các kiểu nền tảng khá phức tạp
    Tính năng mong muốn gần với CREATE DOMAIN hơn. Nó cho phép tạo newtype dựa trên một kiểu cụ thể nền tảng, đồng thời chỉ định cả giá trị mặc định và ràng buộc, nhờ đó các schema phải lặp lại cùng một ràng buộc CHECK trên nhiều bảng sẽ gọn hơn nhiều. Ví dụ có thể viết như create domain mything as text; hoặc create domain mything as integer not null check (VALUE < 5);

    • Lẽ ra nên kiểm tra trước xem đã có tính năng bí danh kiểu hiện có chưa. Tôi sẽ bổ sung nội dung liên quan vào bài viết
  • Cũng cần đề cập đến một cái bẫy khá nguy hiểm: chỉ riêng busy_timeout không thể ngăn lỗi SQLITE_BUSY xảy ra ngay lập tức
    Sau khi bắt đầu transaction bằng một truy vấn chỉ đọc, nếu chạy truy vấn ghi để cố nâng cấp khóa đang giữ mà có thao tác ghi khác, nó sẽ thất bại ngay lập tức bất kể thiết lập busy_timeout. Cách giải quyết là dùng begin immediate

    • Tôi biết đây không phải bug thực sự, nhưng nói thật thì đây là hành vi gần như cảm giác như bug
  • Cần cân nhắc rằng trong số các thiết lập được gom vào edition được đề xuất, một số là thuộc tính của kết nối trong mô hình SQLite, còn phần còn lại là thuộc tính của file cơ sở dữ liệu hoặc của từng bảng riêng lẻ
    PRAGMA journal_mode = WAL được duy trì trong file cơ sở dữ liệu, nhưng PRAGMA foreign_keys = ON phải được thiết lập lại cho mỗi kết nối. STRICT là thuộc tính của từng bảng, nên trong cùng một cơ sở dữ liệu cũng có thể trộn bảng strict và bảng không strict. Vì vậy cần quyết định PRAGMA hợp nhất này sẽ được định nghĩa là thuộc tính của file cơ sở dữ liệu hay của kết nối. Tôi cũng tò mò liệu có cơ chế tích hợp nào cảnh báo để thư viện phiên bản cũ không chỉnh sửa rồi làm hỏng file có chứa tính năng mà nó không biết hay không

  • Cách tiếp cận edition về cơ bản có vẻ là một thỏa hiệp tốt giữa mục tiêu duy trì tương thích ngược của đội ngũ phát triển SQLite và các giá trị mặc định ít gây rối hơn cho người dùng muốn các thiết lập khuyến nghị mới nhất

  • Gần đây tôi rất mê RocksDB. Nó cũng là thư viện cơ sở dữ liệu nhúng như SQLite, nhưng việc không có SQL lại là một lợi thế lớn theo tôi
    Tôi cảm thấy ràng buộc phải biểu diễn mọi thao tác bằng SQL chỉ cản trở việc làm việc với cơ sở dữ liệu

    • RocksDB là cơ sở dữ liệu key-value, còn SQLite là cơ sở dữ liệu quan hệ, nên chúng là những khái niệm riêng biệt được tạo ra cho các mục đích khác nhau
      Khó hình dung việc chuyển một hệ thống dùng SQL như lobste.rs sang RocksDB hay một cơ sở dữ liệu key-value khác sẽ như thế nào
  • Tôi không đồng ý rằng đặt busy_timeout là giải pháp. SQLite có hiệu năng rất tốt nếu dùng một luồng ghi duy nhất và xử lý theo lô các transaction logic không liên quan với nhau
    Cách làm cụ thể được giải thích tại https://andersmurphy.com/2025/12/…

    • Với workload thông thường, đúng là nên giới hạn thao tác ghi về một luồng và tránh tạo cấu trúc để nhiều thao tác ghi cạnh tranh thường xuyên
      Nhưng cũng thường có việc chỉnh sửa trực tiếp cơ sở dữ liệu để dọn dẹp, hoặc chạy script cho các tác vụ quản trị không thường xuyên đến mức không cần xây riêng màn hình quản trị. Khi đó, việc server hay ứng dụng có thể dừng ngẫu nhiên chỉ vì không may thời điểm chạy bị trùng nhau là điều không tốt