Offline-First là một triết lý, không phải một tính năng: Đánh giá 6 tháng đồng bộ hóa SQLite

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Kết nối mạng là một sự xa xỉ, không phải là điều hiển nhiên

Sáu tháng trước, tôi đã xây dựng lại một ứng dụng dịch vụ hiện trường, chuyển từ mô hình REST-heavy tiêu chuẩn sang kiến trúc offline-first hoàn chỉnh. Nhiệm vụ của tôi: loại bỏ biểu tượng tải (loading spinner). Người dùng không nên phải nhìn chằm chằm vào lỗi ‘Không có kết nối’ khi cố gắng lưu dữ liệu quan trọng. Thực tế tại hiện trường, dữ liệu di động rất chập chờn. Các kỹ thuật viên thường xuyên di chuyển giữa các tầng hầm bị chắn sóng và các công trường xa xôi, một thách thức tương tự như khi mở rộng quy mô IoT nơi mà 5G chỉ là một điều xa vời.

Các kiến trúc tiêu chuẩn mặc định rằng server là nguồn dữ liệu gốc (source of truth). Chúng tôi đã đảo ngược điều đó. Trong thế giới của chúng tôi, cơ sở dữ liệu SQLite cục bộ là cơ quan có thẩm quyền cao nhất ngay lập tức. Server đóng vai trò như một bộ tổng hợp dữ liệu toàn cầu và bản sao lưu. Việc đảm bảo an toàn cho máy chủ là rất quan trọng để tránh việc cơ sở dữ liệu “chết” lúc 2 giờ sáng. Sự thay đổi này không chỉ đơn thuần là kỹ thuật; đó là sự thay đổi trong cách bạn tôn trọng thời gian của người dùng.

SQLite vẫn là tiêu chuẩn của ngành công nghiệp vì một lý do: nó vô hình, không cần cấu hình và hiện diện trên hầu hết mọi thiết bị. Những nỗ lực gần đây như Turso và libSQL đang đưa mô hình này lên một tầm cao mới. Thách thức thực sự không nằm ở việc lưu trữ mà ở logic đồng bộ hóa. Điều gì sẽ xảy ra khi hai kỹ thuật viên cùng cập nhật một hệ thống HVAC đồng thời từ các tầng hầm khác nhau? Sau 180 ngày vận hành thực tế, kết quả đã rõ ràng: độ trễ UI biến mất, và các yêu cầu hỗ trợ liên quan đến ‘mất dữ liệu’ đã giảm 40%.

Thiết lập môi trường đồng bộ

Để tái hiện điều này, tôi sử dụng một stack dựa trên Python. Logic này có thể chuyển đổi dễ dàng sang Node.js hoặc Swift. Chúng tôi sử dụng một instance SQLite cục bộ và một server PostgreSQL từ xa làm trung tâm điều phối.

Bắt đầu bằng cách cài đặt các phụ thuộc cốt lõi. Chúng tôi sử dụng dataset để trừu tượng hóa cơ sở dữ liệu một cách sạch sẽ và ulid-py cho các mã định danh hoạt động hiệu quả trong các hệ thống phân tán.

pip install sqlalchemy dataset ulid-py requests

Tôi ưu tiên sử dụng ULID (Universally Unique Lexicographically Sortable Identifiers) thay vì các số nguyên tiêu chuẩn. Chúng là duy nhất trên mọi thiết bị và có thể sắp xếp theo thời gian. Điều này ngăn chặn xung đột ID khi năm mươi thiết bị tạo bản ghi cùng một lúc mà không có kết nối mạng.

import dataset
from ulid import ULID

# Khởi tạo lưu trữ cục bộ
local_db = dataset.connect('sqlite:///local_storage.db')
table = local_db['work_orders']

# Tạo bản ghi sử dụng cờ 'dirty'
work_order_id = str(ULID())
table.insert({
    'id': work_order_id,
    'title': 'Sửa chữa hệ thống HVAC - Đơn vị 4B',
    'status': 'đang chờ',
    'last_updated': 1717845600,
    'sync_status': 'dirty' # 'dirty' kích hoạt trình đồng bộ
})

Công cụ đồng bộ hóa

Việc triển khai của chúng tôi dựa trên hệ thống ‘Cờ bẩn’ (Dirty Flag). Mọi bảng trong cả SQLite và PostgreSQL đều phải bao gồm một mốc thời gian Unix last_updated và một trường sync_status tại cục bộ.

1. Theo dõi thay đổi

Mỗi khi sửa đổi sẽ kích hoạt thay đổi trạng thái. Khi người dùng chỉnh sửa bản ghi, sync_status cục bộ sẽ chuyển sang ‘dirty’. Điều này báo hiệu cho trình chạy ngầm (background worker) ưu tiên hàng này trong lần bắt tay (handshake) tiếp theo.

2. Vòng lặp Đẩy-Kéo (Push-Pull)

Quá trình đồng bộ hóa chạy theo hai giai đoạn riêng biệt: đẩy các thay đổi cục bộ lên và kéo các cập nhật từ các thiết bị khác về.

def sync_with_server():
    # Giai đoạn 1: Đẩy các chỉnh sửa cục bộ
    dirty_records = table.find(sync_status='dirty')
    for record in dirty_records:
        try:
            response = requests.post('https://api.myserver.com/sync', json=record)
            if response.status_code == 200:
                table.update(dict(id=record['id'], sync_status='synced'), ['id'])
        except Exception as e:
            print(f"Đẩy dữ liệu thất bại: {e}")

    # Giai đoạn 2: Lấy các thay đổi mới nhất từ server
    last_sync_time = get_last_sync_timestamp()
    remote_changes = requests.get(f'https://api.myserver.com/changes?since={last_sync_time}').json()
    
    for remote_record in remote_changes:
        local_record = table.find_one(id=remote_record['id'])
        if not local_record or remote_record['last_updated'] > local_record['last_updated']:
            table.upsert(remote_record, ['id'])
            table.update(dict(id=remote_record['id'], sync_status='synced'), ['id'])

3. Giải quyết xung đột

Giải quyết xung đột là nỗi ám ảnh của kiến trúc offline-first. Tuy nhiên, phương pháp ‘Ghi đè sau cùng’ (Last Write Wins – LWW) đã xử lý ổn thỏa 95% trong số hơn 3.000 lệnh làm việc hàng tháng của chúng tôi mà không gặp vấn đề gì. Chúng tôi so sánh mốc thời gian và giữ lại phiên bản mới nhất. Đối với các trường văn bản phức tạp, cuối cùng chúng tôi đã thêm tính năng diff-merge, nhưng tôi khuyên bạn nên thành thạo LWW trước khi cố gắng quá mức (over-engineering) giải pháp của mình.

Xác minh: Duy trì tính toàn vẹn của dữ liệu

Chuyển sang offline-first tạo ra những điểm mù mới. Bạn không thể chỉ dựa vào log của server để xác minh hoạt động của người dùng. Chúng tôi đã triển khai các log tình trạng cục bộ để tải lên định kỳ một ELK stack.

Theo dõi độ trễ đồng bộ

Chúng tôi đã xây dựng một bảng điều khiển để theo dõi ‘Độ trễ đồng bộ’ — khoảng cách giữa thời điểm tạo cục bộ và thời điểm đến server. Nếu mức trung bình toàn hệ thống vượt quá 30 phút, điều đó thường chỉ ra lỗi trong trình chạy ngầm hoặc sự cố mạng khu vực.

Kiểm tra tính toàn vẹn hàng tuần

Các cuộc kiểm tra tính nhất quán ngăn chặn tình trạng dữ liệu bị hỏng âm thầm. Mỗi tuần một lần, client sẽ tính toán mã hash của tất cả các ID cục bộ và gửi nó đến server. Nếu các mã hash không khớp, hệ thống sẽ kích hoạt một quy trình đối soát chính xác. Điều này đã giúp phát hiện ba trường hợp đặc biệt vào tháng trước khi mạng bị ngắt trong quá trình đồng bộ gây ra các bản ghi bị thiếu một phần.

# Logic kiểm tra tính toàn vẹn cơ bản
def verify_integrity():
    local_count = len(table)
    local_hash = calculate_db_hash(table)
    
    status = requests.post('https://api.myserver.com/verify', json={
        'count': local_count,
        'hash': local_hash
    })
    
    if status.json().get('mismatch'):
        trigger_reconciliation()

Thiết kế kiến trúc này đòi hỏi khắt khe, nhưng thành quả là một sản phẩm nhanh nhạy và bền bỉ. Người dùng không quan tâm đến logic đồng bộ SQLite-sang-PostgreSQL. Họ chỉ muốn ứng dụng hoạt động được trong một tầng hầm bê tông. Bằng cách coi cơ sở dữ liệu cục bộ là giao diện chính, bạn sẽ xây dựng được phần mềm tồn tại được trong sự hỗn loạn của thế giới thực.

Share: