🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้
- model · ลิ้นชัก · แฟ้ม (บท 106) —
Postคือคนดูแลสต็อกของลิ้นชักposts· หนึ่งแฟ้ม = บทความหนึ่งชิ้น (หนึ่งแถว) · ช่องบนแฟ้ม = คอลัมน์ post.user(บท 107) — "ขอผู้เขียนของบทความนี้" · ใช้ได้เพราะPostเขียนไว้ว่าbelongs_to :user(ละเอียดในบท 115)- ใบเบิกของ (บท 113) —
Post.all·Post.where(…)ยังไม่ไปตู้ จนกว่าจะเริ่มใช้ข้อมูลจริง เช่น วนeach· และcount= ให้ตู้นับใหม่เสมอ ·size= ถ้าแฟ้มมาอยู่บนโต๊ะแล้ว นับบนโต๊ะ - log ของ server (บท 113) — บรรทัด
Post Load …= ไปตู้หนึ่งรอบ ·↳ ไฟล์:บรรทัด= บรรทัดไหนสั่ง ·(N queries)ท้ายใบสั่ง = รวมกี่รอบ map·&:title(บท 92) —mapวนทีละชิ้นแล้วเก็บผลเป็นรายการใหม่ ·map(&:title)=map { |p| p.title }แบบย่อ
🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน ใช้แทนกันได้)
🗂 ตัวอย่างในบทนี้ใช้ตาราง users / posts / comments และข้อมูลตัวอย่าง (seeds) ชุดเดียวกับที่สร้างไว้ใน บท 107 —
บทความ 6 ชิ้น · สมชาย สมหญิง มานะ เขียนคนละ 2 ชิ้น · คอมเมนต์ 7 อัน
ผลทุกก้อนรันจริงแล้วก็อปมา · ตัดบรรทัดที่ไม่เกี่ยวเป็น … · เวลา (ms) · วันที่ · เลข 0x… ของคุณจะไม่ตรงกับในบท และจอคุณจะมีสี
ภาพก่อนโค้ด: ขอชื่อผู้เขียน 6 คน ต้องเดินไปตู้ 7 รอบ
สมมติหน้าแรกของบล็อกต้องโชว์ "ชื่อบทความ — โดย ชื่อผู้เขียน" ทั้ง 6 ชิ้น
ชื่อบทความอยู่ในลิ้นชัก posts · แต่ชื่อผู้เขียนอยู่อีกลิ้นชักหนึ่ง คือ users · บนแฟ้มบทความมีแค่ช่อง user_id (เลขที่ของผู้เขียน) ไม่มีชื่อ
ถ้าเขียนโค้ดแบบที่คนส่วนใหญ่เขียนตามธรรมชาติ คนดูแลสต็อกจะทำแบบนี้:
ปัญหานี้ชื่อ N+1 — ไปตู้ 1 รอบเพื่อเอารายการ แล้วไปอีก N รอบ (หนึ่งรอบต่อหนึ่งชิ้นในรายการ) · 6 ชิ้นยังไม่รู้สึก · แต่ 100 ชิ้นคือ 101 รอบ และทุกรอบต้องรอฐานข้อมูลตอบ หน้าเว็บจึงช้าลงตามจำนวนข้อมูลไปเรื่อย ๆ
เชฟสั่งคนดูแลสต็อกว่า "เอาแฟ้มบทความมา" · ได้มาแล้วเชฟอ่านทีละแผ่น · เจอช่อง "ผู้เขียนเลขที่ 1" ก็สั่ง "ไปหยิบแฟ้มผู้เขียนเลขที่ 1 มา" ·
คนดูแลสต็อกเดินไปลิ้นชัก users กลับมา · แผ่นถัดไปก็สั่งใหม่ เดินใหม่ — ทีละแผ่น ทีละรอบ
ทางที่ฉลาดกว่า: อ่านแฟ้มบทความให้ครบก่อน จดเลขผู้เขียนทั้งหมด (1, 2, 3) แล้วเดินไปรอบเดียว หยิบมาทั้งตะกร้า
ที่ไม่เหมือนกัน: คนดูแลสต็อกตัวจริงเดินไปสองสามรอบก็คงฉุกคิดเองว่า "เดี๋ยวนะ ขอเลขให้ครบก่อนดีกว่า" · แต่ Rails ไม่ฉุกคิดเอง — มันทำตามที่โค้ดสั่งเป๊ะ ๆ ทีละบรรทัด เราต้องบอกมันล่วงหน้าเองว่า "เดี๋ยวจะใช้ผู้เขียนด้วยนะ"
- N+1 query (N+1 problem)
- ไปฐานข้อมูล 1 รอบเอารายการ แล้วไปเพิ่มอีกหนึ่งรอบต่อทุกชิ้นในรายการ · เป็นสาเหตุอันดับต้น ๆ ที่หน้าเว็บ Rails ช้า · พิมพ์บอก AI ได้ว่า “this page has an N+1 query on post.user, here is the log”
- query
- คำสั่ง SQL หนึ่งประโยคที่ส่งไปฐานข้อมูล = เดินไปตู้หนึ่งรอบ · เวลาคนพูดว่า "หน้านี้ยิง 7 query" แปลว่าไปตู้ 7 รอบ · ถาม senior ได้ว่า “หน้านี้ควรยิงไม่เกินกี่ query ถึงจะถือว่าโอเค”
ลองใน console — นับ SELECT ด้วยตาตัวเอง
เปิด bin/rails console ในแอป wongnok_blog ที่มีข้อมูลจากบท 107 ·
console ของ Rails 8.1 โชว์ SQL ทุกบรรทัดให้อยู่แล้ว ไม่ต้องตั้งอะไรเพิ่ม (ถ้าดูสไลด์หน้า 37 ข้ามบรรทัด ActiveRecord::Base.logger = … ไปได้ — ดูท้ายบท)
คำสั่งที่จะพิมพ์ — "ขอชื่อผู้เขียนของทุกบทความ":
Post.all.map { |p| p.user.name }
Post.all— ใบเบิกของ "ขอบทความทุกชิ้น" (ยังไม่ไปตู้).map { |p| … }— วนทีละบทความ ตั้งชื่อเล่นว่าp· ตรงนี้แหละที่ใบเบิกถูกยื่น เพราะต้องใช้ข้อมูลจริงแล้วp.user.name— ขอผู้เขียนของบทความนี้ แล้วเอาชื่อ
wongnok-blog(dev):001> Post.all.map { |p| p.user.name }
มีบทความ 6 ชิ้น · จะเห็นบรรทัด SELECT โผล่มากี่บรรทัด?
แล้วสมชายเขียน 2 ชิ้น — Rails จะไปหาแฟ้มสมชายสองรอบ หรือจำได้ว่าเพิ่งหยิบมา?
ผลจริง — 7 บรรทัด · และไปหาสมชายสองรอบจริง (เลข 1 ซ้ำกัน · 2 ซ้ำ · 3 ซ้ำ):
wongnok-blog(dev):001> Post.all.map { |p| p.user.name }
Post Load (48.4ms) SELECT "posts".* FROM "posts" /*application='WongnokBlog'*/
User Load (1.5ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
User Load (1.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
User Load (1.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1 /*application='WongnokBlog'*/
User Load (1.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1 /*application='WongnokBlog'*/
User Load (1.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1 /*application='WongnokBlog'*/
User Load (1.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1 /*application='WongnokBlog'*/
=> ["สมชาย", "สมชาย", "สมหญิง", "สมหญิง", "มานะ", "มานะ"]
นี่คือคำตอบของคำถามในสไลด์หน้า 37 ("ถ้า 5 โพสต์เป็นของคนเดียวกัน ยิงกี่ครั้ง") ด้วย — ใน console ยังยิงครบ 1 + 5 · Rails ไม่ได้ถามว่า "คนนี้เพิ่งหยิบมาหรือเปล่า" มันแค่ทำตามคำสั่งทีละบทความ · (ในหน้าเว็บจริงจะต่างไปนิดหนึ่ง — เดี๋ยวเห็นในหัวข้อ log ของ server)
แปลบรรทัด SQL เป็นภาษาคน:
Post Load (48.4ms)— "คนดูแลสต็อกของPostไปหยิบแฟ้ม ใช้เวลา 48.4 มิลลิวินาที" (ตัวเลขเวลาเปลี่ยนทุกรอบ ไม่ต้องสนใจ)SELECT "posts".* FROM "posts"— "ขอแฟ้มทุกช่อง (*) จากลิ้นชักpostsทุกแผ่น" → รอบที่ 1SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1— "ขอแฟ้มทุกช่องจากลิ้นชักusersเฉพาะแผ่นที่เลขที่ (id) เป็น 1 · เอามาไม่เกินแผ่นเดียว" → รอบที่ 2 ถึง 7/*application='WongnokBlog'*/— ป้ายชื่อแอปที่ Rails แปะท้าย SQL (บท 113) ไม่ต้องสนใจ=> [...]— ผลของmapคือรายการชื่อ 6 ชื่อ
ทำไมเกิด: post.user คือการ "เดินไปตู้" ไม่ใช่ "อ่านช่องบนแฟ้ม"
ต้นเหตุทั้งหมดอยู่ที่หน้าตาของโค้ด · post.user หน้าตาเหมือนอ่านค่าธรรมดา เหมือน post.title ·
แต่ข้างในต่างกันมาก — ลองแยกดูทีละคำสั่งใน console (ผลจริง):
wongnok-blog(dev):001> post = Post.first
Post Load (1.6ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
=>
#<Post:0x00000001264959e0
...
wongnok-blog(dev):002> post.user_id
=> 1
wongnok-blog(dev):003> post.user
User Load (1.8ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
=>
#<User:0x0000000126c1abe8
id: 1,
name: "สมชาย",
email: "[FILTERED]",
…
wongnok-blog(dev):004> post.user.name
=> "สมชาย"
post = Post.first— หยิบแฟ้มบทความแผ่นแรกมาถือไว้ (ไปตู้ 1 รอบ ·ORDER BY "posts"."id" ASC= เรียงเลขที่น้อยไปมาก)post.user_id— ได้1และไม่มีบรรทัด SQL เลย · เพราะเลข 1 เขียนอยู่บนแฟ้มที่ถืออยู่แล้ว แค่อ่านpost.user— มีบรรทัดUser Loadโผล่ · ชื่อผู้เขียนไม่ได้อยู่บนแฟ้มบทความ คนดูแลสต็อกจึงต้องเดินไปลิ้นชักusers· (email: "[FILTERED]"คือ Rails ซ่อนอีเมลไม่ให้โชว์ในจอ — ในตู้ยังเป็นอีเมลจริง)post.user.name— ครั้งที่สองไม่ไปตู้แล้ว · บทความแผ่นนี้จำผู้เขียนที่เพิ่งหยิบมาไว้ · แต่จำได้เฉพาะแผ่นนี้ บทความแผ่นอื่นของสมชายไม่รู้เรื่องด้วย — จึงเกิดการไปหาสมชายซ้ำในหัวข้อที่แล้ว
Rails ไม่รู้ล่วงหน้าว่าเราจะถามผู้เขียนของทุกแผ่น · ตอนหยิบแฟ้มบทความมามันจึงไม่หยิบผู้เขียนมาด้วย · พอเราถามทีละแผ่น มันก็ไปตู้ทีละแผ่น — การไปตู้ "ตอนที่ถาม" แบบนี้เรียกว่า lazy loading
คำนี้เจอแล้วในบท 113 · หลักเดียวกัน คือ "ยังไม่ไปตู้จนกว่าจะต้องใช้" ·
บท 113 ใช้กับใบเบิกของ (Post.where(…)) · บทนี้ใช้กับผู้เขียนของแต่ละบทความ (post.user)
เทียบกับ Go (GORM บท 27): ใน Go ถ้าจะทำ N+1 คุณต้องเขียนคำสั่งไปตู้ในลูปเองกับมือ — เห็นชัดตั้งแต่อ่านโค้ด:
// Go + GORM — N+1 แบบเห็นด้วยตา
var posts []Post
db.Find(&posts) // ไปตู้ 1 รอบ
for _, p := range posts {
var u User
db.First(&u, p.UserID) // ไปตู้อีก 1 รอบ ต่อบทความ 1 ชิ้น
fmt.Println(u.Name)
}
ฝั่ง Rails คำว่า db.First ถูกซ่อนอยู่ใน p.user · โค้ดสั้นและอ่านง่ายกว่า แต่มองไม่เห็นว่ามีการไปตู้ ·
นี่คือเหตุผลของประโยคหลักวันนี้: N+1 ไม่ได้อ่านจากโค้ด — อ่านจาก log
- lazy loading
- ยังไม่โหลดของที่ผูกกันอยู่ (เช่น ผู้เขียนของบทความ) จนกว่าจะมีคนถามถึง · เป็นค่าเริ่มต้นของ Rails · สะดวก แต่ถ้าถามในลูปจะกลายเป็น N+1 · ถาม senior ได้ว่า “ตรงนี้ post.user ถูก lazy load ในลูปหรือเปล่า”
- association
- ความสัมพันธ์ระหว่างสองลิ้นชัก เช่น บทความ เป็นของ ผู้ใช้ (
belongs_to :user) ·post.userคือการเดินตามความสัมพันธ์นั้น (ละเอียดในบท 115) · พิมพ์บอก AI ได้ว่า “Post belongs_to :user, I loop over posts and call post.user” — AI จะรู้ทันทีว่าเสี่ยง N+1
แก้ด้วยคำเดียว: includes
ทางแก้คือบอก Rails ล่วงหน้าตอนเขียนใบเบิกว่า "เดี๋ยวจะใช้ผู้เขียนด้วยนะ หยิบมาเผื่อเลย" ·
คำที่ใช้บอกคือ includes(:user) — ชื่อ :user ต้องตรงกับที่เขียนใน model (belongs_to :user)
wongnok-blog(dev):002> Post.includes(:user).map { |p| p.user.name }
Post Load (2.4ms) SELECT "posts".* FROM "posts" /*application='WongnokBlog'*/
User Load (2.0ms) SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3) /*application='WongnokBlog'*/
=> ["สมชาย", "สมชาย", "สมหญิง", "สมหญิง", "มานะ", "มานะ"]
- เหลือ 2 บรรทัด จาก 7 · ผลลัพธ์เหมือนเดิมทุกตัวอักษร
- บรรทัดแรกเหมือนเดิม: หยิบบทความทุกแผ่น
WHERE "users"."id" IN (1, 2, 3)—IN (…)แปลว่า "เฉพาะแผ่นที่เลขที่เป็นตัวใดตัวหนึ่งในวงเล็บ" · ทั้งประโยค = "ขอแฟ้มทุกช่องจากลิ้นชักusersเฉพาะเลขที่ 1, 2 หรือ 3" — Rails อ่านแฟ้มบทความแล้วรวบเลขผู้เขียนมาให้เอง ซ้ำก็ตัดให้- หลังจากนั้น
p.userไม่ไปตู้แล้ว เพราะผู้เขียนทุกคนถูกหยิบมารอไว้แล้ว
ถ้ามีบทความ 100 ชิ้น ก็ยังเป็น 2 รอบ · จำนวนรอบไม่โตตามข้อมูลอีกต่อไป — ต่างจากเดิมที่ 100 ชิ้น = 101 รอบ
includes(:user) เหมือนเขียนเพิ่มท้ายใบเบิกว่า "พร้อมแฟ้มผู้เขียนของทุกแผ่น" ·
คนดูแลสต็อกหยิบแฟ้มบทความมาก่อน จดเลขผู้เขียนที่เจอ (1, 2, 3) แล้วเดินไปลิ้นชัก users รอบเดียว หยิบมาใส่ตะกร้าทั้งสามแผ่น ·
เชฟอ่านบทความแผ่นไหน ก็หยิบผู้เขียนจากตะกร้าข้างตัวได้เลย
ที่ไม่เหมือนกัน: ตะกร้าจริงหยิบของเกินมาก็แค่หนัก ·
แต่ includes ของที่ไม่ได้ใช้ ก็ยังเดินไปหยิบจริง เสียหนึ่งรอบฟรี ๆ — อย่าใส่ includes เผื่อไว้ทุกที่ ใส่เฉพาะของที่หน้านั้นใช้จริง
เทียบกับ Go: GORM ก็มีของแบบเดียวกันชื่อ Preload — db.Preload("User").Find(&posts) ·
เอกสาร GORM บอกว่าได้ SQL สองประโยค: ลิ้นชักหลักหนึ่งรอบ + WHERE … IN (…) อีกหนึ่งรอบ — หน้าตาเดียวกับที่เราเพิ่งเห็น
- eager loading ·
includes - โหลดของที่ผูกกันมาล่วงหน้า (ตรงข้ามกับ lazy loading) · ใน Rails สั่งด้วย
includes(:ชื่อความสัมพันธ์)· พิมพ์บอก AI ได้ว่า “add eager loading with includes(:user) to fix the N+1 in PostsController#index” WHERE id IN (…)- เงื่อนไข "ตรงกับตัวใดตัวหนึ่งในรายการ" · เห็นบรรทัดนี้ใน log หลังแก้ N+1 แปลว่า eager loading ทำงานแล้ว · ถาม senior ได้ว่า “ถ้ารายการใน IN ยาวเป็นพัน ๆ ตัว จะมีปัญหาไหม”
ในหน้าเว็บจริง — อ่าน log ของ server นับก่อน/หลัง
console ช่วยให้เข้าใจ · แต่ N+1 ตัวจริงเกิดในหน้าเว็บ และซ่อนอยู่ใน view · มาทำหน้า /posts จิ๋ว ๆ (ถ้ามีจากบท 113 แล้ว แก้ให้เป็นแบบนี้):
# config/routes.rb — ใน block ของ draw (บท 101)
resources :posts, only: :index
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.all
end
end
<%# app/views/posts/index.html.erb %>
<h1>บทความทั้งหมด</h1>
<ul>
<% @posts.each do |post| %>
<li><%= post.title %> — โดย <%= post.user.name %></li>
<% end %>
</ul>
only: :index— จาก 8 เส้นของresourcesเอาแค่เส้นGET /postsเส้นเดียว (เหมือนบท 113 · ละเอียดในบท 117)- controller วางใบเบิก "บทความทุกชิ้น" ไว้บนถาด
@posts(ยังไม่ไปตู้ — บท 113) - view บรรทัด 1:
<%# … %>— แค่โน้ตชื่อไฟล์ (comment ของ ERB ไม่โชว์บนหน้าเว็บ) · บทนี้นับมันเป็นบรรทัด 1 - view บรรทัด 4:
@posts.each— วนทีละบทความ (ยื่นใบเบิกตรงนี้) - view บรรทัด 5:
post.user.name— ตัวต้นเหตุ หน้าตาไม่มีพิษภัยอะไรเลย
เปิด bin/rails server หน้าต่างหนึ่ง · tail -f log/development.log อีกหน้าต่าง (บท 113) · แล้วเปิด http://localhost:3000/posts ในเบราว์เซอร์ ·
ด้านล่างผมใช้ curl แทน (curl = โปรแกรมเปิดหน้าเว็บจากเทอร์มินัล โชว์ HTML เป็นข้อความ · ใช้เบราว์เซอร์ก็ได้ผลเหมือนกัน) — ผลจริง:
$ curl -s localhost:3000/posts
…
<!-- BEGIN app/views/posts/index.html.erb
--><h1>บทความทั้งหมด</h1>
<ul>
<li>ต้มยำกุ้งน้ำข้น — โดย สมชาย</li>
<li>ส้มตำปูปลาร้า — โดย สมชาย</li>
…
<li>แกงเขียวหวานไก่ — โดย มานะ</li>
</ul>
<!-- END app/views/posts/index.html.erb -->
…
<!-- BEGIN … --> กับ <!-- END … --> = โน้ตที่ Rails โหมด development แปะบอกว่า "ส่วนนี้มาจาก view ไฟล์ไหน" (เบราว์เซอร์ไม่โชว์ ไม่ต้องสนใจ) ·
โน้ต BEGIN กินที่ไปหนึ่งบรรทัด — นี่คือเหตุที่เลขบรรทัดของ .erb ใน log เกินจริง 1 (เดี๋ยวเจอข้างล่าง)
หน้าเว็บถูกต้องทุกบรรทัด ไม่มี error · ถ้าดูแค่หน้าเว็บจะไม่มีวันรู้ว่ามีปัญหา · ต้องดูที่ log (ผลจริง):
Started GET "/posts" for ::1 at 2026-11-04 19:04:37 +0700
Processing by PostsController#index as */*
Rendering layout layouts/application.html.erb
Rendering posts/index.html.erb within layouts/application
Post Load (33.8ms) SELECT "posts".* FROM "posts" /*action='index',application='WongnokBlog',controller='posts'*/
↳ app/views/posts/index.html.erb:5
User Load (1.4ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 /*action='index',application='WongnokBlog',controller='posts'*/
↳ app/views/posts/index.html.erb:6
CACHE User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
↳ app/views/posts/index.html.erb:6
User Load (0.9ms) SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1 /*action='index',application='WongnokBlog',controller='posts'*/
↳ app/views/posts/index.html.erb:6
CACHE User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1
↳ app/views/posts/index.html.erb:6
User Load (0.9ms) SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1 /*action='index',application='WongnokBlog',controller='posts'*/
↳ app/views/posts/index.html.erb:6
CACHE User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1
↳ app/views/posts/index.html.erb:6
Rendered posts/index.html.erb within layouts/application (Duration: 57.4ms | GC: 1.1ms)
Rendered layout layouts/application.html.erb (Duration: 63.3ms | GC: 1.3ms)
Completed 200 OK in 87ms (Views: 4.3ms | ActiveRecord: 59.9ms (7 queries, 3 cached) | GC: 9.6ms)
อ่านแบบนักสืบ — สามที่ที่ต้องดู:
- บรรทัดสุดท้าย
(7 queries, 3 cached)— ดูตรงนี้ก่อนเสมอ · 7 คือจำนวนคำถามทั้งหมด · มี 6 บทความแต่ถาม 7 ครั้ง = กลิ่น N+1 User Loadซ้ำ ๆ กันหลายบรรทัด ต่างกันแค่เลข id — รูปแบบหน้าตาของ N+1 ที่ต้องจำให้ได้↳ app/views/posts/index.html.erb:6— ชี้ว่าบรรทัดไหนสั่ง · ลบหนึ่ง ได้บรรทัด 5 =post.user.nameพอดี (เลขบรรทัดไฟล์.erbใน log เกินจริง 1 — บท 113 เตือนไว้ · สาเหตุคือโน้ต<!-- BEGIN … -->ที่เพิ่งเห็นใน HTML)
ถ้าคุณไม่ได้พิมพ์บรรทัด 1 (โน้ตชื่อไฟล์) ทุกบรรทัดในไฟล์จะขยับขึ้นหนึ่ง log ของคุณจึงเป็น :4 กับ :5 แทน :5 กับ :6 (ผมลองแล้วทั้งสองแบบ) ·
กฎ "ลบหนึ่ง" ยังใช้ได้เหมือนเดิม
ของใหม่ที่ console ไม่มี: CACHE User Load (0.0ms) · นี่คือ query cache ที่เห็นแวบ ๆ ในบท 113 —
ระหว่างทำใบสั่งหนึ่งใบ Rails จำคำตอบของคำถามที่เพิ่งถามไว้ ถามซ้ำเป๊ะ ๆ (สมชายรอบที่สอง) ก็ตอบจากความจำ ไม่เดินไปตู้
log จึงนับว่า 7 query แต่ 3 ตัวในนั้นตอบจากความจำ ไปตู้จริง 4 รอบ · ความจำนี้ล้างทิ้งทุกครั้งที่ใบสั่งจบ · ใน console ไม่มีใบสั่ง จึงไม่มีความจำนี้ (ถึงเห็นสมชายสองรอบเต็ม ๆ เมื่อกี้)
⚠️ อย่าวางใจว่า CACHE จะช่วยได้ — มันช่วยได้เฉพาะตอนผู้เขียนซ้ำกัน · ถ้าบทความ 100 ชิ้นมาจากผู้เขียน 100 คน ก็ยังไปตู้ 101 รอบ
ทีนี้แก้ controller บรรทัดเดียว — view ไม่ต้องแตะเลย:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.includes(:user)
end
end
รีเฟรชหน้าเดิม · log ใหม่ (ผลจริง):
Started GET "/posts" for ::1 at 2026-11-04 19:04:39 +0700
Processing by PostsController#index as */*
Rendering layout layouts/application.html.erb
Rendering posts/index.html.erb within layouts/application
Post Load (2.4ms) SELECT "posts".* FROM "posts" /*action='index',application='WongnokBlog',controller='posts'*/
↳ app/views/posts/index.html.erb:5
User Load (1.5ms) SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3) /*action='index',application='WongnokBlog',controller='posts'*/
↳ app/views/posts/index.html.erb:5
Rendered posts/index.html.erb within layouts/application (Duration: 19.5ms | GC: 0.0ms)
Rendered layout layouts/application.html.erb (Duration: 20.8ms | GC: 0.0ms)
Completed 200 OK in 23ms (Views: 9.3ms | ActiveRecord: 12.2ms (2 queries, 0 cached) | GC: 0.0ms)
(2 queries, 0 cached)— จาก 7 เหลือ 2 · หน้าเว็บที่ได้เหมือนเดิมทุกตัวอักษร- ทั้งสองบรรทัดชี้
:5(= บรรทัด 4 คือ@posts.each) — ผู้เขียนถูกหยิบมาพร้อมกันตอนยื่นใบเบิก ไม่ใช่ตอนถามpost.user - เวลา
87ms→23msในเครื่องผม (6 บทความ ตัวเลขเล็กมาก ของคุณจะต่าง) · สไลด์หน้า 38 อ้างว่า หน้าที่โชว์ 20 โพสต์ใช้ 740ms ก่อนแก้ และ 40–60ms หลังแก้ — ผมไม่ได้วัดเลขชุดนั้น แต่ทิศทางเดียวกัน
includes · preload · eager_load — สามคำ สองวิธี
includes ไม่ใช่คำเดียวที่ทำแบบนี้ได้ · Rails มีวิธีหยิบของที่ผูกกันมาล่วงหน้า สองวิธีจริง ๆ และมีคำสั่งเรียกแต่ละวิธีตรง ๆ ·
includes คือ "ให้ Rails เลือกวิธีให้"
วิธีที่ 1 — preload: แยกไปสองลิ้นชัก สองรอบ (ผลจริง · เหมือนที่ includes ทำเมื่อกี้ทุกบรรทัด):
wongnok-blog(dev):001> Post.preload(:user).map { |p| p.user.name }
Post Load (50.4ms) SELECT "posts".* FROM "posts" /*application='WongnokBlog'*/
User Load (1.5ms) SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3) /*application='WongnokBlog'*/
=> ["สมชาย", "สมชาย", "สมหญิง", "สมหญิง", "มานะ", "มานะ"]
วิธีที่ 2 — eager_load: เย็บสองลิ้นชักเป็นแผ่นเดียว ไปรอบเดียว (ผลจริง):
wongnok-blog(dev):002> Post.eager_load(:user).map { |p| p.user.name }
Post Eager Load (3.1ms) SELECT "posts"."id" AS t0_r0, "posts"."title" AS t0_r1, "posts"."body" AS t0_r2, "posts"."created_at" AS t0_r3, "posts"."updated_at" AS t0_r4, "posts"."published" AS t0_r5, "posts"."views" AS t0_r6, "posts"."user_id" AS t0_r7, "users"."id" AS t1_r0, "users"."name" AS t1_r1, "users"."email" AS t1_r2, "users"."created_at" AS t1_r3, "users"."updated_at" AS t1_r4 FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id" /*application='WongnokBlog'*/
=> ["สมชาย", "สมชาย", "สมหญิง", "สมหญิง", "มานะ", "มานะ"]
ประโยคนี้ยาว แต่อ่านแค่สามท่อนก็พอ:
Post Eager Load— ป้ายหน้าบรรทัดเปลี่ยนจากPost Load· และมีบรรทัดเดียว ไม่มีUser LoadตามมาSELECT "posts"."id" AS t0_r0, … "users"."name" AS t1_r1, …— ขอทุกช่องของทั้งสองลิ้นชัก ·AS t0_r0คือตั้งชื่อเล่นให้แต่ละช่อง กันไม่ให้idของสองลิ้นชักชนกัน (ไม่ต้องอ่าน)FROM "posts" LEFT OUTER JOIN "users"— JOIN = "เอาแฟ้มจากสองลิ้นชักมาวางคู่กันเป็นแผ่นเดียว" ·LEFT OUTER= ถึงบทความไหนหาผู้เขียนไม่เจอ ก็ยังเอาบทความนั้นมา (ช่องผู้เขียนว่างไว้)ON "users"."id" = "posts"."user_id"— กติกาการจับคู่: "ผู้ใช้เลขที่ … คู่กับบทความที่ช่องuser_idเป็นเลขเดียวกัน"
preload = คนดูแลสต็อกเดินสองรอบ ได้สองกองมาวางบนโต๊ะ: กองบทความ กับกองผู้เขียน · Rails จับคู่ให้บนโต๊ะเอง ·
eager_load = ขอให้ห้องเก็บของถ่ายเอกสารแฟ้มบทความเย็บติดกับแฟ้มผู้เขียนมาเป็นแผ่นเดียว เดินรอบเดียว ·
includes = บอกคนดูแลสต็อกว่า "เลือกวิธีเองเลย"
ที่ไม่เหมือนกัน: ในชีวิตจริง "รอบเดียว" ดูดีกว่าเสมอ · แต่ในฐานข้อมูลไม่แน่ — ถ้าบทความหนึ่งมีคอมเมนต์ 50 อัน แผ่นที่เย็บติดกันจะมีข้อมูลบทความซ้ำ 50 แผ่น (สไลด์หน้า 34) ส่งของซ้ำมาเยอะ · บางครั้งสองรอบเบากว่า
เทียบกับ Go: GORM ก็มีสองแบบเหมือนกัน · db.Preload("User") = แบบ preload (แยกไปสองรอบ) ·
db.Joins("User") = แบบ eager_load (เอกสาร GORM เรียกว่า Joins Preloading — ใช้ JOIN ไปรอบเดียว) ·
ต่างกันที่ GORM ไม่มีตัวที่ "เลือกให้เอง" แบบ includes คุณต้องเลือกเองทุกครั้ง
แล้วเมื่อไหร่ต้องเลือกเอง — ตอนกรองด้วยลิ้นชักลูก
สมมติอยากได้ "บทความของมานะ พร้อมชื่อผู้เขียน" · เงื่อนไข "ชื่อผู้เขียนเป็นมานะ" อยู่ในลิ้นชัก users ไม่ใช่ posts ·
where(users: { name: "มานะ" }) คือ "เฉพาะแผ่นที่ช่อง name ของลิ้นชัก users เป็นมานะ" ·
เป็น hash ซ้อน hash: ชั้นนอก users: บอกลิ้นชัก · ชั้นใน { name: "มานะ" } บอกช่องกับค่า
wongnok-blog(dev):005> Post.preload(:user).where(users: { name: "มานะ" }).map(&:title)
preload แยกไปสองรอบ · รอบแรกหยิบจากลิ้นชัก posts อย่างเดียว · แล้วเงื่อนไขเรื่อง users จะไปอยู่ตรงไหน?
ผลจริง — error:
Post Load (1.2ms) SELECT "posts".* FROM "posts" WHERE "users"."name" = 'มานะ' /*application='WongnokBlog'*/
(wongnok-blog):5:in 'Enumerable#map': PG::UndefinedTable: ERROR: missing FROM-clause entry for table "users" (ActiveRecord::StatementInvalid)
LINE 1: SELECT "posts".* FROM "posts" WHERE "users"."name" = 'มานะ' ...
^
…
SQL รอบแรกขอจากลิ้นชัก posts อย่างเดียว แต่ไปตั้งเงื่อนไขกับช่องของ users ·
PostgreSQL ตอบว่า missing FROM-clause entry for table "users" = "คุณพูดถึงลิ้นชัก users แต่ไม่ได้เปิดลิ้นชักนั้นมาด้วย" ·
ลูกศร ^ ชี้ตรงคำว่า "users"."name" ที่เป็นปัญหา
ใช้ includes แทน — Rails เห็นว่ามีเงื่อนไขของลิ้นชักลูก จึงสลับไปใช้วิธีเย็บติดกัน (Eager Load + JOIN) ให้เอง (ผลจริง · ย่อรายชื่อช่องเป็น …):
wongnok-blog(dev):006> Post.includes(:user).where(users: { name: "มานะ" }).map(&:title)
Post Eager Load (2.7ms) SELECT "posts"."id" AS t0_r0, … FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id" WHERE "users"."name" = 'มานะ' /*application='WongnokBlog'*/
=> ["ผัดกะเพราหมูสับ", "แกงเขียวหวานไก่"]
สรุปเป็นตาราง (ผลจริงจาก console ทุกช่อง):
| คำสั่ง | ไปตู้กี่รอบ | SQL หน้าตา | กรองด้วยลิ้นชักลูกได้ไหม | ใช้เมื่อ |
|---|---|---|---|---|
preload(:user) | 2 (ลิ้นชักละรอบ) | ลิ้นชักหลัก + WHERE id IN (…) | ❌ error | แค่อยากเอามาโชว์ ไม่กรอง |
eager_load(:user) | 1 (เย็บเป็นประโยคเดียว) | LEFT OUTER JOIN | ✅ | ต้องกรองด้วยช่องของลิ้นชักลูก |
includes(:user) | ปกติ 2 · มีเงื่อนไขลิ้นชักลูกเป็น 1 | Rails เลือกให้ | ✅ (สลับเป็น JOIN เอง) | ใช้เป็นค่าเริ่มต้นได้ |
ระวัง to_sql (บท 113 · ดู SQL โดยไม่ยิง): กับ preload และ includes มันโชว์แค่ SQL ของลิ้นชัก posts (ผลจริง) ·
ไม่เห็นรอบที่สอง ที่ไปหยิบผู้เขียน — อยากรู้ว่าไปตู้กี่รอบจริง ต้องรันแล้วดู log
preload·eager_load- preload = โหลดของที่ผูกกันด้วย query แยก (2 รอบ) · eager_load = โหลดด้วย JOIN (1 รอบ) · includes = ให้ Rails เลือกระหว่างสองตัวนี้ · ถาม senior ได้ว่า “query นี้กรองด้วยตาราง users ด้วย ควรใช้ eager_load ไปเลยไหม จะได้อ่านโค้ดแล้วรู้ว่าเป็น JOIN”
- JOIN · LEFT OUTER JOIN
- คำสั่ง SQL ที่เอาแถวจากสองตารางมาต่อกันเป็นแถวเดียวตามกติกาจับคู่ (
ON …) · LEFT OUTER = เก็บแถวของตารางซ้ายไว้แม้หาคู่ไม่เจอ · พิมพ์บอก AI ได้ว่า “explain this LEFT OUTER JOIN query from my Rails log in simple words”
N+1 ตัวที่สอง: นับคอมเมนต์ด้วย count
ผู้เขียนเป็นแค่ตัวอย่างแรก · ทุกครั้งที่ในลูปมีการถามลิ้นชักอื่น ก็เป็น N+1 ได้หมด ·
ตัวที่เจอบ่อยมากคือ "บทความนี้มีกี่คอมเมนต์" (สไลด์หน้า 38 · post.comments ใช้ได้เพราะ has_many :comments บท 107) — ผลจริง:
wongnok-blog(dev):001> Post.all.map { |p| p.comments.count }
Post Load (42.8ms) SELECT "posts".* FROM "posts" /*application='WongnokBlog'*/
Comment Count (2.0ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1 /*application='WongnokBlog'*/
Comment Count (1.0ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 2 /*application='WongnokBlog'*/
…
Comment Count (0.9ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 6 /*application='WongnokBlog'*/
=> [2, 1, 1, 0, 3, 0]
SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1 = "ไม่ต้องหยิบแฟ้มมา แค่นับว่าในลิ้นชัก comments มีกี่แผ่นที่เป็นของบทความเลขที่ 1" ·
หน้าตาต่างจาก User Load แต่รูปแบบเดียวกัน: บรรทัดซ้ำ ๆ ต่างกันแค่เลข = 1 + 6
wongnok-blog(dev):002> Post.includes(:comments).map { |p| p.comments.count }
ใส่ includes(:comments) แล้ว คอมเมนต์ทุกอันวางรอในตะกร้าแล้ว · คิดว่าเหลือกี่ query?
ผลจริง — 8 query แย่กว่าเดิมอีก!
Post Load (2.6ms) SELECT "posts".* FROM "posts" /*application='WongnokBlog'*/
Comment Load (3.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3, 4, 5, 6) /*application='WongnokBlog'*/
Comment Count (1.6ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1 /*application='WongnokBlog'*/
…
Comment Count (0.9ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 6 /*application='WongnokBlog'*/
=> [2, 1, 1, 0, 3, 0]
includes หยิบคอมเมนต์มาใส่ตะกร้าแล้วจริง (Comment Load … IN (1, 2, 3, 4, 5, 6)) ·
แต่ count ไม่มองตะกร้า มันเดินไปให้ตู้นับใหม่ทุกครั้ง (บท 113: count = ไปตู้เสมอ) ·
ต้องเปลี่ยนเป็น size ซึ่ง "ถ้ามีของในตะกร้าแล้ว นับจากตะกร้า":
wongnok-blog(dev):003> Post.includes(:comments).map { |p| p.comments.size }
Post Load (2.3ms) SELECT "posts".* FROM "posts" /*application='WongnokBlog'*/
Comment Load (1.7ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3, 4, 5, 6) /*application='WongnokBlog'*/
=> [2, 1, 1, 0, 3, 0]
เหลือ 2 · นี่แหละเหตุผลที่ต้องอ่าน log ทุกครั้ง — ใส่ includes แล้วคิดว่าแก้แล้ว แต่ log บอกว่ายังไม่หาย
วิธีที่ดีกว่านี้สำหรับ "นับคอมเมนต์" คือให้แฟ้มบทความมีช่องเก็บจำนวนคอมเมนต์ไว้เลย ไม่ต้องนับใหม่ทุกครั้ง — ชื่อ counter_cache อยู่ในบท 116
ข้อมูลเยอะ ๆ: find_each — ยกแฟ้มทีละลัง ไม่ยกทั้งตู้
ปัญหานี้คนละเรื่องกับ N+1 แต่มักเจอคู่กัน · Post.all.each = หยิบแฟ้มทุกแผ่นมากองไว้ในหน่วยความจำ (RAM) ทีเดียว แล้วค่อยวน ·
6 แผ่นไม่เป็นไร · สไลด์หน้า 36 ยกตัวอย่าง 2 ล้านแถวบนเครื่อง RAM 2 GB — กองไม่พอ โปรแกรมพัง
find_each วนได้เหมือน each แต่หยิบมาทีละชุด · ลองชุดละ 2 แผ่นกับข้อมูลเรา (ผลจริง):
wongnok-blog(dev):001> Post.find_each(batch_size: 2) { |p| puts p.title }
Post Load (1.4ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT 2 /*application='WongnokBlog'*/
ต้มยำกุ้งน้ำข้น
ส้มตำปูปลาร้า
Post Load (1.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" > 2 ORDER BY "posts"."id" ASC LIMIT 2 /*application='WongnokBlog'*/
ข้าวมันไก่ประตูน้ำ
ก๋วยเตี๋ยวเรืออยุธยา
Post Load (0.7ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" > 4 ORDER BY "posts"."id" ASC LIMIT 2 /*application='WongnokBlog'*/
ผัดกะเพราหมูสับ
แกงเขียวหวานไก่
Post Load (1.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" > 6 ORDER BY "posts"."id" ASC LIMIT 2 /*application='WongnokBlog'*/
=> nil
batch_size: 2— ชุดละ 2 แผ่น (ค่าเริ่มต้นคือ 1000 · ใช้ 2 แค่ให้เห็นกับข้อมูล 6 แผ่น)- ลัง 1:
ORDER BY "posts"."id" ASC LIMIT 2= "เรียงตามเลขที่น้อยไปมาก เอา 2 แผ่นแรก" → ได้เลขที่ 1, 2 - ลัง 2:
WHERE "posts"."id" > 2 … LIMIT 2= "เลขที่มากกว่า 2 เอามา 2 แผ่น" — จำเลขสุดท้ายของลังก่อน แล้วต่อจากตรงนั้น - ลังสุดท้าย
id > 6ไม่ได้อะไรกลับมา → รู้ว่าหมดแล้ว จบ · ในหน่วยความจำมีแฟ้มไม่เกิน 2 แผ่นตลอดเวลา => nil— ค่าที่find_eachคืนมาเมื่อวนจบ ไม่ต้องสนใจ (ของที่เราอยากเห็นคือชื่อที่putsพิมพ์ออกมา)
เทียบกับ Go: GORM มี FindInBatches ทำหน้าที่เดียวกัน —
db.FindInBatches(&results, 100, func(tx *gorm.DB, batch int) error { … }) = หยิบมาชุดละ 100 แล้วส่งให้ฟังก์ชันของเราทีละชุด
ต้องตรวจแฟ้มทุกแผ่นในตู้ · all.each = ยกแฟ้มทั้งตู้มากองบนโต๊ะทีเดียว — โต๊ะเล็ก กองไม่พอ ล้ม ·
find_each = ยกมาทีละลัง ตรวจเสร็จเก็บคืน แล้วยกลังถัดไป · จดไว้ว่าลังที่แล้วจบที่แฟ้มเลขอะไร จะได้ต่อถูก
ที่ไม่เหมือนกัน: ยกทีละลังจริง ๆ คุณเลือกได้ว่าจะไล่ตามวันที่หรือตามชื่อ ·
แต่ find_each ไล่ตามเลขที่ (id) เสมอ — ถ้าสั่งเรียงแบบอื่นไว้ มันทิ้งให้เลย (ดูกล่องเตือนข้างล่าง)
⚠️ เรียงเองไม่ได้ — ลอง Post.order(views: :desc).find_each(batch_size: 2) { … } ได้ข้อความนี้จริง แล้วลำดับออกมาตามเลขที่เหมือนเดิม:
Scoped order is ignored, use :cursor with :order to configure custom order.
Post Load (1.7ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT 2 /*application='WongnokBlog'*/
= "คำสั่งเรียงที่ใส่มาถูกทิ้ง" · ไม่ใช่ error งานยังทำต่อ — จึงพลาดง่ายถ้าไม่อ่าน log
พี่น้องของมันชื่อ in_batches — แทนที่จะส่งให้ทีละแผ่น มันส่งทั้งลังให้ (เป็นใบเบิกของหนึ่งใบ) ·
เหมาะกับงานแบบ "แก้ทุกแผ่นในลังนี้ด้วยคำสั่งเดียว" · บทนี้แค่ให้รู้จักชื่อ
- batch ·
find_each·in_batches - batch = ชุดย่อย · find_each วนทีละแผ่นแต่ดึงจากฐานข้อมูลทีละชุด · in_batches ส่งทั้งชุดให้ · ใช้กับงานที่วนข้อมูลทั้งตาราง (เช่น งานเบื้องหลัง Day 3) · ถาม senior ได้ว่า “ตารางนี้ใหญ่แค่ไหน งานนี้ควรใช้ find_each ไหม batch_size เท่าไหร่ดี”
- memory (RAM) · load the whole table
- หน่วยความจำของเครื่อง = โต๊ะที่วางแฟ้ม ·
Post.all.eachกับตารางใหญ่ = ยกทั้งตู้มาวางโต๊ะ · พิมพ์บอก AI ได้ว่า “this loop loads the whole posts table into memory, rewrite it with find_each”
ตัวช่วยจับ: strict_loading และ bullet
อ่าน log ทุกหน้าด้วยตาเป็นนิสัยที่ดี แต่คนลืมได้ · Rails จึงมีของที่โวยวายแทนเรา ·
strict_loading = "บทความชุดนี้ห้ามไปตู้ตอนถามผู้เขียน ถ้าไม่ได้ includes มาก่อน ให้ระเบิดเลย" (ผลจริง):
wongnok-blog(dev):003> post = Post.strict_loading.first
Post Load (2.5ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
=>
#<Post:0x0000000123dc1a58
...
wongnok-blog(dev):004> post.title
=> "ต้มยำกุ้งน้ำข้น"
wongnok-blog(dev):005> post.user
(wongnok-blog):5:in '<main>': `Post` is marked for strict_loading. The User association named `:user` cannot be lazily loaded. (ActiveRecord::StrictLoadingViolationError)
wongnok-blog(dev):006> Post.strict_loading.includes(:user).first.user.name
Post Load (1.5ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
User Load (0.8ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 /*application='WongnokBlog'*/
=> "สมชาย"
post.title— ปกติ เพราะเป็นช่องบนแฟ้มเอง ไม่ต้องไปตู้post.user— error: "บทความนี้ถูกตั้งเป็น strict_loading · ห้ามโหลด:userแบบ lazy (ตอนถาม)" · เจอบรรทัดนี้ = เจอ N+1 ก่อนมันขึ้นเว็บจริง- บรรทัดสุดท้าย — ใส่
includes(:user)แล้วผ่าน · SQL ของผู้เขียนเป็น= 1แทนIN (1)เพราะมีบทความแผ่นเดียว ผู้เขียนคนเดียว Rails เลยเขียนแบบสั้น (ความหมายเดียวกัน)
ถ้าอยากให้ model ทั้งตัวเข้มแบบนี้ สไลด์หน้า 36 ใส่ self.strict_loading_by_default = true ไว้ใน class Post
(self. ในตัว class = ตั้งค่าให้ทั้ง class ไม่ใช่แฟ้มแผ่นเดียว) ·
ผมลองตั้งค่าเดียวกันใน console (Post.strict_loading_by_default = true) แล้ว Post.first.user ได้ error ตัวเดียวกันจริง ·
แต่ระวัง: หน้าเก่าที่ลืม includes จะพังทันที — จะเปิดกับ model ไหน คุยกับทีมก่อน
bullet เป็น gem ที่สไลด์แนะนำ — ระหว่างพัฒนามันคอยดูทุกหน้าแล้วเตือนว่า "หน้านี้มี N+1 ควรเติม includes ตรงนี้" หรือ "includes ตัวนี้ไม่ได้ใช้ ลบได้" ·
ไม่ได้มากับแอป และบทนี้ไม่ได้ติดตั้ง · ข้อความตัวอย่างจากสไลด์หน้า 36 (ไม่ได้รันในบทนี้):
N+1 Query detected · Post => [:user] · Add to your query: .includes([:user]) ·
ถ้าทีมใช้ อ่านวิธีตั้งค่าที่ github.com/flyerhzm/bullet
- strict loading ·
StrictLoadingViolationError - โหมดที่ห้าม lazy loading · ถ้าโค้ดไปถามของที่ผูกกันโดยไม่ได้ includes มาก่อน จะได้ error ชื่อนี้ · ถาม senior ได้ว่า “โปรเจกต์นี้เปิด strict_loading ไว้ที่ model ไหนบ้าง หรือเปิดแค่ตอนรันเทส”
- bullet
- gem ที่ตรวจหา N+1 และ eager loading ที่ไม่ได้ใช้ ระหว่างพัฒนา · พิมพ์บอก AI ได้ว่า “bullet says N+1 Query detected Post => [:user], where should I add includes?” แล้วแปะข้อความเตือนทั้งก้อน
นิสัยเดียวที่ต้องติดตัว: เปิด log นับ query ก่อน merge
merge = รวมโค้ดของเราเข้า branch หลัก (เช่น main) ให้ทั้งทีมใช้ · หัวข้อนี้คือ "ก่อนส่งงานให้ทีม"
ทั้งบทสรุปได้ว่า อ่านโค้ดอย่างเดียวไม่มีทางรู้ — post.user กับ post.title หน้าตาเหมือนกัน · count กับ size ได้ผลเท่ากัน ·
ที่รู้ได้ทุกครั้งคือ log · ก่อนส่งงานหน้าไหนที่โชว์เป็นรายการ ทำ 4 ข้อนี้:
- เปิดหน้านั้นในเบราว์เซอร์ แล้วดู
tail -f log/development.log - อ่านบรรทัด
Completed …→ ดู(N queries, M cached)· ตัวเลขควรไม่โตตามจำนวนแถว (ลองเพิ่มข้อมูลอีกสักแถวแล้วเปิดใหม่ ถ้าตัวเลขขึ้นตาม = N+1) - มองหาบรรทัด
… Loadหรือ… Countซ้ำ ๆ ต่างกันแค่เลข · ตาม↳ไปหาบรรทัดต้นเหตุ (.erbลบหนึ่ง) - แก้ด้วย
includesที่ controller · นับคู่กับsizeไม่ใช่count· แล้วเปิดหน้าใหม่ นับซ้ำ — อย่าเชื่อว่าแก้แล้วจนกว่า log จะบอก
ข้อนี้สำคัญเป็นพิเศษกับโค้ดที่ AI เขียนให้ · AI ชอบเขียน post.user.name ใน view โดยไม่เติม includes เพราะมันไม่เห็น log ของคุณ ·
คุณเห็น — แปะ log ให้มันดูแล้วบอกว่า "fix the N+1 in this log" ได้ผลกว่าสั่งลอย ๆ
🎯 ภารกิจ — จับ N+1 สองตัวในหน้าเดียว (~10 นาที)
ทำในแอป wongnok_blog ที่มีข้อมูลจากบท 107 · ใช้หน้า /posts ในหัวข้อ "ในหน้าเว็บจริง" ของบทนี้ (route + controller + view)
- ตั้ง controller เป็น
@posts = Post.all· แก้บรรทัด<li>ใน view ให้โชว์จำนวนคอมเมนต์ด้วย:<li><%= post.title %> — โดย <%= post.user.name %> (<%= post.comments.count %> คอมเมนต์)</li> - เปิด
bin/rails serverหน้าต่างหนึ่ง ·tail -f log/development.logอีกหน้าต่าง · เปิดhttp://localhost:3000/posts - จดตัวเลขจากบรรทัด
Completed …และนับว่ามีUser Loadกี่บรรทัด ·Comment Countกี่บรรทัด - แก้ controller เป็น
@posts = Post.includes(:user)→ รีเฟรช → จดตัวเลขใหม่ - แก้เป็น
@posts = Post.includes(:user, :comments)และเปลี่ยนcountใน view เป็นsize→ รีเฟรช → จดตัวเลขสุดท้าย
ก่อนกดเฉลย: ขั้น 3 · 4 · 5 คิดว่า (N queries) จะเป็นเท่าไหร่? และขั้น 4 ทำไมยังไม่จบ?
สิ่งที่ควรเห็น (ผลจริงจากเครื่องที่ใช้ตรวจ · เวลา ms ของคุณจะต่าง แต่จำนวน query ต้องตรง):
| ขั้น | controller · view | บรรทัด Completed (ตัดมาเฉพาะส่วน ActiveRecord) |
|---|---|---|
| 3 | Post.all · count | ActiveRecord: 73.9ms (13 queries, 3 cached) — 1 + ผู้เขียน 6 (3 ตัวจากความจำ) + นับคอมเมนต์ 6 |
| 4 | includes(:user) · count | (8 queries, 0 cached) — ผู้เขียนหายเป็น IN (1, 2, 3) บรรทัดเดียว แต่ Comment Count ยังอยู่ครบ 6 |
| 5 | includes(:user, :comments) · size | (3 queries, 0 cached) — บทความ 1 · ผู้เขียน 1 · คอมเมนต์ 1 (IN (1, 2, 3, 4, 5, 6)) |
ขั้น 4 ยังไม่จบเพราะแก้ไปแค่ N+1 ตัวแรก · หน้าหนึ่งมี N+1 ได้หลายตัว ต้องนับใหม่หลังแก้ทุกครั้ง ·
(ลองเล่นต่อ: ขั้น 5 ถ้าใช้ size แต่ลืม :comments ใน includes ผมได้ 8 query — size เห็นตะกร้าว่างก็ไปให้ตู้นับเหมือนกัน)
ถ้าเจอ error นี้ แปลว่า…
Association named 'users' was not found on Post; perhaps you misspelled it? (ActiveRecord::AssociationNotFoundError) Did you mean? user— คุณเขียนincludes(:users)(มี s) · ชื่อใน includes ต้องตรงกับใน model: บทความbelongs_to :user(เอกพจน์ เพราะมีผู้เขียนคนเดียว) · Rails ใบ้ให้แล้วว่าDid you mean? userPG::UndefinedTable: ERROR: missing FROM-clause entry for table "users"— คุณใช้preloadคู่กับwhere(users: …)· เปลี่ยนเป็นincludesหรือeager_loadActiveRecord::StrictLoadingViolationError— มีคนเปิด strict_loading ไว้ · ไม่ใช่บั๊กของ Rails แต่มันบอกว่าคุณกำลังจะสร้าง N+1 · เติมincludesตัวที่มันบอกชื่อ- ไม่เห็นบรรทัด SQL เลย — คุณดูหน้าต่างผิด · SQL ขึ้นในหน้าต่าง
tail -f(หรือหน้าต่าง server) ไม่ใช่ในเบราว์เซอร์
ตัวเลขของคุณไม่ตรงกับตาราง หรือติดตรงไหน ถามกลับมาได้เลย พร้อมแปะ log ของใบสั่งนั้นทั้งก้อน (ตั้งแต่ Started GET ถึง Completed)
log ของหน้า /posts (สไลด์หน้า 38) มี Post Load 1 บรรทัด · User Load 20 บรรทัด · Comment Count 20 บรรทัด · หน้านี้ไปตู้ประมาณกี่รอบ?
ตัวเลือกที่ 3 ถูก — 1 + 20 + 20 = 41 · N+1 สองตัวซ้อนกัน (ผู้เขียน กับ จำนวนคอมเมนต์) · ในบรรทัด Completed จะเห็นเป็น (41 queries, …) (ถ้าผู้เขียนซ้ำกันบางตัวจะเป็น cached)
ตัวเลือกที่ 1 คือจำนวนที่ควรจะเป็นหลังแก้แล้ว ไม่ใช่ตอนนี้ ·
ตัวเลือกที่ 2 ลืมว่า Comment Count ก็ไปตู้เหมือนกัน แค่หน้าตาต่าง ·
ตัวเลือกที่ 4 สับสน "ใบสั่งจากลูกค้า" (request) กับ "การไปตู้" (query) — ใบสั่งเดียวไปตู้ได้หลายสิบรอบ
จุดที่ต้องจำ: นับทุกบรรทัด … Load / … Count · บรรทัดซ้ำ ๆ ต่างกันแค่เลข = N+1 หนึ่งตัว
หน้าเดียวกันนั้น ต้องโชว์ชื่อผู้เขียนและจำนวนคอมเมนต์ · แบบไหนเหลือ 3 query (บทความ · ผู้เขียน · คอมเมนต์ อย่างละรอบ)?
ตัวเลือกที่ 4 ถูก — ผลจริงในภารกิจ: (3 queries, 0 cached) · คอมเมนต์อยู่ในตะกร้าแล้ว และ size นับจากตะกร้า
ตัวเลือกที่ 1 ลืมหยิบคอมเมนต์มา size เลยไปให้ตู้นับทีละบทความ (ผมลองได้ 8 query) ·
ตัวเลือกที่ 2 หยิบคอมเมนต์มาแล้วแต่ count ไม่มองตะกร้า ไปตู้ใหม่ทุกครั้ง (ผลจริงในหัวข้อคอมเมนต์ · แย่กว่าเดิม) ·
ตัวเลือกที่ 3 แก้แค่ผู้เขียน จำนวนคอมเมนต์ยังเป็น N+1
จุดที่ต้องจำ: includes ของที่ใช้ให้ครบทุกตัว · นับของที่ includes มาแล้วใช้ size · แล้วเปิด log ยืนยัน
ถือแฟ้มบทความไว้แล้ว (post = Post.first) · ข้อไหนอธิบายถูกว่าทำไม post.user_id ไม่ขึ้น SQL แต่ post.user ขึ้น?
ตัวเลือกที่ 1 ถูก — บนแฟ้มบทความมีแค่เลขที่ผู้เขียน (user_id = 1) · ชื่อและอีเมลอยู่ในลิ้นชัก users · post.user จึงต้องเดินไปหยิบ (lazy loading)
ตัวเลือกที่ 2 ผิด เพราะ user_id ไม่ได้มาจาก cache แต่มากับแฟ้มตั้งแต่ Post.first ·
ตัวเลือกที่ 3 ผิด เพราะในหน้าเว็บจริงครั้งแรกก็ไปตู้เหมือนกัน (เห็นใน log ของ server) ความจำช่วยแค่คำถามที่ซ้ำ ·
ตัวเลือกที่ 4 ผิด เพราะ post.user แค่หาแฟ้มผู้เขียน ไม่ได้ตรวจสิทธิ์อะไร (เรื่องสิทธิ์อยู่ Day 4)
จุดที่ต้องจำ: ช่องบนแฟ้ม = อ่านฟรี · ของที่อยู่ลิ้นชักอื่น = ไปตู้ · ไปตู้ในลูป = N+1
ต้องเขียนงานที่วนแก้บทความทุกชิ้น และสไลด์บอกว่าตารางจริงมี 2 ล้านแถว · ควรวนแบบไหน?
ตัวเลือกที่ 2 ถูก — ดึงชุดละ 1000 (ค่าเริ่มต้น) ด้วย WHERE id > … LIMIT … · ในหน่วยความจำมีแฟ้มไม่เกินหนึ่งชุดตลอด
ตัวเลือกที่ 1 ไปตู้รอบเดียวจริง แต่ยกมาทั้งตู้ 2 ล้านแฟ้มเข้าหน่วยความจำ — โต๊ะล้ม ·
ตัวเลือกที่ 3 แก้ N+1 ของผู้เขียน ไม่ได้แก้เรื่องจำนวนแถว ยังยกทั้งตู้เหมือนเดิม ·
ตัวเลือกที่ 4 ไม่ได้ลำดับตามยอดวิว — ผลจริงขึ้น Scoped order is ignored แล้วไล่ตาม id
จุดที่ต้องจำ: N+1 = ไปตู้หลายรอบเกินไป · all.each กับตารางใหญ่ = ยกของมากเกินไปในรอบเดียว · คนละปัญหา คนละทางแก้
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง (Rails 8.1.4) | ต้องสนใจไหม |
|---|---|---|---|
| 37 | ขั้น 1: ตั้ง ActiveRecord::Base.logger = Logger.new(STDOUT) เพื่อให้เห็น SQL | console ใน development โชว์ SQL ให้อยู่แล้ว · ถ้าตั้งตาม ทุกบรรทัดจะมีหัว D, [2026-…] DEBUG -- : เพิ่มมา | ไม่ต้อง — ข้ามได้ |
| 37 | คำถาม "ถ้า 5 โพสต์เป็นของคนเดียวกัน ยิงกี่ครั้ง" | console: ยังยิงครบ 1 + 5 (ไม่มีความจำ) · ในหน้าเว็บ: นับครบเหมือนกัน แต่ตัวซ้ำขึ้นเป็น CACHE / cached | สนใจ — คำตอบขึ้นกับว่ารันที่ไหน |
| 33 · 38 | SQL เป็น SELECT * FROM users WHERE id = 1 | SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 + ป้าย /*…*/ | ไม่ต้อง — ความหมายเดียวกัน |
| 38 | 740ms ก่อนแก้ · 40–60ms หลังแก้ | ตัวเลขของสไลด์ ไม่ได้วัดในบทนี้ · กับข้อมูล 6 แถวผมได้ 87ms → 23ms | ไม่ต้อง — ดูจำนวน query ดีกว่าดูเวลา |
| 36 | find_each ทิ้ง order ของเรา "ถ้าต้องเรียงเองให้ใช้ in_batches" | in_batches ก็ขึ้น Scoped order is ignored, use :cursor with :order … เหมือนกัน และแบ่งชุดตาม id | สนใจ ถ้าจะเรียงเอง — ข้อความเตือนชี้ทางให้ |
อ้างอิง
- Rails Guides — Active Record Query Interface § N + 1 Queries Problem — อ่านต่อถึง Solution to N + 1 Queries Problem แล้วหยุด · ตัวอย่างในคู่มือเป็นหนังสือ 10 เล่มกับผู้แต่ง ภาพเดียวกับบทความ 6 ชิ้นของเรา
- Rails Guides — § Eager Loading Associations — อ่านหัวข้อย่อย includes · preload · eager_load (ข้ามเรื่อง multiple associations ไปก่อนได้) · ยืนยันว่า includes + where ตารางลูกจะกลายเป็น LEFT OUTER JOIN
- Rails Guides — § Retrieving Multiple Records in Batches —
อ่านแค่ find_each กับ option
:batch_size· ย่อหน้า "If an order is present in the relation…" ยืนยันว่า order ถูกทิ้งพร้อมคำเตือน (ค่าเริ่มต้น) - Rails API — ActiveRecord::Batches#find_each —
ดูแค่รายการ options ·
:cursorกับ:orderคือสิ่งที่ข้อความ "Scoped order is ignored, use :cursor with :order" ชี้ไป ถ้าวันหนึ่งต้องไล่ด้วยลำดับอื่น - Rails Guides — § Strict Loading —
ยืนยัน
strict_loadingและStrictLoadingViolationError· อ่านสั้น ๆ ไม่กี่ย่อหน้า - Rails Guides — Caching with Rails § SQL Caching —
ที่มาของบรรทัด
CACHE … Load· อ่านแค่ย่อหน้าแรก: ความจำนี้อยู่แค่ช่วงหนึ่ง request - GORM — Preloading (Eager Loading) —
ฝั่ง Go ที่เทียบกัน · ดูตัวอย่าง Traditional API บรรทัด
db.Preload("Orders").Find(&users)ที่ได้ SQLWHERE user_id IN (…)แบบเดียวกับpreload· หัวข้อ Joins Preloading คือแบบ JOIN รอบเดียวที่เทียบกับeager_load - GORM — Advanced Query § FindInBatches —
ฝั่ง Go ของ
find_each· ดูแค่ตัวอย่างแรก (ชุดละ 100) - GitHub — flyerhzm/bullet — gem ตรวจ N+1 ที่สไลด์แนะนำ (บทนี้ไม่ได้ติดตั้ง) · อ่านแค่ส่วน Configuration ถ้าทีมของคุณใช้