บทเรียน 114 · Rails · Day 2

N+1 — โค้ด 3 บรรทัดที่เดินไปตู้ 101 รอบ

~20 นาที · ควรผ่าน บทเรียน 113 (ใบเบิกของ · อ่าน log นับ query) และ บทเรียน 107 (ข้อมูลตัวอย่าง · post.user) มาก่อน

ปัญหาที่เจอกันทุกทีม: หน้าเว็บที่โค้ดดูธรรมดามาก แต่ช้าลงเรื่อย ๆ ตามจำนวนบทความ
อ่านจบบทนี้ คุณจะมองเห็นปัญหาที่ชื่อ N+1 ใน log ได้เอง · นับได้ว่าหน้าหนึ่งไปถามฐานข้อมูลกี่รอบ · แก้ด้วยคำเดียว includes · และรู้ว่าเมื่อไหร่ต้องใช้ preload หรือ eager_load แทน
ประโยคที่จะติดตัวไปจากบทนี้: N+1 ไม่ได้อ่านจากโค้ด — อ่านจาก log

🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้

🧰 ตรวจด้วย 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 (เลขที่ของผู้เขียน) ไม่มีชื่อ

ถ้าเขียนโค้ดแบบที่คนส่วนใหญ่เขียนตามธรรมชาติ คนดูแลสต็อกจะทำแบบนี้:

รอบที่ 1 ลิ้นชัก posts → หยิบแฟ้มบทความมาทั้ง 6 แผ่น ← นี่คือ "1" ได้แฟ้มแล้ว เริ่มโชว์ทีละชิ้น … ชิ้นแรกเขียนว่า user_id = 1 รอบที่ 2 ลิ้นชัก users → หาแฟ้มเลขที่ 1 → "สมชาย" รอบที่ 3 ลิ้นชัก users → หาแฟ้มเลขที่ 1 → "สมชาย" (คนเดิม! แต่เดินไปอีก) รอบที่ 4 ลิ้นชัก users → หาแฟ้มเลขที่ 2 → "สมหญิง" รอบที่ 5 ลิ้นชัก users → หาแฟ้มเลขที่ 2 → "สมหญิง" รอบที่ 6 ลิ้นชัก users → หาแฟ้มเลขที่ 3 → "มานะ" รอบที่ 7 ลิ้นชัก users → หาแฟ้มเลขที่ 3 → "มานะ" ← 6 รอบนี้คือ "N" รวม 1 + 6 = 7 รอบ · ถ้ามีบทความ 100 ชิ้น = 1 + 100 = 101 รอบ

ปัญหานี้ชื่อ N+1 — ไปตู้ 1 รอบเพื่อเอารายการ แล้วไปอีก N รอบ (หนึ่งรอบต่อหนึ่งชิ้นในรายการ) · 6 ชิ้นยังไม่รู้สึก · แต่ 100 ชิ้นคือ 101 รอบ และทุกรอบต้องรอฐานข้อมูลตอบ หน้าเว็บจึงช้าลงตามจำนวนข้อมูลไปเรื่อย ๆ

🚶 เทียบให้เห็นภาพ — เดินไปตู้ 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 }
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.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
=> "สมชาย"

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'*/
=> ["สมชาย", "สมชาย", "สมหญิง", "สมหญิง", "มานะ", "มานะ"]

ถ้ามีบทความ 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>

เปิด 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)

อ่านแบบนักสืบ — สามที่ที่ต้องดู:

ถ้าคุณไม่ได้พิมพ์บรรทัด 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)

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'*/
=> ["สมชาย", "สมชาย", "สมหญิง", "สมหญิง", "มานะ", "มานะ"]

ประโยคนี้ยาว แต่อ่านแค่สามท่อนก็พอ:

📎 เทียบให้เห็นภาพ — สองกองบนโต๊ะ vs แฟ้มที่เย็บติดกันมา

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 · มีเงื่อนไขลิ้นชักลูกเป็น 1Rails เลือกให้✅ (สลับเป็น 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

เทียบกับ 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'*/
=> "สมชาย"

ถ้าอยากให้ 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 ข้อนี้:

  1. เปิดหน้านั้นในเบราว์เซอร์ แล้วดู tail -f log/development.log
  2. อ่านบรรทัด Completed … → ดู (N queries, M cached) · ตัวเลขควรไม่โตตามจำนวนแถว (ลองเพิ่มข้อมูลอีกสักแถวแล้วเปิดใหม่ ถ้าตัวเลขขึ้นตาม = N+1)
  3. มองหาบรรทัด … Load หรือ … Count ซ้ำ ๆ ต่างกันแค่เลข · ตาม ↳ ไปหาบรรทัดต้นเหตุ (.erb ลบหนึ่ง)
  4. แก้ด้วย 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)

  1. ตั้ง controller เป็น @posts = Post.all · แก้บรรทัด <li> ใน view ให้โชว์จำนวนคอมเมนต์ด้วย:
    <li><%= post.title %> — โดย <%= post.user.name %> (<%= post.comments.count %> คอมเมนต์)</li>
  2. เปิด bin/rails server หน้าต่างหนึ่ง · tail -f log/development.log อีกหน้าต่าง · เปิด http://localhost:3000/posts
  3. จดตัวเลขจากบรรทัด Completed … และนับว่ามี User Load กี่บรรทัด · Comment Count กี่บรรทัด
  4. แก้ controller เป็น @posts = Post.includes(:user) → รีเฟรช → จดตัวเลขใหม่
  5. แก้เป็น @posts = Post.includes(:user, :comments) และเปลี่ยน count ใน view เป็น size → รีเฟรช → จดตัวเลขสุดท้าย

ก่อนกดเฉลย: ขั้น 3 · 4 · 5 คิดว่า (N queries) จะเป็นเท่าไหร่? และขั้น 4 ทำไมยังไม่จบ?

สิ่งที่ควรเห็น (ผลจริงจากเครื่องที่ใช้ตรวจ · เวลา ms ของคุณจะต่าง แต่จำนวน query ต้องตรง):

ขั้นcontroller · viewบรรทัด Completed (ตัดมาเฉพาะส่วน ActiveRecord)
3Post.all · countActiveRecord: 73.9ms (13 queries, 3 cached) — 1 + ผู้เขียน 6 (3 ตัวจากความจำ) + นับคอมเมนต์ 6
4includes(:user) · count(8 queries, 0 cached) — ผู้เขียนหายเป็น IN (1, 2, 3) บรรทัดเดียว แต่ Comment Count ยังอยู่ครบ 6
5includes(: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? user
  • PG::UndefinedTable: ERROR: missing FROM-clause entry for table "users" — คุณใช้ preload คู่กับ where(users: …) · เปลี่ยนเป็น includes หรือ eager_load
  • ActiveRecord::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) เพื่อให้เห็น SQLconsole ใน development โชว์ SQL ให้อยู่แล้ว · ถ้าตั้งตาม ทุกบรรทัดจะมีหัว D, [2026-…] DEBUG -- : เพิ่มมาไม่ต้อง — ข้ามได้
37คำถาม "ถ้า 5 โพสต์เป็นของคนเดียวกัน ยิงกี่ครั้ง"console: ยังยิงครบ 1 + 5 (ไม่มีความจำ) · ในหน้าเว็บ: นับครบเหมือนกัน แต่ตัวซ้ำขึ้นเป็น CACHE / cachedสนใจ — คำตอบขึ้นกับว่ารันที่ไหน
33 · 38SQL เป็น SELECT * FROM users WHERE id = 1SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 + ป้าย /*…*/ไม่ต้อง — ความหมายเดียวกัน
38740ms ก่อนแก้ · 40–60ms หลังแก้ตัวเลขของสไลด์ ไม่ได้วัดในบทนี้ · กับข้อมูล 6 แถวผมได้ 87ms → 23msไม่ต้อง — ดูจำนวน query ดีกว่าดูเวลา
36find_each ทิ้ง order ของเรา "ถ้าต้องเรียงเองให้ใช้ in_batches"in_batches ก็ขึ้น Scoped order is ignored, use :cursor with :order … เหมือนกัน และแบ่งชุดตาม idสนใจ ถ้าจะเรียงเอง — ข้อความเตือนชี้ทางให้

อ้างอิง