ICERUST氷錆Thoughts on software,
systems, and experiments.
บันทึกเรื่องซอฟต์แวร์
ระบบ และการทดลอง

··อ่าน 9 นาที

Elysia / Hono

Elysia vs Hono กับ PostgreSQL บน MacBook Air M5

  • Elysia
  • Hono
  • PostgreSQL
  • Benchmark

สวัสดีครับ กลับมาจับ MacBook Air M5 มาทรมานเป็นรอบที่สาม 😅

โพสต์แรก ผมวัดว่า Bun HTTP Framework แปดตัวเร็วแค่ไหน แล้วพบว่าตัวเองกำลังวัดความร้อนของเครื่องมากกว่าวัด Framework โพสต์ที่สอง ผมวัดพลังงานต่อ Request แล้วพบว่าตัวยิงโหลดกินไฟมากกว่า Server ที่กำลังทดสอบ

ทั้งสองโพสต์มีจุดอ่อนเดียวกันข้อหนึ่ง ซึ่งผมเขียนทิ้งไว้เองว่า:

Handler ใน Benchmark แทบไม่มีงาน ถ้า Handler ต้องรอ Database จริง ความต่างระดับเศษมิลลิจูลตรงนี้อาจเล็กมากเมื่อเทียบกับเวลา Query

รอบนี้ผมเลยเอาข้อนั้นมาทดสอบจริง ๆ ด้วยคำถามว่า:

ถ้าให้ Elysia กับ Hono คุยกับ PostgreSQL ก้อนเดียวกัน ด้วย SQL ชุดเดียวกัน ความต่างที่เคยเห็นจะเหลืออยู่เท่าไหร่?

คำตอบสั้น ๆ คือ เหลือน้อยจนวัดไม่ได้ครับ

สรุปก่อน เผื่อยังไม่อยากอ่านยาว

  • ห่างกันมากที่สุดใน 16 ช่องทดสอบคือ 1.75% (get-user ที่ 100 connections) ค่ากลางของความต่างอยู่ที่ 0.34%
  • ทุกช่องทดสอบ ช่วง min–max จาก 5 รอบของสอง Framework ทับกันหมด ไม่มีช่องไหนแยกออกจากกันได้เลย
  • รันซ้ำ 5 รอบในเงื่อนไขเดียวกันเป๊ะ ๆ ยังแกว่างกันเองเฉลี่ย 2.94% และแย่สุด 12.67% — มากกว่าความต่างระหว่าง Framework
  • คอขวดไม่ใช่ router: CPU ของ API ตันอยู่ราว 113% (ประมาณ 1.1 คอร์) ตั้งแต่ 10 connections ขึ้นไป ขณะที่ PostgreSQL พุ่งถึง 180–430%
  • Workload ฝั่งอ่านยังไปต่อได้ถึง 100 connections แต่ฝั่งเขียนตันที่ 10 connections แล้วนิ่ง
  • 160 trials, 175 ล้าน Request, HTTP และ transport error ศูนย์ ทุก trial
  • ตัวเลขทั้งหมดเป็นผลบนเครื่องเดียว ไม่ใช่การจัดอันดับ Framework และไม่ใช่ตัวเลขจาก Production

ผมวัดอะไร บนอะไร

สี่ workload ที่แตะฐานข้อมูลจริง ไม่ใช่ route ที่ตอบ "hi" เฉย ๆ:

Workload ทำอะไร
get-user หา user 42424 ด้วย primary key
list-users ดึง 20 user ล่าสุด
user-orders ดึง user พร้อม order ไม่เกิน 20 รายการ (user คนนี้มี 12 order)
create-order transaction สร้าง order พร้อม 3 items

ฐานข้อมูลตั้งต้นมี 100,000 users, 1,000,000 orders และ 3,000,000 order_items สร้างจาก MD5 counter-based generator ด้วย seed 424242 ทุกครั้ง และรัน ANALYZE หลัง seed เสร็จ ที่สำคัญคือ ผมไม่ tune PostgreSQL เลย ใช้ค่าที่ Homebrew ติดตั้งมาให้ตรง ๆ เพราะถ้า tune แล้วผลดีขึ้น เราจะไม่รู้ว่าดีขึ้นเพราะ Framework หรือเพราะ config

ทั้งสอง Framework ใช้ database functions, validation, JSON serialization และ connection pool ขนาด 10 ชุดเดียวกันทุกบรรทัด ต่างกันแค่ Elysia ใช้ native listen ส่วน Hono ใช้ Bun.serve เปิด production mode ไม่มี access log ไม่มี cache middleware

ผมทดสอบอย่างไร?

Concurrency 1, 10, 50 และ 100 อย่างละ 5 รอบต่อ Framework รวมเป็น 160 trials วัดจริงรอบละ 30 วินาที

ทุก trial ผ่านขั้นตอนเดิมหมด: ลบและ seed ฐานข้อมูลใหม่ → เปิด API → warm-up ให้ครบ 1,000 request ที่สำเร็จ → พัก 5 วินาที → วัด 30 วินาที → ปิด API → cooldown 15 วินาที

ทำไมต้อง seed ฐานข้อมูลใหม่ทุก trial?

เพราะ create-order เขียนข้อมูลลงไปจริง ถ้าไม่ reset ตารางจะโตขึ้นเรื่อย ๆ ตาม trial ที่ผ่านมา แล้ว Framework ที่บังเอิญรันทีหลังจะเจอฐานข้อมูลที่ใหญ่กว่า — กลายเป็นว่าเราวัดลำดับการรัน ไม่ได้วัด Framework

เรื่องนี้ผมเข็ดมาจากโพสต์แรกแล้วครับ ตอนนั้นแค่สลับลำดับรายการทดสอบ อันดับก็พลิกทันที รอบนี้จึงสลับ Framework ที่เริ่มก่อนแบบ deterministic และจับคู่ให้รันติดกัน เพื่อให้ถ้าเครื่องร้อนขึ้น มันร้อนใส่ทั้งคู่เท่า ๆ กัน

ทั้ง 160 trials รันบน MacBook Air (Mac17,3) เครื่องเดียว — ชิป Apple M5 10 คอร์ แรม 24 GB unified — เสียบปลั๊กไฟไว้ตลอดการทดสอบ ส่วนเวอร์ชันของ Bun, PostgreSQL, Elysia, Hono และ bombardier ในตารางข้างล่าง เป็นค่าที่อ่านจากเครื่องตอนรันจริง ไม่ได้พิมพ์เอง

ลิงก์ชื่อรุ่นด้านบนเป็นลิงก์ Lazada affiliate ครับ กดซื้อผ่านลิงก์นี้ราคาไม่ต่างจากเดิม แต่บล็อกจะได้ค่าคอมมิชชันเล็กน้อย

สภาพแวดล้อมที่บันทึกจริง

Session: 2026-09-12T14-24-22-067Z-full-4423a21a
2026-09-12T14:24:22.067Z — 2026-09-12T17:03:10.238Z

รายการค่าที่บันทึก
macOS
ProductName:		macOS
ProductVersion:		26.6.2
BuildVersion:		25G83
CPU / RAMApple M5 / 24 GiB
Bun / PostgreSQL1.4.0 / 18.6
Elysia / Hono1.4.30 / 4.13.7
Bombardier2.0.2
Homebrew formulapostgresql@18 18.6
ค่า PostgreSQL ที่ใช้
SettingValueUnit
autovacuumon—
checkpoint_completion_target0.9—
checkpoint_timeout300s
effective_cache_size5242888kB
fsyncon—
full_page_writeson—
jiton—
listen_addresseslocalhost—
maintenance_work_mem65536kB
max_connections100—
max_parallel_workers_per_gather2—
max_wal_size1024MB
min_wal_size80MB
port5432—
random_page_cost4—
seq_page_cost1—
server_version18.6 (Homebrew)—
shared_buffers163848kB
synchronous_commiton—
wal_levelreplica—
work_mem4096kB

Throughput ที่สำเร็จ

แต่ละจุดใช้ครบ 5 repetitions แกนตั้งของกราฟเส้นไม่ได้เริ่มที่ศูนย์เสมอ โปรดเทียบค่าจริงในตารางด้วย

get-user

elysiahono020,00040,00060,00080,000020406080100จำนวน connections พร้อมกันRequests ที่สำเร็จ/วินาที
cFrameworkMedian req/sMin–max req/sMean ms¹p50 ms¹p95 ms¹p99 ms¹Max ms
1elysia18,116.6917,904.51–18,156.690.050.050.060.071.19
1hono18,072.8917,854.94–18,078.030.050.050.060.071.15
10elysia59,488.2958,539.61–59,962.340.170.160.230.4214.46
10hono59,063.2957,972.6–59,881.680.170.160.230.417.47
50elysia69,802.4768,458.74–70,324.560.710.671.161.410.28
50hono69,065.9167,742.16–69,686.020.720.681.161.399.91
100elysia72,531.6370,665.31–72,922.911.381.32.12.499.09
100hono71,282.6469,633.05–71,636.71.41.332.122.498.78

list-users

elysiahono10,00020,00030,00040,00050,000020406080100จำนวน connections พร้อมกันRequests ที่สำเร็จ/วินาที
cFrameworkMedian req/sMin–max req/sMean ms¹p50 ms¹p95 ms¹p99 ms¹Max ms
1elysia12,946.7612,859.88–13,039.770.080.080.080.091.17
1hono12,902.4912,649.06–13,033.390.080.080.080.091.1
10elysia39,839.6239,085.66–39,958.80.250.240.340.587.1
10hono39,819.0138,995.98–40,070.060.250.240.340.554.82
50elysia44,620.1443,576.32–45,283.241.121.061.752.112.47
50hono43,99543,034.77–44,385.071.131.081.762.1220.12
100elysia45,950.0944,593.55–46,587.262.172.113.153.6929.07
100hono45,773.2644,599.43–46,369.852.182.123.133.6611.45

user-orders

elysiahono10,00020,00030,00040,00050,00060,000020406080100จำนวน connections พร้อมกันRequests ที่สำเร็จ/วินาที
cFrameworkMedian req/sMin–max req/sMean ms¹p50 ms¹p95 ms¹p99 ms¹Max ms
1elysia11,151.311,127.56–11,240.410.090.090.10.111.63
1hono11,181.511,099.48–11,257.170.090.090.090.111.54
10elysia44,112.442,927.47–44,267.830.230.210.310.5269.1
10hono44,167.5543,060.34–44,204.130.230.210.310.57.53
50elysia51,425.9649,893.8–51,789.760.970.921.672.1572.93
50hono50,699.6649,607.03–51,327.840.980.931.642.182.45
100elysia52,884.0851,330.82–53,249.391.891.564.416.5284.22
100hono52,749.2251,372.99–52,992.271.891.594.056.43115.02

create-order

elysiahono05,00010,00015,00020,00025,000020406080100จำนวน connections พร้อมกันRequests ที่สำเร็จ/วินาที
cFrameworkMedian req/sMin–max req/sMean ms¹p50 ms¹p95 ms¹p99 ms¹Max ms
1elysia4,725.464,720.22–4,762.510.210.210.230.2514.18
1hono4,740.174,714.33–4,748.770.210.210.220.2637.07
10elysia20,458.7519,908.35–20,657.980.490.450.650.8485.19
10hono20,431.7918,172.51–20,760.420.490.460.630.8177.99
50elysia20,241.7419,856.66–20,402.272.472.332.877.697.74
50hono20,308.7219,887.44–20,520.362.462.312.88.1187.1
100elysia19,912.8119,335.74–20,127.685.024.895.599.96106.28
100hono20,017.7819,689.06–20,268.394.994.815.439.8587.63

¹ Median ของสถิติแต่ละ trial ไม่ใช่ latency distribution รวม HTTP และ transport errors เป็นศูนย์ทุก trial ที่รวมผล ค่าที่ไม่มีแสดง N/A

การใช้ทรัพยากรจาก samples

แสดง median ของ repetitions โดย CPU หนึ่ง core เต็ม = 100% และ RSS เป็น sampled peak หน่วย MiB ผลรวม PostgreSQL อาจนับ shared pages ซ้ำ ค่านี้ไม่ได้ยืนยันว่าตัวสร้างโหลดไม่เป็นคอขวด

WorkloadcFrameworkAPI CPU %Load CPU %PG CPU %API MiBLoad MiBPG MiB
create-order1elysia37.7315.8637.5568.7720.311,284.67
create-order1hono37.5615.9637.6963.8620.251,281.69
create-order10elysia112.6383.06355.7773.1422.82,026.41
create-order10hono112.7383.69355.8268.2522.582,028.14
create-order50elysia111.9792.86354.517723.442,028.09
create-order50hono110.8593.39356.2170.1722.942,031.78
create-order100elysia114.0693.98349.8178.8625.782,023.48
create-order100hono114.1994.56352.7973.0825.52,026.8
get-user1elysia53.1377.320.5862.9140.97806.02
get-user1hono53.1676.5420.4859.8441.03807.53
get-user10elysia113.5227.35197.3966.0823.52834.16
get-user10hono113.01226.08195.5858.8123.58832.09
get-user50elysia108.6336.1191.5170.1124.8915.86
get-user50hono108.06329.75184.9965.7224.7831.31
get-user100elysia109.25332.65183.5271.3827.77908.94
get-user100hono108.17322.34181.0865.8827.89916.75
list-users1elysia50.2959.2227.2364.4140.8819.34
list-users1hono50.0645.1227.359.0240.34819.59
list-users10elysia112.16149.19228.9669.0823.36838.73
list-users10hono110.98149.49227.6260.9123.53837.92
list-users50elysia115.2202.32215.1480.0824.75910.45
list-users50hono113.48198.95210.7479.1124.78957.23
list-users100elysia118.09190.17207.3581.2527.11837.89
list-users100hono116.23189.73207.5278.7827.28834.69
user-orders1elysia38.9137.3742.2262.1720.86807.55
user-orders1hono39.1137.3441.8860.7720.88807.72
user-orders10elysia114.79161.78391.2466.3823.48948.92
user-orders10hono114.04161.91391.2160.3323.52944.58
user-orders50elysia114.09272.02428.4776.6924.72943.03
user-orders50hono112.36268.56422.5973.5824.45950.05
user-orders100elysia115.07287.08423.5977.4427.7970.06
user-orders100hono114.05285.14421.6673.8327.7952.77

ดาวน์โหลดข้อมูลตรวจสอบ

ผลลัพธ์: เสมอกันจนแยกไม่ออก

ช่องที่ห่างกันมากที่สุดในทั้ง 16 ช่องคือ get-user ที่ 100 connections — 72,532 เทียบกับ 71,283 req/s คิดเป็น 1.75% ส่วนค่ากลางของความต่างทั้งหมดอยู่ที่ 0.34% และ Elysia นำในเชิงตัวเลข 11 จาก 16 ช่อง

ฟังดูเหมือน Elysia ชนะนิด ๆ ใช่ไหมครับ แต่อย่าเพิ่ง

ผมรันทุกช่อง 5 รอบ ในเงื่อนไขเดียวกันทุกอย่าง แล้ว 5 รอบนั้นแกว่างกันเองเฉลี่ย 2.94% และแย่สุดถึง 12.67% (create-order ที่ 10 connections) พูดอีกแบบคือ ถ้าผมรัน Framework เดียวกันสองครั้ง ผมอาจได้ผลต่างกันมากกว่าที่ Elysia ต่างจาก Hono เสียอีก

และทุกช่องทดสอบ ช่วง min–max ของสอง Framework ทับกันหมด ไม่มีช่องไหนเลยที่แยกออกจากกันได้

เพราะฉะนั้นโพสต์นี้ ไม่ประกาศผู้ชนะครับ ใครจะบอกว่า Elysia เร็วกว่า 1.75% ก็เอาตัวเลขนี้ไปอ้างไม่ได้ เพราะมันจมอยู่ใน noise ของเครื่องผมเอง

ถ้าไม่ใช่ router แล้วคอขวดอยู่ไหน?

ตัวเลข CPU ตอบได้ชัดกว่า throughput ครับ

CPU ของ API ตันอยู่ราว 113% หรือประมาณ 1.1 คอร์ ตั้งแต่ 10 connections ขึ้นไป ทุก workload — เพราะ Bun รัน process เดียว ขณะเดียวกัน PostgreSQL ใช้ 180–430% และตัว bombardier เองกิน 149–336% ใน workload ฝั่งอ่าน

สามตัวนี้แย่ง 10 คอร์ชุดเดียวกันอยู่ ดังนั้นที่อัตราระดับนี้ router ของทั้งสองตัวยังห่างไกลจากการเป็นข้อจำกัดมาก สิ่งที่ผมวัดได้จริง ๆ คือ PostgreSQL, Bun SQL client และ pool ที่แชร์กัน — ไม่ใช่ความเร็วของ Framework

นี่คือคำตอบของคำถามที่ผมทิ้งไว้ในโพสต์ก่อน: พอ handler ต้องรอฐานข้อมูลจริง ความต่างของ Framework ก็หายไปตามที่เดาไว้ครับ

ทำไม create-order ไม่ scale?

ฝั่งอ่านยังได้ throughput เพิ่มจาก 10 ไป 100 connections (get-user จาก 59,488 เป็น 72,532 req/s) แต่ฝั่งเขียนไม่เป็นอย่างนั้น

create-order ขึ้นสูงสุดที่ 10 connections ด้วย 20,459 req/s แล้วลดลงเล็กน้อยเป็น 19,913 ที่ 100 connections ยิ่งเพิ่ม connection ยิ่งได้น้อยลง

เหตุผลน่าจะตรงไปตรงมา: pool ตรึงไว้ที่ 10 connections ดังนั้นการเพิ่ม client เป็น 100 ไม่ได้เพิ่มงานที่ฐานข้อมูลทำได้ แค่เพิ่มคิวรอ บวกกับการเขียนทำให้ตารางโตขึ้นระหว่างวัด ซึ่งไม่ใช่คุณสมบัติของ Framework ไหนเลย

ก่อนนำตัวเลขไปอ้าง ขอฝากไว้ 7 ข้อ

  1. เครื่องเดียว ห้องเดียว คืนเดียว ตัวยิงโหลด API และ PostgreSQL อยู่บนเครื่องเดียวกัน แย่ง CPU และ thermal headroom กันเอง ความต่างที่เห็นจึงโยนให้ Framework ทั้งหมดไม่ได้
  2. ตัวยิงโหลดอยู่ในทุกตัวเลข bombardier กิน CPU 149–336% ใน workload ฝั่งอ่าน มันอาจเป็นคอขวดร่วมด้วย
  3. pool 10 connections เป็นเพดานที่ตั้งใจตรึง ผลที่ concurrency 50 และ 100 จึงบอกเรื่อง pool มากกว่าบอกเรื่อง Framework
  4. Workload เป็นแบบ hot-key และ warm-cache ยิง user คนเดิมซ้ำ ๆ ไม่ใช่ traffic สุ่มแบบของจริง และเป็น closed-loop ซึ่งซ่อนผลของ queueing เมื่อเทียบกับ fixed arrival rate
  5. ตัวเลข CPU กับ RSS เป็นค่าจาก sample เก็บประมาณวินาทีละครั้งจาก cumulative CPU time ไม่ใช่การบัญชีที่แม่นยำ และ RSS รวมของ PostgreSQL นับ shared pages ซ้ำได้
  6. percentile ในตารางเป็น median ของ percentile แต่ละ trial ไม่ใช่ percentile รวมของทุก request — อ่านผิดข้อนี้กันบ่อย
  7. cooldown 15 วินาทีไม่ได้รับประกันว่าเครื่องเย็นเท่ากันทุกรอบ ผมบันทึก thermal log ไว้และไม่เจอ warning แต่ “ไม่มี warning” ไม่เท่ากับ “อุณหภูมิคงที่”

และขอเปิดเผยเหมือนโพสต์ก่อน ๆ ครับ: ผมเคยมีส่วนร่วมกับเอกสารของ Elysia และรอบนี้ Elysia ก็นำในเชิงตัวเลข 11 จาก 16 ช่องอีก ใครนำผลไปใช้ควรรับรู้ความเกี่ยวข้องส่วนนี้ไว้ด้วย — ถึงผมจะสรุปเองว่าความต่างนี้วัดไม่ได้ก็ตาม

อยากลองทำซ้ำ เริ่มจากตรงนี้

ต้องใช้ Bun, PostgreSQL และ bombardier ตรงเวอร์ชันในตารางข้างบน พร้อม frozen lockfile และ source hashes ที่แนบมาใน manifest

bun install --frozen-lockfile
bun run typecheck
bun run test
bun run bench:run --profile quick --execute
bun run bench:run --profile full --execute
# ใช้ path ของ session ที่ runner พิมพ์ออกมาจริง ๆ:
bun run bench:report --session reports/runs/SESSION_ID

คำสั่งที่มี --execute จะลบและ seed ใหม่เฉพาะ benchmark_db ที่มี ownership marker ของโปรเจกต์นี้เท่านั้น และต้องเสียบปลั๊กไฟไว้ — runner เช็ก AC power ก่อนทุก trial ถ้าสลับไปใช้แบตเตอรี่มันจะหยุดทันที ซึ่ง session ก่อนหน้าของผมตายไปแล้วหนึ่งรอบตอน trial ที่ 71 ด้วยเหตุผลนี้เลยครับ

Full profile ใช้เวลาวัดจริง 80 นาที แต่รวม reset, warm-up, settle, cooldown และการตรวจทั้งหมดแล้วจบที่ 2 ชั่วโมง 39 นาที

ข้อมูลดิบทั้ง 160 trials, summary CSV และ manifest ที่ sanitize แล้ว ดาวน์โหลดได้จากลิงก์ในส่วนผลลัพธ์ข้างบน ถ้าไม่เห็นด้วยกับวิธีคิด throughput หรือการอ่าน percentile ของผม หยิบข้อมูลดิบไปคำนวณใหม่ได้เลยครับ

ส่วนที่ผมอยากให้ช่วยตรวจมากที่สุดคือ เพดานของ pool ครับ ผมยังไม่ได้ลองว่าถ้าขยาย pool จาก 10 เป็น 50 แล้ว create-order จะไปต่อได้ไหม หรือ PostgreSQL จะเป็นกำแพงอยู่ดี ถ้าใครมี Apple Silicon แล้วอยากลองซ้ำ ผมยินดีดูผลไปด้วยกันครับ

Keep building. Keep learning.สร้างต่อไป เรียนรู้ต่อไป

技術は旅である