---
title: "รายงานผลการทดสอบประสิทธิภาพ"
titleEn: "Performance Test Report"
clauses: ["4.7.6", "12.2", "ก.1.4.1", "ก.1.4.2"]
phase: 2
category: "รายงานอัตโนมัติ"
generated: true
generatedAt: "2026-10-03T02:53:27Z"
---

> สร้างอัตโนมัติเมื่อ 2026-10-03T02:53:27Z

# รายงานผลการทดสอบประสิทธิภาพ

**อ้างอิง TOR:** ข้อ 4.7.6 (Performance Test ≥ 500 concurrent), ข้อ 12.2 (FCP ≤ 5 วินาที / TTFT ≤ 15 วินาที / Error ≤ 5% ที่ p95), ข้อ ก.1.4.1 (รองรับ concurrent ≥ 500), ข้อ ก.1.4.2 (รองรับ 1,000,000 บัญชี)

รายงานนี้สร้างโดย `scripts/perf/load-test.ts` (`npm run report:perf`) จากการยิงคำขอจริงไปยังเซิร์ฟเวอร์ที่รันอยู่ และ `scripts/perf/seed-perf-users.ts` (`npm run seed:perf-users`) สำหรับหัวข้อจำนวนบัญชีผู้ใช้ ตัวเลขทั้งหมดเป็นค่าที่วัดได้จริงในสภาพแวดล้อมสาธิต (เครื่องเดียว) — **ยังไม่ใช่ผลทดสอบที่ 500 concurrent บนสภาพแวดล้อม production** ซึ่งต้องทดสอบซ้ำก่อนใช้งานจริง

## สรุปผลเทียบเกณฑ์ TOR 12.2

| ตัวชี้วัด | เกณฑ์ | ค่าที่วัดได้ (แย่ที่สุดในทุกระดับที่ทดสอบ) | ระดับโหลดที่ทดสอบ | ผล |
|---|---|---|---|---|
| FCP (p95) | ≤ 5 วินาที | 1.78 s | ผู้ใช้ 1 ราย (browser จริง) หน้า `/th`, `/th/chat` | ผ่าน |
| TTFT (p95) | ≤ 15 วินาที | 11.15 s | แชต 5/10/20 concurrent | ผ่าน |
| Error rate — แชต (SSE) | ≤ 5% | 0.0% | สูงสุด 20 concurrent | ผ่าน |
| Error rate — HTTP (`/api/health`, `/th`) | ≤ 5% | 0.0% | สูงสุด 100 concurrent | ผ่าน |

**ข้อสังเกตสำคัญ:** ผลข้างต้นเป็นผลที่ระดับโหลดของ sandbox (HTTP สูงสุด 100 concurrent, แชตสูงสุด 20 concurrent) เกณฑ์ TOR 4.7.6 / ก.1.4.1 กำหนด 500 concurrent ซึ่ง **ยังไม่ได้ทดสอบ** ใน demo นี้ — ดูหัวข้อ "การประมาณผลที่ 500 concurrent"

## สภาพแวดล้อมที่ทดสอบ

| รายการ | ค่า |
|---|---|
| เครื่องทดสอบ (client และ server อยู่เครื่องเดียวกัน) | Apple M5 Pro (15 cores), RAM 24 GB |
| ระบบปฏิบัติการ | Darwin 25.4.0 (arm64) |
| Node.js | v24.19.0 |
| เซิร์ฟเวอร์ | http://localhost:3210 — Next.js dev server (`next dev`) |
| เวอร์ชันแอป / ดัชนีค้นคืน | 1.0.0-demo / 4,392 chunks จาก 329 เอกสาร (BM25 in-memory) |
| ฐานข้อมูล | SQLite (ไฟล์เดียว, WAL) ผ่าน Prisma — production ใช้ PostgreSQL |
| LLM | Typhoon API (typhoon-v2.5-30b-a3b-instruct) — LLM จริง เรียกบริการภายนอกผ่านอินเทอร์เน็ต |
| ระยะเวลาทดสอบรวม | 280 วินาที |

> เซิร์ฟเวอร์ที่ทดสอบเป็น **Next.js dev server (`next dev`)** ซึ่งไม่ได้ minify/optimize, compile หน้าแบบ on-demand และมี overhead ของ HMR — latency และ FCP จึงสูงกว่า production build (`next build && next start`) อย่างมีนัยสำคัญ ตัวเลขในรายงานนี้จึงเป็นค่าฝั่งอนุรักษ์ (แย่กว่าที่จะได้จาก production build บนเครื่องเดียวกัน) นอกจากนี้ระหว่างทดสอบมีงานพัฒนา/ทดสอบอื่นใช้เครื่องและเซิร์ฟเวอร์เดียวกันอยู่

## วิธีทดสอบ

1. เข้าสู่ระบบด้วยผู้ใช้ seed (GET `/api/csrf` → POST `/api/auth/login`) แล้วใช้ session cookie + CSRF token เดียวกันกับทุก virtual user (VU)
2. Warm-up: เรียก `/api/health`, `/th` และถามแชต 1 คำถาม (ไม่นับผล latency) เพื่อให้เซิร์ฟเวอร์ compile route และโหลดดัชนีค้นคืนเข้าหน่วยความจำ
3. HTTP: ที่แต่ละระดับ (10, 50, 100 VU พร้อมกัน) × 3 รอบ — แต่ละ VU ส่ง 3 คำขอต่อเนื่องกันต่อรอบ ไปที่ `GET /api/health` (ผ่าน middleware + ตรวจ session + query ฐานข้อมูล) และ `GET /th` (หน้าเว็บ SSR) วัดเวลาจนอ่าน body ครบ; error = สถานะไม่ใช่ 200 หรือ timeout
4. แชต: ที่แต่ละระดับ (5, 10, 20 VU พร้อมกัน) × 3 รอบ — แต่ละ VU ส่ง `POST /api/chat/qa` (SSE) 1 คำถามต่อรอบ หมุนเวียนจากคำถามจริง 10 ข้อ ผ่าน pipeline เต็ม (guardrail → query rewrite → hybrid search → rerank → LLM stream → output guardrail → บันทึก) วัด **TTFT** = เวลาตั้งแต่ส่งคำขอจนได้ event `delta` แรก และเวลารวมจนจบ stream; error = สถานะไม่ใช่ 200, มี event `error`, หรือไม่มี event `done` (timeout 120 วินาที)
5. FCP: เปิดหน้า `/th` และ `/th/chat` (เข้าสู่ระบบแล้ว) ด้วย Chromium ผ่าน Playwright หน้าละ 5 ครั้ง (context ใหม่ทุกครั้ง = ไม่มี cache) อ่านค่า `first-contentful-paint` จาก `performance.getEntriesByType('paint')`

**เรื่อง rate limit:** middleware จำกัด 60 คำขอ/นาที/IP สำหรับ `/api/*` และ 10 คำขอ/นาที/IP สำหรับ login หากยิงจาก IP เดียว ผลจะถูกครอบด้วย 429 ทั้งหมดและไม่สะท้อนความสามารถของระบบ สคริปต์จึงส่ง header `x-forwarded-for` ที่ไม่ซ้ำกันต่อ VU (ช่วง `10.77.x.y`) เพื่อจำลองผู้ใช้จากหลาย IP — rate limiter ยังเปิดทำงานตามปกติตลอดการทดสอบ (จำนวน 429 ที่พบแสดงในตาราง)

## ผลการทดสอบ HTTP

### GET /api/health

| Concurrent (VU) | คำขอ | p50 | p95 | max | p95 รายรอบ | Error rate | 429 | คำขอ/วินาที |
|---:|---:|---:|---:|---:|---|---:|---:|---:|
| 10 | 90 | 69 ms | 85 ms | 86 ms | 86 ms / 76 ms / 74 ms | 0.0% | 0 | 140.3 |
| 50 | 450 | 154 ms | 701 ms | 711 ms | 262 ms / 708 ms / 224 ms | 0.0% | 0 | 206.0 |
| 100 | 900 | 343 ms | 1.27 s | 1.33 s | 381 ms / 1.03 s / 1.29 s | 0.0% | 0 | 188.2 |

### GET /th (หน้าแรก, SSR)

| Concurrent (VU) | คำขอ | p50 | p95 | max | p95 รายรอบ | Error rate | 429 | คำขอ/วินาที |
|---:|---:|---:|---:|---:|---|---:|---:|---:|
| 10 | 90 | 464 ms | 1.53 s | 1.53 s | 645 ms / 1.53 s / 466 ms | 0.0% | 0 | 17.0 |
| 50 | 450 | 2.59 s | 3.32 s | 3.33 s | 2.69 s / 3.14 s / 3.33 s | 0.0% | 0 | 19.3 |
| 100 | 900 | 7.09 s | 12.04 s | 12.07 s | 7.12 s / 7.89 s / 12.05 s | 0.0% | 0 | 13.3 |

ทุกคำขอตอบสถานะ 200

latency คำนวณจากคำขอที่สำเร็จ (สถานะ 200) เท่านั้น

## ผลการทดสอบแชต (POST /api/chat/qa, SSE, LLM จริง)

| Concurrent (VU) | คำขอ | TTFT p50 | TTFT p95 | TTFT max | เวลารวม p50 | เวลารวม p95 | Error rate | ถูกปฏิเสธโดย guardrail | TTFT ≤ 15 วิ (p95) | Error ≤ 5% |
|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|---|
| 5 | 15 | 1.37 s | 1.89 s | 1.89 s | 6.50 s | 10.36 s | 0.0% | 0 | ผ่าน | ผ่าน |
| 10 | 30 | 1.62 s | 2.66 s | 2.73 s | 7.21 s | 10.37 s | 0.0% | 0 | ผ่าน | ผ่าน |
| 20 | 60 | 2.58 s | 11.15 s | 13.32 s | 15.28 s | 21.79 s | 0.0% | 0 | ผ่าน | ผ่าน |

ไม่มีคำขอแชตที่ผิดพลาด

TTFT และเวลารวมคำนวณจากคำขอที่สำเร็จ; คำขอที่ guardrail ปฏิเสธ (ไม่มี event `delta`) นับเป็นคำขอสำเร็จแต่ไม่นำมาคิด TTFT

**โทเคนที่ใช้จริง:** การทดสอบแชตเรียก LLM จริง จึงใช้โทเคนจริงของโครงการ — รวม **560,863 โทเคน** จาก 106 คำขอ (input 514,236, thinking 0, output 46,627; รวม warm-up 4,044 โทเคนแล้ว) นับจาก `usage` ใน event `done` ของทุกคำขอ และถูกบันทึกในตาราง TokenUsage / นับรวมในเพดานโทเคน 24 ชม. ของระบบตามปกติ

## First Contentful Paint (FCP)

Browser: Chromium ที่มากับ Playwright 153.0.8010.12 (headless, viewport 1280×800, ไม่มี cache)

| หน้า | จำนวนครั้ง | FCP p50 | FCP p95 | FCP max | เกณฑ์ ≤ 5 วิ |
|---|---:|---:|---:|---:|---|
| `/th` | 5 | 128 ms | 132 ms | 132 ms | ผ่าน |
| `/th/chat` | 5 | 128 ms | 1.78 s | 1.78 s | ผ่าน |

FCP วัดขณะไม่มีโหลดอื่นจากสคริปต์นี้ (ผู้ใช้ 1 ราย) — ยังไม่ได้วัด FCP ขณะมีโหลด 500 concurrent

## การประมาณผลที่ 500 concurrent (TOR 4.7.6, ก.1.4.1)

**สิ่งที่วัดจริง:** `/api/health` ที่ 100 VU: p95 1.27 s, error 0.0%, 188 คำขอ/วินาที; `/th` ที่ 100 VU: p95 12.04 s, error 0.0%; แชตที่ 20 VU: TTFT p95 11.15 s, error 0.0%

**สิ่งที่ไม่ได้วัด:** โหลด 500 concurrent ทั้ง HTTP และแชต — demo นี้ **ไม่อ้างว่าผ่านเกณฑ์ 500 concurrent** การประมาณด้านล่างเป็นการวิเคราะห์คอขวด ไม่ใช่ผลทดสอบ

คอขวดของสถาปัตยกรรม demo ที่ทำให้ไม่สามารถขยายผลเชิงเส้นไปถึง 500 ได้:

| คอขวดใน demo | ผลเมื่อโหลดเพิ่ม | สิ่งที่ใช้ใน production (Solution Architecture) |
|---|---|---|
| Node.js process เดียว (event loop เดียว) รับทั้งหน้าเว็บ, API, SSE และงานค้นคืน | งานที่ใช้ CPU (BM25, rerank, SSR) บล็อกคำขออื่นทั้งหมด latency เพิ่มตามคิว | แยก 8 services บน Kubernetes พร้อม HPA ขยาย pod ตามโหลด |
| ดัชนี BM25 + pseudo-embedding อยู่ในหน่วยความจำของ process เดียว | ค้นคืนเป็นงาน synchronous บน event loop และขยายแนวนอนไม่ได้ | Elasticsearch (hybrid BM25 + vector) และบริการ embedding/rerank แยก |
| SQLite single-writer (WAL) | ทุกคำถามเขียนหลายตาราง (ข้อความ, โทเคน, audit, request log) — การเขียนเข้าคิวทีละรายการ เสี่ยง `SQLITE_BUSY` เมื่อ concurrent สูง | PostgreSQL + connection pool |
| Rate limit และยอดจองโทเคนเก็บใน memory ของ process | ใช้ร่วมกันหลาย instance ไม่ได้ | Redis |
| โควตา/rate limit ของ LLM API ภายนอก (ใช้ร่วมกับผู้ใช้รายอื่นของผู้ให้บริการ) — 1 คำถามเรียก LLM 3 ครั้ง (guard classifier, query rewrite, generate) | ที่ 500 concurrent = ~1,500 คำขอ LLM พร้อมกัน เกินโควตา API สาธารณะ → 429/timeout และ TTFT สูงขึ้น | LLM endpoint ที่จัดสรรเฉพาะ (dedicated/self-hosted) ตามขนาดที่ออกแบบ + คิว/แบ็กออฟ |
| Next.js dev server | ช้ากว่า production build หลายเท่า | production build หลัง CDN/Ingress |

**ข้อสรุปอย่างตรงไปตรงมา:** ส่วน HTTP/หน้าเว็บไม่พบความผิดพลาดเกินเกณฑ์ที่ระดับสูงสุดที่ทดสอบ (100 VU) แต่ latency เพิ่มตามจำนวน VU เพราะเป็น process เดียว ส่วนเส้นทางแชตถูกจำกัดด้วย LLM API และ process เดียวเป็นหลัก จึง **ไม่สามารถยืนยันจาก demo นี้ว่าระบบผ่านเกณฑ์ที่ 500 concurrent** การยืนยัน TOR 4.7.6 / ก.1.4.1 ต้องทำ load test ที่ 500 concurrent บนสภาพแวดล้อม production (หรือ staging ที่เทียบเท่า) ด้วยสคริปต์เดียวกันนี้ (`PERF_LEVELS=100,250,500 PERF_CHAT_LEVELS=100,250,500 PERF_CHAT_MAX_REQUESTS=...`) ก่อนส่งมอบงวดที่เกี่ยวข้อง

## ผลการทดสอบรองรับบัญชีผู้ใช้จำนวนมาก (ก.1.4.2)

> ทดสอบเมื่อ 2026-10-03T02:54:59Z ด้วย `scripts/perf/seed-perf-users.ts --count=1000000` (`npm run seed:perf-users -- --count=1000000`)

สร้างบัญชีผู้ใช้ **1,000,000 บัญชี** ลงฐานข้อมูลทดสอบแยก `prisma/perf.db` (schema เดียวกับระบบจริงจาก `prisma migrate deploy`; ไม่แตะฐานข้อมูลของ demo) แล้ววัดเวลา query ที่เกี่ยวกับจำนวนผู้ใช้โดยตรง

| รายการ | ผลที่วัดได้ | หมายเหตุ |
|---|---|---|
| จำนวนบัญชีที่สร้าง | 1,000,000 (ขอ 1,000,000) | ครบ |
| เวลา insert รวม | 65.73 s (15,215 แถว/วินาที) | batch ละ 5,000 แถว/transaction ผ่าน node:sqlite (Node v24.19.0) |
| ขนาดไฟล์ฐานข้อมูล | 580.7 MB | ≈ 609 bytes/บัญชี รวมดัชนี 4 ตัวของตาราง User |
| ค้นหาด้วย emailHash (เส้นทางล็อกอิน) — พบ | p50 80.8 µs · p95 0.17 ms · max 1.26 ms | 1,000 ครั้งแบบสุ่ม |
| ค้นหาด้วย emailHash — ไม่พบ | p50 2.9 µs · p95 84.2 µs | 1,000 ครั้ง (อีเมลที่ไม่มีในระบบ) |
| `COUNT(*)` ทั้งตาราง | 2.41 ms | มัธยฐานจาก 3 ครั้ง |
| รายการแบ่งหน้า `ORDER BY createdAt DESC LIMIT 50 OFFSET 0` | 132 ms | มัธยฐานจาก 3 ครั้ง |
| รายการแบ่งหน้า `ORDER BY createdAt DESC LIMIT 50 OFFSET 1,000` | 95 ms | มัธยฐานจาก 3 ครั้ง |
| รายการแบ่งหน้า `ORDER BY createdAt DESC LIMIT 50 OFFSET 500,000` | 2.80 s | มัธยฐานจาก 3 ครั้ง |
| รายการแบ่งหน้า `ORDER BY createdAt DESC LIMIT 50 OFFSET 999,950` | 3.38 s | มัธยฐานจาก 3 ครั้ง |
| นับตามตัวกรอง role=GENERAL_USER, status=active | 21 ms (969,000 แถว) | ใช้ดัชนี (role, status) |
| นับตามตัวกรอง role=GENERAL_USER, status=suspended | 0.28 ms (20,000 แถว) | ใช้ดัชนี (role, status) |
| นับตามตัวกรอง role=ADMIN, status=active | 18.0 µs (1,000 แถว) | ใช้ดัชนี (role, status) |
| กรอง role=GENERAL_USER, status=suspended LIMIT 50 | 31.3 µs | ใช้ดัชนี (role, status) |
| อ่านทั้งตาราง `SELECT * … ORDER BY createdAt DESC` | 2.11 s | วิธีที่หน้า Admin › Users ของ demo ใช้อยู่ (ดูข้อจำกัด) |

**Query plan (SQLite `EXPLAIN QUERY PLAN`):**

- ล็อกอิน: `SEARCH User USING INDEX User_emailHash_key (emailHash=?)`
- รายการแบ่งหน้า: `SCAN User; USE TEMP B-TREE FOR ORDER BY`

**การตีความ:**

- เส้นทางล็อกอินค้นด้วยดัชนี unique ของ `emailHash` (B-tree) เวลาค้นจึงแทบไม่ขึ้นกับจำนวนบัญชี — ที่ 1,000,000 บัญชี p95 = 0.17 ms ต่อครั้ง เวลาล็อกอินจริงถูกกำหนดโดยการตรวจรหัสผ่านด้วย bcrypt ซึ่งใช้เวลามากกว่าการค้นฐานข้อมูลหลายเท่าและไม่ขึ้นกับจำนวนบัญชี
- การนับ/กรองตาม role + status ใช้ดัชนี `(role, status)`
- รายการแบ่งหน้าเรียงตาม `createdAt` ไม่มีดัชนีรองรับ จึงสแกนทั้งตารางและช้าลงตามจำนวนบัญชีและ OFFSET — production ต้องเพิ่มดัชนี `createdAt` และใช้ keyset pagination

**ข้อจำกัดที่ต้องระบุอย่างตรงไปตรงมา:**

- ข้อมูลผู้ใช้เป็นข้อมูลจำลอง: `emailHash` = SHA-256 ของอีเมลที่สร้างขึ้น (เหมือนระบบจริง) แต่ `emailEnc` / `displayNameEnc` / `passwordHash` เป็นค่าจำลองที่ยาวเท่าของจริง ไม่ได้เข้ารหัส AES-256-GCM หรือ bcrypt จริง — ใช้วัดขนาดข้อมูลและความเร็ว query ได้ แต่ไม่ได้วัดต้นทุนการถอดรหัส
- วัดที่ชั้นฐานข้อมูล (SQLite ไฟล์เดียว, ผู้ใช้ 1 connection, ไม่มีโหลดอื่น) ไม่ได้ทดสอบผ่าน HTTP API และไม่ได้ทดสอบการล็อกอินพร้อมกันจำนวนมากบนฐานข้อมูลขนาดนี้
- หน้า Admin › Users ของ demo (`listUsers` ใน `src/application/identity/admin-user-usecases.ts`) โหลดผู้ใช้ **ทั้งตาราง** แล้วถอดรหัสและค้นหา/แบ่งหน้าในหน่วยความจำ เพราะอีเมล/ชื่อถูกเข้ารหัส — เฉพาะการอ่านทั้งตารางที่ 1,000,000 บัญชีใช้ 2.11 s (ยังไม่รวมการถอดรหัสทีละแถวและหน่วยความจำ) วิธีนี้ **ไม่รองรับ 1,000,000 บัญชี** ต้องเปลี่ยนเป็นการแบ่งหน้าที่ฐานข้อมูล + blind index สำหรับค้นหาก่อนใช้งานจริง
- production ใช้ PostgreSQL (ไม่ใช่ SQLite) จึงต้องทดสอบซ้ำบนฐานข้อมูลจริงด้วยข้อมูลขนาดเดียวกัน ผลในหัวข้อนี้ยืนยันได้เพียงว่า schema และดัชนีของเส้นทางล็อกอินรองรับข้อมูลระดับนี้

ไฟล์ `prisma/perf.db` ถูกเก็บไว้ให้ตรวจสอบซ้ำได้ (อยู่ใน `.gitignore`; ลบได้ด้วย `rm prisma/perf.db*`)

## วิธีรันซ้ำ

```bash
npm run report:perf                                   # load test + TTFT + FCP (ต้องมีเซิร์ฟเวอร์รันอยู่; แชตใช้โทเคนจริง)
PERF_SKIP_CHAT=1 npm run report:perf                  # เฉพาะ HTTP + FCP ไม่ใช้โทเคน
PERF_LEVELS=10,50,100 PERF_CHAT_LEVELS=5,10,20 PERF_ROUNDS=3 BASE_URL=http://localhost:3210 npm run report:perf
npm run seed:perf-users -- --count=1000000            # ทดสอบจำนวนบัญชีผู้ใช้ (เขียนหัวข้อ ก.1.4.2)
```
