หนึ่งวินาทีเป็นหน่วยเล็ก ๆ จนกว่าคุณจะจดมันลงทุกวินาทีของวันทำงาน การสุ่มตัวอย่างที่ 1 Hz เป็นเวลาแปดชั่วโมงคือ 28,800 แถว หนึ่งปีเต็มของคนที่นั่งทำงานอยู่ที่โต๊ะก็เกินเจ็ดล้านแถว และทุกแถวก็เล็ก น่าเบื่อ และเหมือนแถวข้าง ๆ เป๊ะ คำถามที่น่าสนใจสำหรับสถาปนิกไม่ใช่ว่าจะเก็บเจ็ดล้านแถวอย่างไร — ดาตาเบสไหน ๆ ก็ทำได้ขณะหลับ — แต่คือจะเก็บมันบนเครื่อง เครื่องเดียว ที่เป็นของคน คนเดียว โดย ไม่มีเซิร์ฟเวอร์อยู่ในวงจร ได้อย่างไร ให้ไฟล์ยังเปิดได้ คิวรีได้ และน่าเชื่อถือในปี 2050
นั่นคือโจทย์การออกแบบเบื้องหลัง Timex แอปจับเวลาอัตโนมัติของเราบน Mac Timex เป็นผลิตภัณฑ์โอเพนซอร์สแบบปิด — แอปที่คุณซื้อที่ gettimex.app — แต่สถาปัตยกรรมที่อยู่ใต้มันคือสิ่งที่วิศวกรทุกคนที่สร้างเครื่องมือเดสก์ท็อปต้องคิดทบทวนในที่สุด และไม่มีอะไรเป็นความลับ ดังนั้นโพสต์นี้คือคู่มือการออกแบบ ไม่ใช่การทิ้งซอร์สโค้ด ผมจะพูดถึงแพตเทิร์น: local-first, ไฟล์ SQLite ฝังตัวไฟล์เดียว, การสุ่มตัวอย่างที่ 1 Hz, และการม้วนวินาทีดิบเป็นเซสชันที่มนุษย์จริง ๆ จำได้ สคีมาและคิวรีด้านล่างผมเขียนขึ้นเองเพื่อโพสต์นี้เพื่อแสดงรูปทรง ไม่ใช่การก๊อปปี้จากผลิตภัณฑ์
ชุดเครื่องมือนักพัฒนาของ Muvon — Octomind, Octocode, และที่เหลือ — เป็นโอเพนซอร์สภายใต้ Apache-2.0 Timex คือตัวที่เราเก็บเป็นแบบปิด สถาปัตยกรรมก็ยังควรค่าแก่การเล่าอย่างซื่อตรง
Local-first คือการตัดสินใจ ไม่ใช่อารมณ์
"Local-first" ถูกใช้เป็นคำการตลาด ซึ่งน่าเสียดาย เพราะจริง ๆ แล้วมันเป็นการตัดสินใจเชิงสถาปัตยกรรมที่รับน้ำหนัก พร้อมผลพวงที่คุณผูกมัดตั้งแต่วันแรก
การตัดสินใจคือ: แหล่งความจริงอยู่บนอุปกรณ์ของผู้ใช้ และแอปทำงานได้ครบถ้วนเมื่อถอดเครือข่ายออก ไม่ใช่ "ทำงานออฟไลน์แล้วซิงก์ทีหลัง" — นั่นคือ offline-first ซึ่งเป็นสถาปัตยกรรมซิงก์ที่มีแคชโลคัลขันสกรูแปะไว้ข้างบน Local-first หมายถึงไม่มี ทีหลัง เลย ไม่มีเซิร์ฟเวอร์ที่ถือสำเนาทางการ ไฟล์บนดิสก์ คือ ความทรงจำของผลิตภัณฑ์ และทุกอย่างที่เหลือเป็นเพียงมุมมองของมัน
สำหรับแอปจับเวลาเดสก์ท็อปผู้ใช้คนเดียว นี่ไม่ใช่การตัดสินใจที่ยาก ลองไล่ทางเลือก:
- ดาตาเบสบนคลาวด์ ตอนนี้คุณต้องเดินเครื่องเซิร์ฟเวอร์ คุณมี round-trip เครือข่ายบนเส้นทางเขียนสำหรับข้อมูลที่ถูกสร้างวินาทีละครั้ง ตลอดกาล โดยผู้ใช้ทุกคน คุณมีงบสำหรับ outage, กลยุทธ์สำรองข้อมูล, พื้นผิว GDPR, สิ่งที่ถูกเจาะได้ และบิลที่เรียกซ้ำซึ่งคุณต้องผลักไปให้ผู้ใช้เป็นค่าสมาชิก สำหรับเครื่องมือที่งานทั้งหมดคือบันทึกว่า คนคนหนึ่ง ทำอะไรบน Mac เครื่องหนึ่ง เซิร์ฟเวอร์คือภาระล้วน ๆ
- ล็อกไบนารีของตัวเอง ชวนหลงใหล — append อย่างเดียว เร็ว กระชับ แต่ตอนนี้คุณเป็นเจ้าของฟอร์แมตตลอดไป คุณเขียนตัวอ่าน เครื่องมือไมเกรชัน ลอจิกบีบอัด โค้ดกู้คืนจากแครช วันที่ผู้ใช้อยากจะ "แค่คิวรีข้อมูลของฉัน" คุณต้องส่งมอบเครื่องคิวรี คุณประดิษฐ์ดาตาเบสขึ้นใหม่ แบบห่วย ๆ และขังผู้ใช้ไว้ในฟอร์แมตที่มีแต่โค้ดของคุณเข้าใจ
- ดาตาเบส SQL ฝังตัวในไฟล์เดียว หนึ่งไฟล์ ไม่มีเซิร์ฟเวอร์ ทรานแซกชัน ACID ภาษาคิวรีที่ทุกคนรู้จักอยู่แล้ว ฟอร์แมตไฟล์ที่เสถียรมากว่าสองทศวรรษ และเป็นฟอร์แมตจัดเก็บที่หอสมุดรัฐสภาสหรัฐแนะนำอย่างเป็นทางการสำหรับชุดข้อมูล อ่านได้ด้วยเครื่องมือนับร้อยที่คุณไม่ได้เขียน
ทางเลือกที่สามคือ SQLite และสำหรับแอปเดสก์ท็อปที่มีผู้เขียนคนเดียว มันไม่ใช่การประนีประนอม — มันคือคำตอบที่ถูกต้อง เรื่องความเป็นส่วนตัวที่ผู้คนชอบ ("ไม่มีคลาวด์ ไม่มีเทเลเมทรี ข้อมูลของคุณอยู่บน Mac ของคุณ") เป็นผลสืบเนื่องจากสิ่งนี้ มันไม่ใช่นโยบายที่เราเขียน มันคือสถาปัตยกรรม: ไม่มี endpoint ที่ข้อมูลกิจกรรมของคุณจะรั่วออกไปได้ เพราะมันไม่เคยออกจากไฟล์ (การเรียกเครือข่ายเดียวที่ Timex ทำคือการเปิดใช้งานไลเซนส์ — ประวัติการติดตามของคุณไม่มีส่วนในนั้น)
ทำไมต้อง SQLite โดยเฉพาะ
SQLite เป็นดาตาเบสที่ถูกติดตั้งมากที่สุดในโลก — มันอยู่ในโทรศัพท์ เบราว์เซอร์ รถยนต์ของคุณ — และเหตุผลที่มันเหมาะกับแอปเดสก์ท็อปควรค่าแก่การพูดออกมาดัง ๆ เพราะเป็นเหตุผลเดียวกับที่คนซึ่งคิดแต่ในกรอบไคลเอนต์-เซิร์ฟเวอร์มองข้ามมันไป
มันฝังตัว ไม่ใช่เซิร์ฟเวอร์ ไม่มีโพรเซสให้จัดการ ไม่มีพอร์ตให้ผูก ไม่มี connection pool ไม่มี pg_hba.conf ดาตาเบสคือไลบรารีที่ลิงก์เข้าไปในแอปของคุณ "การเชื่อมต่อ" คือการเรียกฟังก์ชัน สำหรับแอปผู้ใช้คนเดียว นี่ตัดความกังวลเชิงปฏิบัติการออกไปทั้งหมวด
มันคือไฟล์เดียว เรื่องสำรองข้อมูลของคุณคือ cp เรื่อง "เอ็กซ์พอร์ต" ของคุณคือ "นี่ไฟล์" เรื่องซิงก์ของคุณ ถ้าอยากได้สักวัน คือ "ย้ายไฟล์" — พร้อมข้อแม้ที่ผมจะพูดถึง ผู้ใช้หามันเจอ ก๊อปลง USB โยนลงโฟลเดอร์ที่ iCloud หรือ Dropbox เฝ้าดูอยู่ แล้วไม่มีอะไรในแอปของคุณพัง เทียบกับการอธิบายให้ผู้ใช้ที่ไม่ใช่สายเทคว่าจะเอ็กซ์พอร์ตข้อมูลจากบัญชีคลาวด์อย่างไรสิ
มันคิวรีได้โดยทุกคน นี่คือส่วนที่สำคัญที่สุดที่ทำให้ "เป็นเจ้าของข้อมูลของคุณ" เป็นความจริงไม่ใช่สโลแกน ข้อมูลของผู้ใช้อยู่ในฟอร์แมตที่ sqlite3 CLI, DB Browser for SQLite, มอดูล sqlite3 ของ Python, DuckDB, Datasette และเครื่องมือวิเคราะห์เกือบทุกตัวบนโลกอ่านได้โดยตรง คุณไม่ใช่ยามเฝ้าประตูข้อมูลของผู้ใช้ของคุณเอง พวกเขาตอบคำถามที่คุณไม่เคยสร้างหน้าจอให้ได้
มันทนทานและเป็นทรานแซกชัน SQLite เป็น ACID COMMIT หนึ่งครั้งจะเกิดขึ้นครบหรือไม่เกิดเลย แม้ไฟดับกลางการเขียน สำหรับเครื่องมือที่บันทึกสายธารต่อเนื่อง คำถามว่า "ดาตาเบสจะเป็นยังไงถ้าฝาพับปิดปึงในไมโครวินาทีที่ผิด" มีคำตอบน่าเบื่อแต่ถูกต้อง แทนที่จะเป็นไฟล์เสีย
ข้อโต้แย้งที่คุณจะได้ยินคือ "SQLite ไม่สเกล / ไม่รองรับผู้เขียนพร้อมกัน" จริงทั้งคู่และไม่เกี่ยวทั้งคู่ตรงนี้ แอปเดสก์ท็อปผู้ใช้คนเดียวมีผู้เขียนพอดีหนึ่งราย: ตัวมันเอง สิ่งที่ SQLite ทำได้แย่ — หลายเครื่องกระหน่ำเขียนผ่านเครือข่าย — เป็นสิ่งที่สถาปัตยกรรมนี้ไม่เคยทำ การเลือกดาตาเบสให้เข้ากับแพตเทิร์นการเข้าถึงคือเกมทั้งหมด และแพตเทิร์นการเข้าถึงตรงนี้คือ ผู้เขียนหนึ่งราย, ส่วนใหญ่เป็น append, อ่านเชิงวิเคราะห์เป็นครั้งคราว นั่นคือสนามบ้านของ SQLite
ลูปสุ่มตัวอย่างที่ 1 Hz
งานของตัวจับคือตอบ "ผู้ใช้กำลังทำอะไร" อย่างต่อเนื่อง วิธีคือตัวสุ่ม: วินาทีละครั้ง ถามระบบปฏิบัติการสามคำถาม
- แอปพลิเคชันไหนอยู่หน้าสุด?
- ชื่อหน้าต่างที่โฟกัสอยู่คืออะไร?
- (ถ้าได้รับอนุญาต สำหรับเบราว์เซอร์) URL ของแท็บที่ใช้งานคืออะไร?
บน macOS คำตอบเหล่านั้นมาจาก API ของแอปพลิเคชันหน้าสุดและทรี Accessibility (AXUIElement) — สิทธิ์เดียวกันที่เปิดเผยชื่อหน้าต่างก็เปิดเผยแถบที่อยู่ของเบราว์เซอร์ ซึ่งเป็นเหตุผลที่ Timex อ่าน URL ของแท็บได้โดยไม่ต้องมี prompt ของ Automation แยกสำหรับเบราว์เซอร์ส่วนใหญ่ ไม่มีอะไรเป็นกรรมสิทธิ์; มันคือพื้นผิว macOS ที่มีเอกสารกำกับซึ่งตัวจับใด ๆ ก็ใช้ สิ่งที่คุณทำกับตัวอย่างคือที่ที่การออกแบบอยู่
การ implement แบบไร้เดียงสาเขียนหนึ่งแถวต่อหนึ่งตัวอย่าง หนึ่งวินาที หนึ่งแถว มันถูกต้องและสิ้นเปลือง: 28,800 แถวที่เกือบเหมือนกันสำหรับวันแปดชั่วโมงที่คุณใช้สองชั่วโมงในหน้าต่างเอดิเตอร์เดิม การเก็บ "เอดิเตอร์, เอดิเตอร์, เอดิเตอร์, …" 7,200 ครั้งไม่ใช่ข้อมูล มันคือพิกเซลค้าง
โมเดลที่ดีกว่าปฏิบัติต่อสายธารวินาทีต่อวินาทีเป็น อินพุต และ เซกเมนต์ เป็น เอาต์พุต เซกเมนต์คือช่วงต่อเนื่องที่คำตอบของทั้งสามคำถามยังคงเดิม คุณเก็บเซกเมนต์ที่เปิดอยู่ปัจจุบันไว้ในหน่วยความจำ — (bundleId, title, url, startAt) — และในแต่ละ tick คุณเปรียบเทียบตัวอย่างใหม่กับมัน:
- บริบทเดิม? ขยายเซกเมนต์ที่เปิดอยู่ ไม่มีอะไรลงดิสก์
- บริบทเปลี่ยน? ปิดเซกเมนต์ที่เปิดอยู่ (ประทับ
endAtคำนวณduration) เขียนมันลง แล้วเปิดอันใหม่
ดังนั้นเซสชันเอดิเตอร์สองชั่วโมงคือ หนึ่ง แถวที่มีระยะเวลาสองชั่วโมง ไม่ใช่ 7,200 แถว ดิสก์เห็นการเขียนเฉพาะเมื่อมีอะไรเปลี่ยนจริง ๆ — ซึ่งสำหรับงานมนุษย์จริงเกิดน้อยกว่าวินาทีละครั้งมาก ลูป 1 Hz ให้ความละเอียดแก่คุณ; โมเดลเซกเมนต์ให้การจัดเก็บที่มีเหตุผล คุณได้ทั้งสองอย่าง
tick (1 Hz) ─▶ ตัวอย่าง(app, title, url)
│
├─ เท่ากับเซกเมนต์ที่เปิดอยู่? ──▶ ขยายในหน่วยความจำ, ไม่เขียน
│
└─ ต่างกัน? ──▶ ปิด + flush เซกเมนต์ที่เปิดอยู่ ──▶ เปิดอันใหม่
ความว่างเปล่าไม่ใช่บริบท
ตัวสุ่มที่วิ่งแบบหลับหูหลับตาจะบันทึก "เอดิเตอร์" แปดชั่วโมงอย่างมีความสุข เพราะเอดิเตอร์อยู่หน้าสุดตอนคุณไปกินข้าวเที่ยง ข้อมูลนั้นเป็นเรื่องโกหก และแอปจับเวลาที่โกหกแย่กว่าไม่มีแอปจับเวลาเลย
ดังนั้นคุณต้องมี เกตความว่าง (idle gate): ติดตามกิจกรรมอินพุต และหลังเกณฑ์หนึ่งที่ไม่มีคีย์บอร์ดหรือเมาส์ (Timex ใช้สองนาที) ให้หยุดผูกเวลาให้แอปเบื้องหน้า วิธีที่ซื่อตรงในการสร้างโมเดลคือปฏิบัติต่อความว่างเป็นการเปลี่ยนสถานะที่ปิดเซกเมนต์ที่ใช้งานอยู่ เหมือนการสลับบริบทเป๊ะ เมื่ออินพุตกลับมา เซกเมนต์ใหม่เปิดขึ้น ช่องว่างระหว่างทั้งสองเป็นของจริง — มันคือเวลาที่เครื่องเปิดอยู่แต่มนุษย์ไม่อยู่ — และคุณจะเก็บช่องว่างนั้นเป็นข้อเท็จจริงชั้นหนึ่งในสคีมา หรืออนุมานจากรอยต่อระหว่างเซกเมนต์ก็ได้ — Timex ใช้วิธีหลัง
มีการตัดสินใจฝังอยู่ตรงนี้: วินาที ก่อน ที่คุณจะว่างยังถูกผูกให้สิ่งที่อยู่หน้าสุด ไม่มีคำตอบสมบูรณ์แบบ (คุณไม่อาจรู้ว่าผู้ใช้ลุกไปจนกว่าเขาจะไม่กลับมา) ดังนั้นการออกแบบเลือกเกณฑ์หนึ่งและบันทึกมันไว้ ความซื่อตรงเรื่องขอบเบลอดีกว่าความแม่นยำจอมปลอมที่แสร้งว่าการสุ่มตัวอย่างอ่านใจคนได้
สคีมาสำหรับช่วงเวลา
นี่คือสคีมาตัวอย่าง — ของผม เขียนขึ้นเพื่อโพสต์นี้ — ที่จับโมเดลเซกเมนต์ไว้ มันคือรูปทรง ไม่ใช่ DDL จริงของผลิตภัณฑ์ แต่เป็นสิ่งที่สมเหตุสมผลอย่างยิ่งที่จะสร้าง
-- เซกเมนต์กิจกรรมที่ปิดแล้ว: หน่วยที่ม้วนรวมแล้ว ไม่ใช่ตัวอย่างดิบ
CREATE TABLE activity (
id INTEGER PRIMARY KEY,
bundle_id TEXT NOT NULL, -- เช่น com.apple.dt.Xcode
app_name TEXT NOT NULL,
title TEXT, -- ชื่อหน้าต่างที่โฟกัส (nullable)
url TEXT, -- แท็บที่ใช้งาน เฉพาะเบราว์เซอร์
domain TEXT, -- host ที่สกัดมา เพื่อจัดกลุ่มเร็ว
started_at INTEGER NOT NULL, -- วินาที unix epoch
ended_at INTEGER NOT NULL,
duration INTEGER NOT NULL, -- ended_at - started_at, ดีนอร์มอลไลซ์
category_id INTEGER REFERENCES category(id)
);
CREATE INDEX idx_activity_started ON activity(started_at);
CREATE INDEX idx_activity_bundle ON activity(bundle_id);
CREATE INDEX idx_activity_domain ON activity(domain) WHERE domain IS NOT NULL;
CREATE TABLE category (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL, -- hex
score INTEGER NOT NULL -- น้ำหนักผลิตภาพ, -100..100
);
-- จับคู่แอปกับหมวดหมู่เริ่มต้น ผู้ใช้แทนที่ได้
CREATE TABLE app_category (
bundle_id TEXT PRIMARY KEY,
category_id INTEGER NOT NULL REFERENCES category(id)
);
สามทางเลือกการออกแบบในนั้นควรค่าแก่การปกป้อง:
เก็บช่วงที่ปิดแล้ว ไม่ใช่ tick ดิบ ตาราง activity คือเซกเมนต์ ตัวอย่างวินาทีต่อวินาทีไม่เคยถูกบันทึกถาวร — มันอยู่หนึ่ง tick ในหน่วยความจำแล้วพับเข้าเซกเมนต์ที่เปิดอยู่ นี่คือความต่างระหว่างดาตาเบสที่โตปีละ ~7 ล้านแถว กับดาตาเบสที่โตเท่าจำนวนครั้งที่คุณสลับหน้าต่างจริง ซึ่งสำหรับคนส่วนใหญ่น้อยกว่าหนึ่งถึงสองอันดับของขนาด
ดีนอร์มอลไลซ์ duration มันหาได้จาก ended_at - started_at และการเก็บมันไว้อยู่ดีคือการละเมิด "อย่าเก็บสิ่งที่คำนวณได้" อย่างจงใจ เหตุผล: ทุกคิวรีที่ UI รันรวมระยะเวลา และคอลัมน์จำนวนเต็มที่เก็บไว้พร้อมดัชนีดีกว่าการคำนวณการลบใหม่ข้ามหลายล้านแถวในทุกคำถาม "สัปดาห์ของฉันไปไหนหมด" การดีนอร์มอลไลซ์เป็นเครื่องมือ; กฎคือดีนอร์มอลไลซ์สิ่งที่คุณอ่านตลอด และคำนวณสิ่งที่คุณอ่านนาน ๆ ที
ช่วงครึ่งเปิด [started_at, ended_at) จุดเริ่มรวมเข้า จุดจบไม่รวม นี่คือธรรมเนียมน่าเบื่อแต่ถูกต้องสำหรับช่วงเวลา: เซกเมนต์ที่อยู่ติดกันสองอันแบ่งกันใช้ timestamp ขอบโดยไม่ทับซ้อนและไม่มีช่องว่าง และ "เซกเมนต์นี้ตกอยู่ในวันนี้ไหม" คือ started_at < day_end AND ended_at > day_start ที่สะอาดโดยไม่มี off-by-one ที่เที่ยงคืน ทำผิดตรงนี้แล้วทุกการรวมผลจะถูกสาปอย่างแยบยล
สิ่งที่ผม ไม่ แสดงให้คุณดูคือประวัติไมเกรชันจริงของผลิตภัณฑ์ ดัชนีที่แน่นอน หรือลอจิกการ seed หมวดหมู่ — นั่นเป็นกรรมสิทธิ์ และตามจริงมันก็เป็นส่วนที่น่าเบื่อด้วย แพตเทิร์นข้างบนคือความรู้ที่ถ่ายโอนได้
คิวรีที่คุณรันบนไฟล์ของตัวเองได้
นี่คือที่ที่ "เป็นเจ้าของข้อมูลของคุณ" เลิกเป็นสโลแกน เพราะที่เก็บคือไฟล์ SQLite ธรรมดา ผู้ใช้ — ไม่ใช่แค่แอป — ถามคำถามกับมันได้ นี่คือคิวรีต่อสคีมาตัวอย่างข้างบนที่ผู้ใช้ใด ๆ ปรับและรันด้วย sqlite3 CLI บนดาตาเบสของตัวเองได้
วันนี้ไปไหนหมด แยกตามแอป:
SELECT app_name, SUM(duration) / 3600.0 AS hours
FROM activity
WHERE started_at >= strftime('%s', 'now', 'start of day')
GROUP BY bundle_id
ORDER BY hours DESC;
เว็บไซต์ยอดนิยมสัปดาห์นี้:
SELECT domain, SUM(duration) / 60 AS minutes
FROM activity
WHERE domain IS NOT NULL
AND started_at >= strftime('%s', 'now', '-7 days')
GROUP BY domain
ORDER BY minutes DESC
LIMIT 20;
เวลาที่จดจ่อเทียบกับวอกแวก โดยใช้น้ำหนักหมวดหมู่:
SELECT c.name,
SUM(a.duration) / 3600.0 AS hours
FROM activity a
JOIN category c ON c.id = a.category_id
WHERE a.started_at >= strftime('%s', 'now', '-30 days')
GROUP BY c.id
ORDER BY hours DESC;
ไม่มีอันไหนต้องการคำขอฟีเจอร์ การเอ็กซ์พอร์ต CSV หรือ API ผู้ใช้ป้อนไฟล์เข้า DuckDB ถ้าอยากได้ window function เข้า Datasette ถ้าอยากได้ UI เว็บ เข้าโน้ตบุ๊ก pandas ถ้าอยากได้กราฟ ผลิตภัณฑ์ส่งมอบมุมมอง Today; ข้อมูล ส่งมอบความสามารถในการตอบทุกอย่างที่เหลือ นั่นคือเงินปันผลของการเลือกฟอร์แมตที่ทั้งโลกอ่านได้
ความทนทาน: WAL, แครช และการเติบโตของไฟล์
แอปที่เขียนต่อเนื่องบนแล็ปท็อปที่ถูกพับฝาปิดปึงต้องมีคำตอบสำหรับ "ไฟล์จะเป็นยังไงเมื่อโพรเซสตายกลางการเขียน" คำตอบของ SQLite ดี แต่คุณต้องเลือกเข้าเวอร์ชันที่ดีอย่างตั้งใจ
ใช้โหมด WAL rollback journal เริ่มต้นทำให้ผู้อ่านต้องเรียงคิวต่อผู้เขียนและทำงาน fsync มากกว่า Write-Ahead Logging (PRAGMA journal_mode=WAL) เขียนการเปลี่ยนแปลงลงไฟล์ข้างเคียง -wal และปล่อยให้การอ่านดำเนินไปพร้อมกับผู้เขียนรายเดียว — ซึ่งเป็นรูปทรงของตัวจับที่ append เซกเมนต์ขณะที่ UI อ่านมันเพื่อวาดไทม์ไลน์เป๊ะ นี่คือเหตุผลที่คุณจะเห็นสามไฟล์บนดิสก์: timex.sqlite, timex.sqlite-wal, และ timex.sqlite-shm นั่นไม่ใช่ความเสียหาย; นั่นคือ WAL กำลังทำงานของมัน -wal ทำ checkpoint กลับเข้าไฟล์หลักเป็นระยะ
ความปลอดภัยเมื่อแครชเป็นของจริง แต่ปรับ sync ด้วย synchronous=NORMAL ภายใต้ WAL ไฟดับอาจทำให้คุณเสียทรานแซกชันสุดท้ายที่ยังไม่ checkpoint แต่ทำให้ดาตาเบสเสียหายไม่ได้ — ข้อแลกเปลี่ยนที่แอปเดสก์ท็อปส่วนใหญ่ควรรับ เพราะการเสียไม่กี่วินาทีสุดท้ายของ "คุณอยู่ในเอดิเตอร์" คือ rounding error และข้อแลกเปลี่ยนความทนทาน/ทรูพุตคุ้มค่า synchronous=FULL ปลอดภัยกว่าและช้ากว่า; สำหรับข้อมูลกิจกรรมความละเอียดวินาที NORMAL คือค่าเริ่มต้นที่มีเหตุผล สิ่งที่คุณต้องไม่ทำคือรันโดยปิดการซิงโครไนซ์เพื่อไล่ตามตัวเลขเบนช์มาร์ก แล้วทำหน้าตกใจตอน kernel panic กินไฟล์ไป
รวมการเขียนเป็นชุด แม้เซกเมนต์จะเกิดไม่บ่อยเมื่อเทียบกับตัวอย่าง การห่อการ flush ในทรานแซกชันและไม่เรียก fsync ทุกการเขียนจิ๋ว ๆ ทำให้ดิสก์มีความสุข ตัวสุ่มถือการเขียนไว้สั้น ๆ แล้ว flush ชุดเล็ก ๆ ได้; UI ไม่จำเป็นต้องให้เซกเมนต์อยู่บนดิสก์ในไมโครวินาทีที่มันปิด
วางแผนการเติบโตและการเก็บรักษา เซกเมนต์โตช้า แต่มันโตตลอดไป และ "ตลอดไป" เป็นเวลานานบนแล็ปท็อป 256 GB การออกแบบที่ซื่อตรงให้ผู้ใช้มีปุ่มหมุนการเก็บรักษา — เก็บเซกเมนต์ดิบ N เดือน เลือกได้ที่จะม้วนข้อมูลเก่าเป็นสรุปรายวันที่หยาบกว่า และ VACUUM เป็นครั้งคราวเพื่อทวงพื้นที่คืนจากการลบ คุณไม่ต้องสร้างทั้งหมดในวันแรก แต่คุณควรรู้ว่ามันไปทางไหน เพราะ "ดาตาเบสที่มีแต่โต" คือบั๊กแบบสโลว์โมชัน
ข้อแลกเปลี่ยนตรงไปตรงมาของ local-first
ผมคงขายนิทานเรื่องเดียวกับที่ผมวิจารณ์ ถ้าผมหยุดที่ด้านดี Local-first ซื้อความเป็นเจ้าของและความเป็นส่วนตัวให้คุณด้วยการจ่ายสองอย่าง และคุณควรรู้ว่ามันมีราคาเท่าไหร่
ไม่มีซิงก์ในตัว ถ้าคนหนึ่งใช้เดสก์ท็อปและแล็ปท็อป แต่ละเครื่องมีไฟล์ของตัวเองและความจริงของตัวเอง ไม่มีเซิร์ฟเวอร์มาปรองดองพวกมัน เพราะการไม่มีเซิร์ฟเวอร์นั้นคือประเด็นทั้งหมด คำตอบแบบเอาของตัวเองมาคือวางไฟล์ในโฟลเดอร์ที่ iCloud หรือ Dropbox ซิงก์ — แต่เข้าใจว่านี่คือการซิงก์ ไฟล์ ไม่ใช่ซิงก์ ดาตาเบส และ SQLite เตือนชัดเจนว่าดาตาเบส WAL ไม่ถูกกับเครื่องมือซิงก์ไฟล์แบบไร้เดียงสาที่ก๊อป .sqlite ขณะที่ -wal กำลัง flush มันใช้ได้ถ้าแอปปิดอยู่ตอนไฟล์ถูกย้าย; มันเสียหายได้ถ้าสองเครื่องเขียนโฟลเดอร์เดียวกันพร้อมกัน ดังนั้นจุดยืนที่ซื่อตรงคือ: local-first ให้สำเนาที่สมบูรณ์แบบหนึ่งชุดต่อเครื่อง และมัลติดีไวซ์เป็นฟีเจอร์แยกแบบ opt-in ที่คุณสร้างอย่างตั้งใจหรือไม่สร้างเลย
การสำรองข้อมูลเป็นงานของผู้ใช้ — ดังนั้นทำให้มันง่ายจิ๊บจ๊อย เมื่อไม่มีคลาวด์ ไม่มีใครสำรองประวัติของผู้ใช้นอกจากผู้ใช้เอง การบรรเทาคือการสำรองข้อมูลคือ ไฟล์เดียวที่คุณก๊อปได้ ซึ่งเป็นเรื่องสำรองข้อมูลที่ง่ายที่สุดในวงการซอฟต์แวร์ แต่ผลิตภัณฑ์ต้องบอกอย่างตรงไปตรงมาแทนที่จะแสร้งว่าคลาวด์คอยรับหลังให้ เพราะมันไม่ได้คอย โดยการออกแบบ
และนี่คือส่วนที่ควรพูดตรง ๆ: วินาทีที่คุณอยากได้ซิงก์มัลติดีไวซ์จริง ๆ คุณได้ลงชื่อรับหนึ่งในปัญหาที่ยากจริงของวิทยาการคอมพิวเตอร์ การซิงก์ดาตาเบสข้ามอุปกรณ์ที่แก้ไขอย่างอิสระและหลุดออฟไลน์คือแดนของการแก้ความขัดแย้ง, CRDT, นาฬิกาเวกเตอร์ และความเสียใจแบบ last-writer-wins มันไม่ใช่ "แค่ rsync ไฟล์" ผลิตภัณฑ์จำนวนมากที่เริ่มแบบ local-first แล้วมาขันสกรูซิงก์ทีหลังลงเอยด้วยการสร้างดาตาเบสคลาวด์ที่เคยภูมิใจว่าหลีกเลี่ยงได้ขึ้นใหม่ — ตอนนี้มีพื้นผิวบั๊กระบบกระจายอยู่ข้างบนด้วย ท่าที่ป้องกันได้คือส่งมอบ local-first เป็นค่าเริ่มต้นที่ซื่อตรง เปิดประตูไว้สำหรับซิงก์ opt-in ในฐานะฟีเจอร์ที่ตั้งใจและออกแบบแยกต่างหาก และอย่าปล่อยให้ความสะดวกของซิงก์ลากแหล่งความจริงกลับขึ้นเซิร์ฟเวอร์อย่างเงียบ ๆ วันที่สำเนาทางการอยู่ที่อื่นนอกจากดิสก์ของผู้ใช้ คุณไม่ใช่ local-first อีกต่อไป — คุณคือแอปคลาวด์ที่มีแคชออฟไลน์สวย ๆ ซึ่งเป็นสิ่งที่ดีที่จะเป็น เพียงแต่ไม่ใช่สิ่งที่คุณบอกว่าคุณเป็น
สิ่งนี้ให้อะไรแก่ผู้ใช้
ถอดทุกอย่างออก สถาปัตยกรรมให้คำสัญญาเพียงหนึ่งเดียว: ไฟล์เป็นของคุณ มันรอดจากบริษัทที่ทำแอปถูกเทกโอเวอร์ มันรอดจาก wifi ดับบนเครื่องบิน มันรอดจากพาดหัวข่าวการเจาะข้อมูลครั้งหน้า เพราะประวัติการติดตามของคุณไม่ได้อยู่บนเซิร์ฟเวอร์ของใครให้ถูกเจาะ มันอ่านได้ด้วยเครื่องมือที่จะยังไม่มีอีกทศวรรษ เพราะฟอร์แมตเก่ากว่าเครื่องมือส่วนใหญ่ที่มีอยู่ตอนนี้ ไม่มีบัญชีที่จะล็อกคุณออก ไม่มีค่าสมาชิกที่การหมดอายุลบประวัติของคุณ ไม่มีตัวช่วยเอ็กซ์พอร์ตให้ต้องอ้อนวอนขอข้อมูลของตัวเอง
นั่นคือคุณค่าที่นำเสนอทั้งหมดของ local-first และเป็นเหตุผลที่เราสร้าง Timex บนไฟล์ SQLite ไฟล์เดียวแทนที่จะเป็นแบ็กเอนด์ที่เราต้องโฮสต์ รักษาความปลอดภัย และปิดมันในที่สุด สัญชาตญาณเดียวกันไหลผ่านชุดโอเพนซอร์สของ Muvon — โค้ดของคุณ เครื่องมือของคุณ repo ของคุณ — และผ่านผลิตภัณฑ์ปิดเพียงตัวเดียวที่เราส่งมอบ: เวลาของคุณ ไฟล์ของคุณ เครื่องของคุณ
หนึ่งวินาทีเป็นหน่วยเล็ก ๆ เจ็ดล้านวินาทีในไฟล์ที่คุณเป็นเจ้าของและคิวรีได้ด้วยเครื่องมือใด ๆ บนโลก กลายเป็นความต่างระหว่าง "ปีของฉันไปไหนหมด" ที่เป็นคำถามเชิงวาทศิลป์ กับคำถามที่ตอบได้
— Vladimir
คำถามที่พบบ่อย
ทำไมต้อง SQLite แทนดาตาเบสบนคลาวด์สำหรับแอปเดสก์ท็อป?
เพราะแพตเทิร์นการเข้าถึงคือผู้เขียนหนึ่งรายบนเครื่องเดียว ส่วนใหญ่เป็น append พร้อมการอ่านเชิงวิเคราะห์เป็นครั้งคราว — ซึ่งเป็นที่ที่ดาตาเบสไฟล์เดียวฝังตัวเปล่งประกายพอดี และไคลเอนต์-เซิร์ฟเวอร์เป็นภาระเชิงปฏิบัติการล้วน ๆ ไม่มีเซิร์ฟเวอร์สำหรับข้อมูลของคุณหมายถึงไม่มีงบ outage ไม่มีบิลซ้ำ ไม่มีพื้นผิวการเจาะที่ถือประวัติของคุณ และเรื่องสำรองข้อมูลคือการก๊อปไฟล์
ทำไมต้องม้วนตัวอย่าง 1 Hz เป็นเซกเมนต์แทนการเก็บทุกวินาที?
ความละเอียดวินาทีต่อวินาทีคือสิ่งที่คุณต้องการเพื่อความแม่นยำ; การจัดเก็บ วินาทีต่อวินาทีคือความสิ้นเปลือง เพราะงานจริงใช้เวลาเป็นนาทีหรือชั่วโมงในบริบทเดิม การพับตัวอย่างที่เหมือนกันต่อเนื่องเป็นช่วงปิดหนึ่งช่วงลดจำนวนแถวลงหนึ่งถึงสองอันดับของขนาดโดยไม่สูญเสียความเที่ยง
ผมคิวรีข้อมูลของตัวเองได้จริงไหม?
ได้ — นั่นคือประเด็นของการเลือก SQLite ไฟล์เปิดได้ใน sqlite3 CLI, DB Browser, DuckDB, Datasette, โน้ตบุ๊ก Python หรืออะไรก็ตามที่อ่าน SQLite คิวรีตัวอย่างในโพสต์นี้รันบนสคีมาที่แสดง; ปรับให้เข้ากับไฟล์ของคุณ
Local-first เหมือนกับ offline-first ไหม?
ไม่ Offline-first คือแอปคลาวด์ที่มีแคชโลคัลซึ่งซิงก์เมื่อทำได้ — เซิร์ฟเวอร์ยังถือสำเนาทางการ Local-first หมายถึงอุปกรณ์ถือแหล่งความจริงและไม่มีสำเนาทางการบนเซิร์ฟเวอร์เลย คนละสถาปัตยกรรม คนละการรับประกัน
กับดักของ local-first คืออะไร?
ไม่มีซิงก์มัลติดีไวซ์ในตัว และการสำรองข้อมูลขึ้นกับคุณ ทั้งคู่เป็นต้นทุนจริง การบรรเทาคือ "ข้อมูลของคุณ" เป็นไฟล์เดียวที่ก๊อปไปไหนก็ได้ — แต่ถ้าคุณต้องการให้อุปกรณ์ผสานการแก้ไขอัตโนมัติ นั่นเป็นปัญหายากต่างหาก (การแก้ความขัดแย้ง, CRDT) ที่การก๊อปไฟล์เท่าไรก็ไม่แก้
Timex เป็นโอเพนซอร์สไหม?
ไม่ Timex เป็นผลิตภัณฑ์โอเพนซอร์สแบบปิด เครื่องมือนักพัฒนาของ Muvon — Octomind, Octocode และที่เหลือ — เป็นโอเพนซอร์สภายใต้ Apache-2.0 โพสต์นี้เกี่ยวกับสถาปัตยกรรมและแพตเทิร์น ซึ่งเป็นความรู้วิศวกรรมทั่วไป ไม่ใช่ซอร์สของผลิตภัณฑ์
Timex คือแอปจับเวลาอัตโนมัติบน Mac แบบปิดซอร์สและ local-first: การสุ่มตัวอย่างที่ 1 Hz ลงไฟล์ SQLite โลคัลไฟล์เดียวที่คุณเป็นเจ้าของ — ไม่มีการเก็บกิจกรรมของคุณบนคลาวด์ ไม่มีเทเลเมทรีว่าคุณทำอะไร การเรียกเครือข่ายเดียวคือการเปิดใช้งานไลเซนส์ สคีมาและคิวรีในที่นี้เป็นตัวอย่างและเขียนขึ้นเพื่อโพสต์นี้ ไม่ใช่ซอร์สของผลิตภัณฑ์ ชุดเครื่องมือนักพัฒนาของ Muvon — Octomind และผองเพื่อน — เป็นโอเพนซอร์สภายใต้ Apache-2.0


