Skip to content

Hiệu năng D1 và API ​

Trang này ghi các luật giữ cho conan-platform-production (D1) và api.conan.school nhẹ: chỉ mục cho truy vấn nóng, chỉ đọc cột cần, cho phép bộ đệm có kiểm soát, và không ghi lặp. Nguồn: kiểm toán D1 16.10.2026 (chỉ đọc, wrangler d1 insights 7 ngày, EXPLAIN QUERY PLAN, curl), sửa trong một PR ngày 17.10.2026.

Lúc kiểm toán: 407 MB, 651 bảng, ~1,16 tỉ dòng đọc mỗi tuần, trong đó ~470 triệu là quét cả bảng vì thiếu chỉ mục.

1. Chỉ mục: kiểm bằng kế hoạch thật, không đoán ​

Migration api.conan.school/migrations/20261116a_d1_hot_query_indexes.sql thêm 17 chỉ mục (CREATE INDEX IF NOT EXISTS, chỉ thêm). Mỗi dòng ghi truy vấn thật mà nó phục vụ và số đo trước.

Truy vấn nóng (văn bản từ insights)Lượt/tuầnDòng đọc/lượt trướcChỉ mục
legacy_ebook_sections WHERE chapter_id IN (...)~40.0004.598 (cả bảng)(chapter_id, section_number)
legacy_ebook_chapters WHERE ebook_id = ? ORDER BY chapter_number~40.0001.179(ebook_id, chapter_number)
legacy_ebook_quiz_questions WHERE quiz_id IN (...)~4.7009.439(quiz_id)
năm bảng con của section WHERE section_id IN (...)~4.000-5.500 mỗi bảngcả bảng(section_id)
legacy_ebook_authors WHERE ebook_id IN (?)~36.00025(ebook_id)
legacy_ebooks WHERE school_id AND is_active AND slug~35.500245(slug, school_id, is_active)
kệ sách WHERE school_id AND is_active ORDER BY sort_order/deepbooks 412, heartbeat 28278.017 và 3,4 triệu (do subquery chương)(school_id, is_active, sort_order)
legacy_courses / courses theo slug (unit preview)~21.000457 + 2.129(slug) cả hai họ
legacy_courses WHERE school_id AND is_active ORDER BY sort_order~15.300435(school_id, is_active, sort_order)
legacy_workshop_registrations WHERE workshop_id AND status~1.1001.190(workshop_id, status)
legacy_seo_content_briefs WHERE status IN ... ORDER BY scheduled_date~2.300145(status, scheduled_date)
legacy_book_ops_issue_registry WHERE school_id AND ebook_id~3.5001.262(school_id, ebook_id, status)

D1 không có sqlite_stat1 (không ai chạy ANALYZE), nên planner chọn chỉ mục bằng luật thô: chỉ mục khớp nhiều cột bằng nhau hơn thắng. Hệ quả đã gặp khi viết migration này: một chỉ mục chỉ có slug THUA chỉ mục (school_id, is_active, sort_order) ở câu WHERE school_id=? AND is_active=? AND slug=?, và câu tra một quyển đọc lại cả kệ. Vì thế chỉ mục slug của sách mang đủ ba cột của bộ lọc.

Cũng vì không có thống kê, kế hoạch trên một SQLite rỗng có cùng lược đồ giống kế hoạch trên D1. Đó là cách kiểm "sau migration" khi chưa áp được lên production:

bash
# trước: kế hoạch thật trên production (chỉ đọc)
npx --no-install wrangler d1 execute conan-platform-production --remote --command "EXPLAIN QUERY PLAN SELECT ..."
# sau: test chạy kế hoạch trên lược đồ chép từ production, đỏ nếu còn `SCAN <bảng>`
cd api.conan.school && npm run test:d1-perf

Sau khi CI áp migration, chạy lại lệnh EXPLAIN đầu tiên: phải ra SEARCH ... USING INDEX idx_....

Thêm chỉ mục mới: lấy văn bản truy vấn từ wrangler d1 insights conan-platform-production --timePeriod 7d --sort-type sum --sort-by reads --json (không viết lại bằng tay), EXPLAIN trên production, viết migration, thêm câu đó vào khối 1 của tests/d1-api-optimizations.test.mts.

Truy vấn mức khoá (course-intelligence/levels.ts, 161.701 dòng/lượt) KHÔNG thiếu chỉ mục: mọi subquery đã SEARCH. Dòng đọc là do nó đếm cả ngân hàng câu hỏi mỗi khoá; muốn giảm phải đổi cách tính (cache kết quả), không phải thêm chỉ mục.

2. Chỉ đọc cột cần ​

d1Client (shared/d1Client.ts). Truy vấn có quan hệ (select('a, con(x, y)')) từng luôn SELECT * ở bảng gốc và ở mọi bảng quan hệ, rồi mới chiếu xuống. Từ 17.10.2026 nó đọc cột được chọn cộng khoá nối. Hai lưới an toàn, vì client có ~440 chỗ gọi: tên cột không trơn (bí danh, biểu thức) thì giữ *; D1 báo no such column thì chạy lại bằng * và ghi cảnh báo D1CLIENT_NARROW_FALLBACK một lần. Kết quả trả ra không đổi.

Ví dụ đo: mục lục quyển hook (15 phần) đọc ~195 KB từ D1 trước, ~5,5 KB sau; trung bình cả kệ ~12 KB mỗi phần. Thấy D1CLIENT_NARROW_FALLBACK trong Workers Logs nghĩa là một chỗ gọi xin cột không tồn tại: sửa chỗ gọi.

Route công khai trả đúng cột người gọi đọc, không select('*'):

RouteTrướcSauGhi chú
/api/public/misc/home2.954.576 B, 7,2 s (16.10), 526.036 B sau #456~38.700 B (gzip ~12.700)12 khoá, 6 workshop, 12 sách, 10 tin, không thân bài
/api/public/ebooks/{slug}select('*')cột của hợp đồng ebookDetailhết cover_prompt, cover_meta_json, authoring_meta_json, ai_metadata
/api/content/ebookscó ai_metadatakhôngkhông frontend nào đọc cột đó

Số "sau" của /home đo bằng router mới chạy trên chính các hàng production (đọc bằng SELECT), không phải ước lượng.

3. Bộ đệm: mặc định đóng, route xin mở ​

shared/cache.ts. Middleware toàn cục (cacheControlMiddleware, khai ở src/index.ts) ghi no-store lên mọi phản hồi, TRỪ khi route tự đặt Cache-Control và đủ cả bốn điều kiện:

  1. GET hoặc HEAD;
  2. trạng thái 200 hoặc 304;
  3. không có Set-Cookie;
  4. nếu header nói public thì yêu cầu không gắn danh tính (userId trống). Vi phạm thì hạ về no-store và ghi PUBLIC_CACHE_WITH_IDENTITY.

Route công khai không đọc danh tính gọi cachePublic(c) (mặc định public, max-age=60, s-maxage=300, kèm Vary: Origin, x-school-id: CORS phản chiếu Origin, và các route này đọc x-school-id để chọn trường). Đang xin mở: /misc/home, /misc/deepbooks, /misc/fb-reviews, /misc/page-info, /misc/workshops, /ebooks/sitemap, /ebooks/{slug}, bốn route unit preview, /courses (danh sách, elearning, chi tiết, outcomes, outline), tin và playbook. Route đổi theo người đọc (What's New, JTBD, hub cộng đồng) tự đặt header của mình kèm Vary; What's New của khách (public, max-age=300) trước đây bị middleware ghi đè thành no-store.

Hai route ảnh từng được miễn bằng đường dẫn (/api/book-art/*, BOOK_COVER_PATH_PATTERN) nay đi qua luật chung.

Lỗi không bao giờ thành "rỗng + public". Route xin mở phải kiểm error của D1 TRƯỚC cachePublic: lỗi thì ném HTTPException(503) (tới app.onError, errorResponse), và middleware ghi no-store lên mọi phản hồi không phải 200/304. Khuôn ở /ebooks/sitemap, /courses/{slug}/outcomes, /courses/{slug}/outline. Một mảng rỗng do lỗi mà nằm trong bộ đệm là một khoảng thời gian trang hiện "chưa có gì" mà không gì đỏ (LUẬT SEO). /misc/home là trang ghép nhiều khối: khối khoá học hỏng thì 500; khối phụ hỏng (mỗi khối tự .catch, kể cả SQL thô của workshop) thì trang vẫn 200 với mảng rỗng, nhưng cả phản hồi KHÔNG gọi cachePublic (log HOME_BLOCK_FAILED).

Worker chạy TRƯỚC bộ đệm CDN của Cloudflare, nên header này chủ yếu cho trình duyệt (tokyo gọi API từ trình duyệt) và cho lớp đệm nào đứng trước. Muốn bộ đệm ở edge thật (đỡ D1 cho cả lượt SSR của www) thì phải thêm Cache API như endpoint bìa sách; chưa làm.

4. Không ghi lặp ​

  • Heartbeat sách (domains/book-engine/findings.ts, migration 20261116b): một lỗi (trường, sách, loại lỗi) là một dòng, lượt sau upsert run_id, nội dung, last_seen_at. Trước đó 4 lượt/ngày chèn lại cùng danh sách (66.409 dòng cho 1.419 lỗi, ~220 MB/năm). Chỉ mục UNIQUE là chỉ mục một phần (WHERE last_seen_at IS NOT NULL) vì dòng cũ trùng khoá và đợt này không xoá dữ liệu. Màn hình admin vẫn đọc run_id = lượt mới nhất.
  • Kho A/B (KEEP_CANDIDATE_SQL, course-intelligence/arena.ts): không chèn bản giống hệt (cùng loại, mục tiêu, model, prompt, nội dung). lesson-banks có 3.941 bản cho ~323 nội dung khác nhau; bản trùng không bao giờ được ghép cặp để bầu nên không mất gì.

Hai bảng từng mang toàn bộ lịch sử lặp cũ (~21 MB và ~50 MB). Chủ repo duyệt dọn 16.10.2026: migration 20261117a, 20261117b, bản lưu ở R2 riêng tư trước khi xoá (d1-cleanup).

5. Cổng mentor và cổng học viên đã ngừng ​

Quyết định của chủ repo 16.10.2026. /api/mentor/* và /api/learner/* của api.conan.school trả 410{ code: 'GONE' } qua errorResponse (shared/retired.ts), cho mọi phương thức, khai trước app.route nên router không chạy tới. Mã và dữ liệu giữ nguyên. Mở lại: gỡ hai dòng mountRetiredPortal. Không frontend nào trong hai repo gọi hai cổng này (grep 17.10.2026), và Rafiki/Simba gọi api.sutucon.com, không gọi api này (rà soát #458).

Vòng cron mô hình mentor VẪN chạy (MENTOR_MODEL_WORKFLOW, ~20 lượt/ngày). D1 conan-platform-production dùng chung với api.sutucon.com (repo sutucon.com), và route /mentor-model của Rafiki (rafiki.sutucon.com) đọc mentor_model_runs

  • hai bảng snapshot. api.sutucon.com không có cron, nên vòng của api này là chỗ duy nhất làm mới dữ liệu đó. Bản đầu của #458 gỡ vòng này vì "không ai trong hai repo đọc"; rà soát bắt được trước khi merge (known-risks). Chủ repo chưa quyết về Rafiki.

Kiểm sau deploy ​

bash
# migration đã áp
gh run list --workflow=deploy-api.yml --limit 3
npx --no-install wrangler d1 execute conan-platform-production --remote \
  --command "SELECT name FROM schema_migrations WHERE name LIKE '20261116%'"
# chỉ mục được dùng
npx --no-install wrangler d1 execute conan-platform-production --remote \
  --command "EXPLAIN QUERY PLAN SELECT * FROM legacy_ebook_sections WHERE chapter_id IN ('a','b')"
# phản hồi nhỏ và có bộ đệm
curl -s -o /dev/null -w "%{size_download} %{time_total}\n" https://api.conan.school/api/public/misc/home
curl -sI https://api.conan.school/api/public/misc/home | grep -i cache-control      # public, max-age=60, s-maxage=300
curl -sI https://api.conan.school/api/user/profile | grep -i cache-control         # vẫn no-store
curl -s -o /dev/null -w "%{http_code}\n" https://api.conan.school/api/mentor/classes  # 410
# một tuần sau: dòng đọc của truy vấn mục lục sách phải gần 0
npx --no-install wrangler d1 insights conan-platform-production --timePeriod 1d --sort-type sum --sort-by reads --limit 10

Xem thêm: known-risks, skill api-service (Luật 5 và 6), data-model.

Tài liệu nội bộ nền tảng Conan School.