3 นาที

ทดสอบโหลด Go ก่อนจ้าง backend คนแรก

ใช้การทดสอบโหลด Go เพื่อจำลองทราฟฟิกจริง วัด p95 latency แรงกดดันฐานข้อมูล หน่วยความจำ และข้อผิดพลาด แล้วตัดสินใจว่าควรจ้างหรือไม่

ทดสอบโหลด Go ก่อนจ้าง backend คนแรก

ผู้ใช้รายเดือนที่ใช้งาน 10,000 คนไม่ใช่ข้อกำหนดด้านความจุ เป็นเพียงตัวเลขด้านการเรียกเก็บเงินหรือการวิเคราะห์ บริการ Go อาจรองรับได้มากกว่านั้นมากเมื่อคำขอเบาและกระจายตลอดเดือน หรืออาจล่มเมื่อมีผู้ใช้เพียงไม่กี่ร้อยคน หากแต่ละเซสชันแตกออกเป็นคิวรีช้า การอัปโหลด และการเรียกบริการภายนอก

คำถามที่มีประโยชน์คือ แบ็กเอนด์ที่สร้างมาบรรลุเป้าหมายบริการที่ระบุไว้ภายใต้ทราฟฟิกสูงสุดที่คาดไว้หรือไม่ โดยมีพื้นที่เผื่อสำหรับการเติบโตและความล้มเหลวทั่วไป คุณตอบได้ก่อนจ้าง backend engineer แต่เฉพาะเมื่อ load test มีลักษณะเหมือนผลิตภัณฑ์ของคุณ และบันทึกสิ่งที่ API, Go runtime และ PostgreSQL กำลังทำอยู่พร้อมกัน กราฟ latency เฉลี่ยที่เป็นสีเขียวแทบพิสูจน์อะไรไม่ได้

นี่คือการทดสอบที่ผมจะขอก่อนบอกผู้ก่อตั้งว่าแบ็กเอนด์ Go ที่สร้างไว้นั้นพร้อมสำหรับผู้ใช้รายเดือนที่ใช้งาน 10,000 คน ผลลัพธ์ผ่านหรือไม่ผ่านทำซ้ำได้ ชี้คอขวดแรก และแยกปัญหาความจุออกจากปัญหาความถูกต้อง

เปลี่ยนจำนวนผู้ใช้รายเดือนเป็นคำขอช่วงพีค

แปลงการคาดการณ์ผู้ใช้เป็นคำขอต่อวินาทีก่อนเลือกระดับโหลด จำนวนผู้ใช้รายเดือนที่ใช้งานซ่อนตัวแปรสองอย่างที่ขับเคลื่อนแบ็กเอนด์ ได้แก่ จำนวนเซสชันที่เข้ามาในช่วงคึกคักที่สุด และปริมาณงานที่แต่ละเซสชันสร้าง

เริ่มจากข้อมูลที่สังเกตได้หากมี private beta นับเซสชันใน 15 นาทีที่คึกคักที่สุด จำนวนคำขอต่อเซสชัน และสัดส่วนเส้นทาง หากยังไม่มีทราฟฟิก ให้เขียนสมมติฐานไว้ในที่ที่ทุกคนทักท้วงได้ ตัวอย่างเช่น ผู้ใช้ที่ใช้งาน 10,000 คนสร้างแปดเซสชันต่อเดือน แต่ละเซสชันมีคำขอ API 15 ครั้ง และ 20 เปอร์เซ็นต์ของทราฟฟิกรายวันเกิดในชั่วโมงที่คึกคักที่สุด จะได้ประมาณ 8 คำขอต่อวินาทีในวันคึกคักโดยเฉลี่ย การเปิดตัว การแจ้งเตือน กำหนดส่งเงินเดือน หรือเขตเวลาร่วมกัน อาจทำให้ช่วงพีคจริงสูงกว่านี้หลายเท่า

อย่าทำให้การคำนวณนี้ดูแม่นยำเกินจริง ใช้เพื่อกำหนดการทดสอบสามระดับ:

  • ช่วงพีคที่คาดไว้: โหลดที่คึกคักที่สุดที่คุณคาดการณ์ในปัจจุบัน
  • ช่วงพีคเมื่อเติบโต: สองเท่าของช่วงพีคที่คาดไว้ เว้นแต่ธุรกิจจะมีการคาดการณ์ที่ดีกว่า
  • ระดับ stress: เพิ่มทราฟฟิกจนเป้าหมายบริการล้มเหลวหรือทรัพยากรอิ่มตัว

การทดสอบช่วงพีคที่คาดไว้และช่วงเติบโตตอบว่าการเปิดตัวที่วางแผนไว้มีพื้นที่เผื่อหรือไม่ ส่วน stress test บอกว่าอะไรพังก่อนและความล้มเหลวปรากฏต่อผู้ใช้อย่างไร คำตอบหลังสำคัญ เพราะบริการที่ปฏิเสธงานส่วนเกินได้เร็วจัดการง่ายกว่าบริการที่ใช้คอนเน็กชันฐานข้อมูลทุกเส้นจนเส้นทางที่ไม่เกี่ยวข้องหยุดรอ

ใช้โมเดลโหลดแบบเปิดสำหรับการทดสอบความจุ โมเดลเปิดเริ่มคำขอตามอัตราการมาถึงคงที่ แม้คำขอก่อนหน้าจะช้าลง โมเดลปิดที่มี virtual user จำนวนคงที่มักซ่อนการพัง เมื่อการตอบสนองช้าลง ผู้ใช้เหล่านั้นจะส่งคำขอใหม่น้อยลง ทำให้โหลดที่เสนอเข้ามาลดลงพอดีกับตอนที่บริการกำลังลำบาก เอกสาร k6 ของ Grafana อธิบายความต่างนี้ผ่าน arrival-rate executors และรายงาน dropped_iterations เมื่อเครื่องสร้างโหลดเริ่มงานตามกำหนดไม่ได้ ให้ถือว่า dropped iterations คือความล้มเหลวของเครื่องทดสอบ ไม่ใช่ความสำเร็จของเซิร์ฟเวอร์

รันแต่ละระดับคงที่อย่างน้อย 30 นาทีหลังวอร์มอัป การทดสอบห้านาทีจะพลาดการหมุนเวียนคอนเน็กชัน รอบ garbage collection การขับ cache งานเบื้องหลัง และการเพิ่มขึ้นของหน่วยความจำอย่างค่อยเป็นค่อยไป เมื่อการทดสอบสั้นผ่านแล้ว ให้เพิ่ม soak test แยกต่างหากสองชั่วโมงที่ช่วงพีคคาดไว้

เขียนการแปลงเป็นตารางคำนวณเล็ก ๆ และให้เห็นทุกหน่วยชัดเจน จำนวนผู้ใช้รายเดือนคูณเซสชันต่อผู้ใช้และคำขอต่อเซสชัน จะได้คำขอรายเดือน ค่อยหารหลังจากจัดสรรทราฟฟิกให้วันทำการและชั่วโมงที่คึกคักที่สุดแล้ว จากนั้นเพิ่มทราฟฟิกจากการ retry งานเบื้องหลัง webhooks และ polling ที่การวิเคราะห์ผู้ใช้อาจไม่นับ frontend ที่ poll ทุกสิบวินาทีอาจสร้างงาน API มากกว่าการคลิกที่เปิดหน้า

จำลอง burst แยกจากช่วงพีคคงที่ การล็อกอินหลังการแจ้งเตือน การนำเข้าที่เสร็จ หรือไคลเอนต์ที่ retry หลังเหตุขัดข้องสั้น ๆ อาจรวมงานไว้ในหนึ่งนาที เพิ่มช่วง burst ที่ขึ้นถึงอัตรา burst ที่คาดไว้อย่างรวดเร็ว คงไว้นานพอให้คิวเต็ม แล้วกลับสู่ปกติ บริการควรฟื้นได้โดยไม่มี backlog ที่เพิ่มขึ้นหรือการรีสตาร์ตด้วยมือ บันทึกเวลาฟื้นตัวเป็นผลลัพธ์ ระบบอาจผ่านการทดสอบคงที่แต่ยังไม่ปลอดภัย หาก burst สั้น ๆ ทำให้พูลหรือ workers ค้าง

อย่าคูณอัตราสุดท้ายด้วย safety factor ตามอำเภอใจแล้วเรียกว่าสมจริง ผูกพื้นที่เผื่อกับความไม่แน่นอนทางธุรกิจ เช่น ความคลาดเคลื่อนของการคาดการณ์ แคมเปญที่วางแผนไว้ replica หนึ่งตัวใช้งานไม่ได้ หรือเวลาที่ต้องใช้เพื่อเพิ่มความจุ ทดสอบทุกสมมติฐานที่คุณตั้งใจจะพึ่งพา เก็บตารางคำนวณไว้ข้างผลลัพธ์ เพราะผลการรันที่ผ่านจะหมดความหมายเมื่อไม่มีใครจำได้ว่าการคาดการณ์และสมมติฐาน burst ใดสร้างเป้าหมายขึ้นมา

สัดส่วนทราฟฟิกต้องคล้ายเซสชันจริง

load test ที่สมจริงรักษาความถี่ของเส้นทาง ขนาด payload การยืนยันตัวตน การกระจายข้อมูล think time และการแย่งกันเขียน การเรียก /health 100 ครั้งต่อวินาทีวัด handler สุขภาพ ไม่ใช่แอปพลิเคชัน

สร้างสัดส่วนจาก access logs หากทำได้ จัดกลุ่มเส้นทางตามการกระทำทางธุรกิจแทน URL ดิบ เพราะ /projects/123 และ /projects/456 มีรูปแบบเดียวกัน SaaS ระยะแรกที่พอเป็นไปได้อาจจัด 45 เปอร์เซ็นต์ให้การอ่านรายการและรายละเอียด 20 เปอร์เซ็นต์ให้การค้นหา 15 เปอร์เซ็นต์ให้การสร้างหรืออัปเดต 10 เปอร์เซ็นต์ให้การล็อกอินและรีเฟรช token และ 10 เปอร์เซ็นต์ให้ export หรืองานหนักอื่น ๆ ตัวเลขของคุณควรมาจาก flow ของผลิตภัณฑ์ ไม่ใช่ตัวอย่างนี้

ใช้บัญชีทดสอบและระเบียนจำนวนมาก การใช้บัญชีเดียวซ้ำอาจทำให้ cache ร้อนผิดจริง ทำให้การอัปเดตต่อแถวเดียวเรียงลำดับกัน หรือชน rate limit ที่ทราฟฟิกจริงจะกระจายออกไป สร้างข้อมูล tenant ขนาดเล็ก กลาง และใหญ่ รวมระเบียนที่ไม่มีอยู่ input ที่ไม่ถูกต้อง และการยืนยันสิทธิ์ที่ล้มเหลว เพราะเส้นทางข้อผิดพลาดมัก query ฐานข้อมูลหรือจอง response body ต่างจากกรณีสำเร็จ

เก็บการอัปโหลดขนาดใหญ่และ export ที่ยาวไว้ใน scenario ของตัวเองหากมีเป้าหมายบริการต่างกัน แต่ยังต้องรันพร้อมทราฟฟิกปกติ มิฉะนั้นการทดสอบจะพลาด incident ที่ผู้ใช้สังเกตได้พอดี คือ export ประเภทหนึ่งยึดพูลไว้ ขณะที่หน้าตั้งค่าง่าย ๆ ต้องรออยู่ข้างหลัง

อย่า mock PostgreSQL, object storage, queues หรือบริการขาออกในการรันความจุสุดท้าย mock มีประโยชน์สำหรับแยกต้นทุนของ handler แต่ตัด dependencies ที่มีแนวโน้มกำหนดความจุที่สุดออกไป ชี้การทดสอบไปยัง staging stack ที่มีขนาด instance การตั้งค่าฐานข้อมูล indexes ขีดจำกัดคอนเน็กชัน และเส้นทางเครือข่ายเหมือน production ข้อมูลที่ล้างแล้วแต่มีลักษณะเหมือน production ดีกว่า seed rows ที่เหมือนกันหนึ่งพันแถว

หลีกเลี่ยงการทดสอบผ่าน content delivery cache หากปกติ API ไม่ได้ถูก cache ในทางกลับกัน ให้คง cache จริงไว้ในเส้นทางเมื่อ production ใช้มัน จุดประสงค์ไม่ใช่ทำให้แบ็กเอนด์ดูยุ่ง จุดประสงค์คือทำซ้ำงานที่คำขอของผู้ใช้ก่อจริง

k6 test ที่รันได้ควรเข้ารหัสสัญญาไว้

เก็บ thresholds และ traffic stages ไว้ใน version control เพื่อไม่ให้การทดสอบกลายเป็นภาพหน้าจอที่ตีความภายหลัง k6 ใช้ thresholds เป็นเกณฑ์ผ่านหรือไม่ผ่าน และออกด้วยสถานะ nonzero เมื่อล้มเหลว จึงเหมาะจะใช้เป็น release check

โครงตัวอย่างต่อไปนี้ขับเซสชันผสมด้วย arrival rate ตรวจ semantics ของการตอบสนอง และกำหนด latency แยกสำหรับการอ่านทั่วไปและ export หนัก แทนที่ routes, payloads และเป้าหมายด้วยค่าที่ตกลงกันสำหรับผลิตภัณฑ์ของคุณ โค้ดใน test ตั้งใจให้เรียบง่าย เพื่อให้ check ที่ล้มเหลวโยงกลับไปยังการกระทำของผู้ใช้ได้

import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

const businessErrors = new Rate('business_errors');

export const options = {
  scenarios: {
    expected_peak: {
      executor: 'ramping-arrival-rate',
      startRate: 5,
      timeUnit: '1s',
      preAllocatedVUs: 40,
      maxVUs: 200,
      stages: [
        { target: 10, duration: '5m' },
        { target: 10, duration: '30m' },
        { target: 20, duration: '10m' },
        { target: 20, duration: '30m' },
      ],
    },
  },
  thresholds: {
    'http_req_duration{name:project_list}': ['p(95)<300'],
    'http_req_duration{name:project_create}': ['p(95)<500'],
    'http_req_duration{name:export}': ['p(95)<2000'],
    http_req_failed: ['rate<0.01'],
    business_errors: ['rate<0.005'],
    dropped_iterations: ['count==0'],
  },
};

export function setup() {
  const response = http.post(`${__ENV.BASE_URL}/api/login`, JSON.stringify({
    email: __ENV.TEST_EMAIL,
    password: __ENV.TEST_PASSWORD,
  }), { headers: { 'Content-Type': 'application/json' } });
  check(response, { 'login succeeds': r => r.status === 200 });
  return { token: response.json('token') };
}

export default function (data) {
  const headers = { Authorization: `Bearer ${data.token}`, 'Content-Type': 'application/json' };
  const list = http.get(`${__ENV.BASE_URL}/api/projects?limit=25`, {
    headers,
    tags: { name: 'project_list' },
  });
  businessErrors.add(!check(list, {
    'list status is 200': r => r.status === 200,
    'list has items': r => Array.isArray(r.json('items')),
  }));

  if (Math.random() < 0.25) {
    const create = http.post(`${__ENV.BASE_URL}/api/projects`, JSON.stringify({
      name: `load-${__VU}-${__ITER}`,
    }), { headers, tags: { name: 'project_create' } });
    businessErrors.add(!check(create, { 'create status is 201': r => r.status === 201 }));
  }

  if (Math.random() < 0.03) {
    const runExport = http.post(`${__ENV.BASE_URL}/api/exports`, '{}', {
      headers,
      tags: { name: 'export' },
    });
    businessErrors.add(!check(runExport, { 'export accepted': r => r.status === 202 }));
  }

  sleep(Math.random() * 2 + 1);
}

เป้าหมายตัวอย่างเป็นจุดเริ่มต้น ไม่ใช่คำสัญญาที่ใช้ได้ทั่วไป กำหนด p95 ตามแต่ละกลุ่มเส้นทางจากเวลาหน่วงที่ผู้ใช้ทนได้และข้อกำหนดของผลิตภัณฑ์เอง อย่าใช้ threshold 300 ms เดียวตัดสินทั้ง export แบบ asynchronous และคำขอ autocomplete

รันสคริปต์จากเครื่องที่ไม่ได้โฮสต์แอปพลิเคชัน ยืนยันว่าเครื่องสร้างโหลดมี CPU เหลือและไม่มี dropped iterations บันทึก commit ที่แน่นอน การตั้งค่า environment ตัวระบุ data snapshot คำสั่ง และผลทดสอบดิบ หากไม่มีสิ่งเหล่านี้ การเปรียบเทียบครั้งหลังจะอาศัยความทรงจำและความหวังเป็นส่วนใหญ่

ปรับเทียบเครื่องสร้างโหลดก่อนเชื่อผลการรันยาว ชี้มันไปยัง handler เล็ก ๆ ที่ไม่มีงานฐานข้อมูล เพิ่ม arrival rate ที่ร้องขอให้สูงกว่าการทดสอบที่วางไว้ และตรวจว่ารักษาอัตราได้โดยไม่ใช้ CPU, sockets หรือเครือข่ายของตัวเองจนหมด เมื่อ k6 เพิ่ม virtual users หรือรายงาน dropped iterations เครื่องสร้างโหลดอาจเป็นข้อจำกัด กระจายการสร้างโหลดไปยังหลายเครื่องเมื่อเครื่องเดียวส่งงานที่ต้องการไม่ได้ และทำให้นาฬิกาของทุกเครื่องตรงกันเพื่อให้กราฟเซิร์ฟเวอร์และไคลเอนต์เทียบกันได้

ให้แต่ละรันมี baseline ที่เงียบ หยุด migrations การนำเข้าข้อมูล และ staging jobs ที่ไม่เกี่ยวข้อง เว้นแต่งานเหล่านั้นจะรันในช่วงพีคจริง จากนั้นจัดการทดสอบครั้งที่สองโดยเปิดงานเบื้องหลังจริง ชุดทดสอบนี้บอกทั้งความจุ API ที่สะอาดและความจุในการปฏิบัติการที่ผู้ใช้จะได้รับ หากผ่านเฉพาะรันที่เงียบ แผนเปิดตัวกำลังพึ่งสิ่งที่ staging สร้างขึ้น

สร้างสคริปต์ที่สองสำหรับ user journey เดียว และรันด้วย virtual user หนึ่งตัวก่อนเพิ่มโหลด ตรวจทุก response ระเบียนที่สร้าง และการ cleanup วิธีนี้จับ token ที่ผิด check ที่คืน true ตลอด หรือข้อมูลทดสอบที่ชนกันหลัง iteration แรกได้ ผลความจุไม่มีความหมายหากสคริปต์กำลังทดสอบหน้า error หรืออ่าน object ที่ cache เดียวกันซ้ำ ๆ

p95 ต้องมีบริบทระดับเส้นทาง

ใช้ p95 latency เพราะค่าเฉลี่ยซ่อนกลุ่มที่ช้า แต่ห้ามอ่าน p95 เพียงอย่างเดียว ที่ p95 800 ms คำขอหนึ่งในยี่สิบใช้เวลาอย่างน้อยเท่านั้น ซึ่งอาจทำให้หน้าที่มีหลายคำขอรู้สึกช้าอยู่เสมอ percentile ยังผันผวนสำหรับเส้นทางที่มีตัวอย่างน้อย จึงรายงานจำนวนคำขอไว้ข้างกัน

บันทึก p50, p95, p99, ค่าสูงสุด, throughput และอัตราข้อผิดพลาดสำหรับทุกการกระทำทางธุรกิจที่ตั้งชื่อ p50 แสดงพฤติกรรมปกติ p95 เป็นประตูบริการที่ใช้ได้จริง และ p99 เปิดให้เห็นหางโดยไม่ปล่อยให้ค่าสูงสุดเพียงครั้งเดียวครอบงำการตัดสินใจ แยกผลตาม status code response 500 ที่เร็วต้องไม่ทำให้เรื่อง latency ดูดีขึ้น

วัดระยะเวลาของ handler ฝั่งเซิร์ฟเวอร์ควบคู่กับระยะเวลาที่ไคลเอนต์เห็น ช่องว่างรวมการตั้งค่าคอนเน็กชัน proxies เวลาเครือข่าย และการส่ง response หาก client p95 เพิ่มแต่ handler p95 คงที่ ให้มองนอก handler หากทั้งคู่เพิ่มและเวลารอฐานข้อมูลสูงขึ้น คำขอน่าจะกำลังต่อคิวรอคอนเน็กชันหรือคิวรี

ผลวอร์มอัปและผล steady state ต้องแยกกัน การคอมไพล์ไม่ได้เกิดใน Go binary ที่ deploy แล้ว แต่ cold caches คอนเน็กชันฐานข้อมูลใหม่ lazy initialization และ autoscaling อาจบิดเบือนนาทีแรก ผู้ใช้ยังพบพฤติกรรมตอนเย็นอยู่ จึงเก็บเป็นผลลัพธ์แยก แทนที่จะลบทิ้ง

ค่าเฉลี่ยยังมีประโยชน์ต่อการบัญชีทรัพยากร เวลาฐานข้อมูลรวมหารด้วยจำนวนครั้งเรียกช่วยระบุคิวรีที่ช้าปานกลางแต่ถูกเรียกบ่อยมากได้ แต่แทนเป้าหมายบริการแบบ percentile ไม่ได้ สิ่งที่มักถูกปนกันคือ latency กับ capacity: latency บอกว่างานที่เสร็จใช้เวลานานเท่าไร ขณะที่ capacity บอกว่าบริการรักษาปริมาณงานที่เสนอเข้ามาได้เท่าไรโดยไม่ให้คิวหรือข้อผิดพลาดเพิ่มขึ้น latency ที่ดีในอัตราคำขอที่ทำได้ต่ำ ไม่ได้พิสูจน์ capacity

กำหนดความล้มเหลวก่อนรัน ผมจะถือว่า launch test ไม่ผ่านเมื่อเส้นทางสำคัญใดพลาดเป้าหมาย p95 HTTP failures ที่ไม่คาดคิดเกินอัตราที่ตกลง business checks ล้มเหลว scheduled iterations หล่น หรือทรัพยากรหนึ่งยังอิ่มตัว การผ่านสี่จากห้าประตูคือรันที่ไม่ผ่าน แต่มีข้อมูลวินิจฉัยที่มีประโยชน์

การรอคอนเน็กชันเผยคิวฐานข้อมูลที่ซ่อนอยู่

ย้อนการเปลี่ยนแปลงด้านประสิทธิภาพที่มีปัญหา
สแนปช็อตและการย้อนกลับช่วยให้การเปลี่ยนแปลงแบ็กเอนด์ที่สร้างไว้มีทางกู้คืนเมื่อรีลีสไม่ผ่านเป้าหมาย

ทำ instrumentation ให้ database/sql ก่อนทดสอบ เพราะ latency ของแอปพลิเคชันบอกไม่ได้ว่า PostgreSQL ช้าหรือแอปกำลังรอเข้าถึงมัน DB.Stats() ของ Go รายงาน OpenConnections, InUse, Idle, WaitCount และ WaitDuration พร้อมตัวนับการปิด ส่งออกเมตริกเหล่านี้ไปยังระบบ metrics ทุกไม่กี่วินาที

func recordDBStats(ctx context.Context, db *sql.DB, g GaugeSet) {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            s := db.Stats()
            g.Set("db_open_connections", float64(s.OpenConnections))
            g.Set("db_in_use_connections", float64(s.InUse))
            g.Set("db_idle_connections", float64(s.Idle))
            g.Set("db_wait_count_total", float64(s.WaitCount))
            g.Set("db_wait_seconds_total", s.WaitDuration.Seconds())
        }
    }
}

คำนวณการเปลี่ยนแปลงของตัวนับสะสมในช่วง steady window WaitCount ที่เพิ่มขึ้นหมายถึงคำขอต้องรอคอนเน็กชันว่าง นำการเปลี่ยนแปลงของ WaitDuration หารด้วยการเปลี่ยนแปลงของ WaitCount จะได้เวลารอพูลเฉลี่ยของช่วงนั้น สร้างกราฟ InUse เทียบกับ MaxOpenConnections; เส้นราบที่ขีดจำกัดพร้อมการรอที่เพิ่มขึ้นคือพูลอิ่มตัว

อย่าตอบสนองด้วยการเพิ่ม SetMaxOpenConns จนกราฟดูดีขึ้น วิธีแก้ที่นิยมนี้ย้ายคิวเข้า PostgreSQL และอาจเพิ่มการแย่งทรัพยากร การใช้หน่วยความจำ และ latency ของคิวรี ระบุก่อนว่าทำไมคอนเน็กชันจึงถูกใช้นาน: คิวรีช้า transactions ที่เปิดค้างระหว่างเรียกเครือข่าย อ่านแถวทีละแถว หรือเรียก Rows.Close() ไม่ครบ แล้วกำหนดขนาดพูลภายในงบคอนเน็กชันของฐานข้อมูลให้ครอบคลุมทุก application replica และ worker

เอกสาร Go ระบุว่า SetMaxOpenConns ที่มีค่าไม่เป็นบวกจะปล่อยพูลไม่จำกัด ค่าไม่จำกัดเป็นค่าเริ่มต้นที่อันตรายใน production เมื่อหลาย replicas เปิดคอนเน็กชันพร้อมกัน กำหนดขีดจำกัดชัดเจน ตั้งค่า idle และ lifetime อย่างตั้งใจ และกันความจุฐานข้อมูลไว้สำหรับ migrations การดูแลระบบ และงานเบื้องหลัง

ติดตามระยะเวลา transaction แยกต่างหาก handler อาจตอบใน 200 ms แต่ cleanup ที่ defer หรือ transaction ที่รั่วอาจยึดคอนเน็กชันไว้นานกว่านั้นมาก เมตริกพูลเผยแรงกดดัน แต่ traces หรือการวัดเวลา transaction เผยเจ้าของ

หลักฐานคิวรีช้าต้องมาจาก PostgreSQL

เปิด pg_stat_statements ในสภาพแวดล้อมทดสอบ และเก็บสแนปช็อตก่อนและหลังแต่ละรัน เอกสาร PostgreSQL อธิบายว่ามันติดตามสถิติการวางแผนและการประมวลผลของ statements ที่ถูกทำให้เป็นรูปแบบเดียวกัน มุมมองมีจำนวน calls, rows, เวลาประมวลผลรวมและเฉลี่ย กิจกรรม block และกิจกรรม temporary block หลักฐานนี้ดีกว่าการเดาจากคิวรีที่ปรากฏใน trace หนึ่งครั้ง

ใช้ผลต่างระหว่างสแนปช็อตเพราะมุมมองสะสม รีเซ็ตเฉพาะในฐานข้อมูลทดสอบที่แยกไว้ เพราะการรีเซ็ตทำลายหลักฐานงานอื่น คิวรีนี้หาคำสั่งที่ใช้เวลาประมวลผลมากที่สุดระหว่างหน้าต่างทดสอบที่สะอาด:

SELECT
  queryid,
  calls,
  round(total_exec_time::numeric, 1) AS total_ms,
  round(mean_exec_time::numeric, 2) AS mean_ms,
  rows,
  shared_blks_read,
  temp_blks_written
FROM pg_stat_statements
WHERE calls > 20
ORDER BY total_exec_time DESC
LIMIT 20;

เวลารวมพบคิวรีที่เรียกบ่อยและครองงานฐานข้อมูล เวลาเฉลี่ยพบ statements ที่ช้ารายตัว แต่ทั้งคู่ไม่ได้ให้ query p95 เพราะ pg_stat_statements รวม calls ไว้ ใช้ tracing หรือ duration histograms เมื่อต้องการ tail latency ของ statement เดียว นี่คืออีกสิ่งที่มักสับสนกัน: คิวรีช้าอาจหมายถึงเวลาประมวลผลเฉลี่ยสูง เวลาหางสูง หรือต้นทุนรวมมหาศาลจากความถี่สูง แต่ละแบบต้องแก้ต่างกัน

สำหรับ statements อันดับต้น ๆ ให้รัน EXPLAIN (ANALYZE, BUFFERS) ด้วยพารามิเตอร์ที่ปลอดภัยและเป็นตัวแทน นอกการทดสอบโหลดที่จับเวลา ANALYZE รัน statement จริง จึงครอบ statements ที่เปลี่ยนข้อมูลด้วย transaction ที่ rollback หรือดูบนสำเนาที่ใช้ทิ้งได้ มองหา estimates ที่ต่างจากจำนวนแถวจริงมาก loops ซ้ำ การสแกนตามลำดับบนตารางใหญ่ที่มีเงื่อนไขคัดเลือก การ sort ที่เขียนลงดิสก์ และ shared blocks ที่อ่านจำนวนมาก

โค้ดที่สร้างมามักทำรูปแบบ N+1 ซึ่งดูปกติกับ seed data: ดึง 25 projects แล้วออก owner query หนึ่งครั้งและ count query หนึ่งครั้งต่อ project ที่สิบคำขอต่อวินาที endpoint เดียวนี้อาจสร้าง database statements มากกว่า 500 ครั้งต่อวินาที ก่อนเส้นทางอื่นเริ่มทำงาน วิธีแก้อาจเป็น join, WHERE id = ANY($1) แบบ batch หรือ count ที่คำนวณล่วงหน้า การเพิ่มขีดจำกัดพูลยังปล่อยความสูญเปล่าไว้เหมือนเดิม

เก็บ lock waits ด้วย คิวรีที่เร็วเมื่อรันเดี่ยวอาจหยุดรอเมื่อมีการอัปเดต tenant, account หรือ sequence เดียวกันพร้อมกัน เมื่อ latency กระโดดเฉพาะใน scenario ที่เขียน ให้ตรวจ active waits และขอบเขต transaction แทนการเพิ่ม index ตามความเคยชิน

หน่วยความจำต้องนิ่งภายใต้โหลดคงที่

วางแผนความจุก่อนสร้าง
ใช้โหมดวางแผนเพื่อกำหนดขีดจำกัดพูล ระยะหมดเวลา และพฤติกรรมความล้มเหลวที่ตรวจสอบได้ก่อนเปลี่ยนโค้ด

ตัดสินหน่วยความจำจากรูปทรงตามเวลา ไม่ใช่ peak เดียว process Go ที่เพิ่มระหว่างวอร์มอัปแล้วแกว่งรอบระดับคงที่ มีพฤติกรรมต่างจาก process ที่ baseline หลัง garbage collection เพิ่มขึ้นตลอด soak สองชั่วโมง

บันทึก process resident memory, Go heap allocation, heap objects, จำนวน goroutine, ความถี่ garbage collection, pause time และ allocation rate หน่วยความจำ container สำคัญ เพราะระบบปฏิบัติการฆ่า process ตามขีดจำกัดของมัน ไม่ใช่เฉพาะ Go heap เปรียบเทียบหน่วยความจำหลัง garbage collections ภายใต้โหลดใกล้เคียงกัน วิธีนี้ตัดรูปฟันเลื่อยตามปกติออกได้มากและทำให้เห็นการค้างอยู่

เปิด standard Go runtime metrics หรือ profiling endpoint ที่ป้องกันไว้ใน staging เก็บ heap profile ใกล้จุดเริ่มและจุดสิ้นสุดของ soak แล้วเปรียบเทียบจุดจัดสรรที่ยังค้างด้วย go tool pprof เก็บ goroutine profile ด้วย จำนวน goroutine ที่เพิ่มขึ้นอาจเผยคำขอที่ค้างบน channels response bodies ที่ไม่เคยปิด หรืองานเบื้องหลังที่เริ่มโดยไม่มี cancellation

อย่าตั้ง GOMEMLIMIT เท่ากับขีดจำกัด container พอดี process ยังต้องใช้หน่วยความจำสำหรับ goroutine stacks executable mappings database driver buffers และการจัดสรรนอก heap อื่น ๆ เหลือ headroom และพิสูจน์ภายใต้ payload ที่ใหญ่ที่สุด การทดสอบที่มี JSON bodies เล็กบอกได้น้อยมากเกี่ยวกับ endpoint ที่อ่านอัปโหลด 20 MB เข้า memory

บังคับกรณีที่ไม่สบายใจ: request bodies ขนาดสูงสุดที่รับได้ ผลคิวรีขนาดใหญ่ clients ที่ยกเลิก timeouts และ exports ซ้ำ ๆ ตรวจว่าหน่วยความจำคืนกลับหลังงานจบ ดู CPU ด้วย เพราะ garbage collection หนักอาจคุมหน่วยความจำให้อยู่ใต้ขีดจำกัด แต่ทำลาย latency

เงื่อนไขผ่านที่ใช้ได้จริงรวมเพดานและแนวโน้ม กำหนดให้ resident memory อยู่ใต้ขีดจำกัด deployment อย่างปลอดภัยที่ growth peak แล้วกำหนดให้ baseline หลัง collection และจำนวน goroutine หยุดเพิ่มระหว่าง soak ไม่มีเปอร์เซ็นต์ปลอดภัยสากล เลือก headroom ตามพฤติกรรมการรีสตาร์ตของแพลตฟอร์ม ความผันผวนของทราฟฟิก และว่า replica อื่นรับภาระจากการรีสตาร์ตได้หรือไม่

ความล้มเหลวต้องรวมคำตอบที่ผิดและพฤติกรรมเมื่อเกินโหลด

เปลี่ยนโค้ดพร้อมจุดตรวจ
สร้างสแนปช็อตก่อนแก้แรงกดดันของพูล การแตกคิวรีจำนวนมาก หรือหน่วยความจำตอบกลับที่ไม่จำกัด

นับ transport failures, HTTP status failures, timeouts, panics และ responses ที่ผิดแยกกัน http_req_failed จับคำขอ HTTP ที่ล้มเหลวตาม response callback ของ k6 แต่ response 200 ที่มีรายการว่าง การคิดเงินซ้ำ หรือระเบียนหายก็ยังล้มเหลว นั่นคือเหตุผลที่ตัวอย่างส่ง business_errors จาก content checks

tag การปฏิเสธที่คาดไว้ เช่น input ไม่ถูกต้องหรือ rate limit ที่ตั้งใจ เพื่อไม่ให้ปนกับอัตราข้อผิดพลาดที่ไม่คาดคิด แล้ว assert สัญญาของมัน: status ถูกต้อง body มีขนาดจำกัด และปฏิเสธเร็ว ระบบที่เกินโหลดไม่ควรใช้เวลา 30 วินาทีก่อนตอบ 503

ดู server logs เพื่อหา panic recovery, context deadline errors, ความล่าช้าในการได้คอนเน็กชัน, PostgreSQL serialization failures และคิวรีที่ถูกยกเลิก จัดกลุ่มข้อผิดพลาดตามสาเหตุที่คงที่ ไม่ใช่ข้อความเต็ม เพื่อไม่ให้ identifiers สร้างหลายพันหมวด เก็บ representative traces สำหรับการเกิดครั้งแรกและ tail ที่ latency สูง

ทำ degradation test หนึ่งครั้งหลัง capacity test ที่สะอาด ลดจำนวนคอนเน็กชันฐานข้อมูลที่พร้อมใช้ เพิ่ม latency ที่ควบคุมได้ให้ dependency ขาออกหนึ่งตัว หรือรีสตาร์ต application replica หนึ่งตัวขณะที่ทราฟฟิกยังมา ทำเฉพาะในสภาพแวดล้อมทดสอบที่แยกไว้ เป้าหมายคือยืนยันว่า timeouts, cancellation และ health checks จำกัดความล้มเหลว แทนที่จะปล่อยให้คิวกินทรัพยากรทั้งหมด

กำหนดค่า HTTP server อย่างชัดเจน เอกสาร net/http ของ Go ระบุว่า ReadTimeout, WriteTimeout และ IdleTimeout ที่เป็นศูนย์หรือติดลบอาจหมายถึงไม่มี timeout ขึ้นกับ field บริการที่สร้างมามักเรียก http.ListenAndServe ด้วยค่าเริ่มต้น และไม่เคยตัดสินใจเรื่องนี้ http.Server ที่กำหนดเอง deadlines ระดับคำขอ และขนาด body ที่จำกัด ป้องกัน clients ที่ช้าและ dependencies ที่ค้างไม่ให้ยึดทรัพยากรตลอดไป

ทบทวนความถูกต้องหลังรัน นับระเบียนที่สร้าง ตรวจ idempotency ตรงที่ clients retry ตรวจว่างานเบื้องหลังเสร็จครั้งเดียว และยืนยันว่าไม่มี state บางส่วนคงอยู่หลังคำขอที่ล้มเหลว load tests ในโครงการของผมพบ bug งานซ้ำมากกว่าการ review โค้ดที่ฉลาดล้ำเคยพบ

การตัดสินใจจ้างมาจากข้อจำกัดแรก

คุณเปิดตัวได้โดยไม่มี backend engineer เมื่อระบบผ่าน expected และ growth tests ซ้ำ ๆ soak ทำให้ memory baseline คงที่ database queues อยู่ในการควบคุม และทีมอธิบายความล้มเหลวแรกของ stress test ได้ การรันสีเขียวครั้งเดียวที่โชคดีไม่ใช่หลักฐาน รัน commit เดียวกันอย่างน้อยสามครั้งและตรวจความแปรปรวนที่มาก

เก็บบันทึกผลลัพธ์กระชับสำหรับแต่ละรัน:

  • Commit และ environment รวมถึงขนาด replica และการตั้งค่าฐานข้อมูล
  • ขนาด dataset สัดส่วนทราฟฟิก arrival rates และระยะเวลาทดสอบ
  • Route p95 และ p99, throughput ที่ทำได้, business failures และ dropped iterations
  • CPU และ memory สูงสุด แนวโน้ม memory หลัง collection และแนวโน้ม goroutine
  • Pool waits, SQL อันดับต้นตามเวลารวม, lock waits และจุดที่พังที่สังเกตได้

จ้างหรือว่าจ้างผู้ช่วยด้านแบ็กเอนด์ก่อนเปิดตัว หากไม่มีใครอธิบาย pool waits ที่เพิ่มขึ้น หน่วยความจำที่ค้าง lock contention หรือการเขียนที่ไม่สม่ำเสมอได้ จ้างเมื่อคนเดียวที่รัน test ได้ไม่สามารถเปลี่ยนโค้ดที่สร้างไว้ได้อย่างปลอดภัย นี่คือช่องว่างด้านความเป็นเจ้าของ ไม่ใช่เกณฑ์คำขอต่อวินาที

ผลทดสอบที่ไม่ผ่านไม่ได้หมายความว่าต้องจ้างเต็มเวลาเสมอไป index ที่ขาด คิวรี N+1 หรือ export ที่ไม่จำกัด อาจเป็นการซ่อมแซมเฉพาะจุด ความล้มเหลวซ้ำซ้อนในเรื่องการออกแบบ transaction การสังเกตการณ์ cancellation และพฤติกรรมการ deploy ชี้ไปสู่งานวิศวกรรมต่อเนื่อง ความแตกต่างอยู่ที่คุณพบข้อบกพร่องเดียว หรือค้นพบว่าไม่มีใครเป็นเจ้าของพฤติกรรมของระบบ

Koder.ai สามารถสร้างและส่งออกแบ็กเอนด์ Go นำไปปรับใช้ และเก็บสแนปช็อตไว้เพื่อย้อนกลับได้ แต่การสร้างโค้ดไม่ได้ลบล้างความจำเป็นของการวางแผนความจุ เก็บ load script และการเปลี่ยนแปลงด้าน observability ไว้กับซอร์ส เพื่อให้ทุกการเปลี่ยนแปลงแบ็กเอนด์ที่มีนัยสำคัญต้องผ่านสัญญาเดียวกัน

อย่าสัญญากับธุรกิจว่าผู้ใช้รายเดือน 10,000 คนปลอดภัย สัญญาเป็น arrival rate ที่วัดได้ สัดส่วนเส้นทาง เป้าหมาย latency error budget และกรอบทรัพยากร เมื่อผลิตภัณฑ์เปลี่ยน ให้เปลี่ยน inputs เหล่านี้และรัน test อีกครั้ง

คำถามที่พบบ่อย

แบ็กเอนด์ Go รองรับผู้ใช้รายเดือนที่ใช้งาน 10,000 คนได้หรือไม่?

บ่อยครั้งทำได้ แต่จำนวนผู้ใช้รายเดือนที่ใช้งานไม่ได้บอกภาระของแบ็กเอนด์ แปลงการคาดการณ์เป็นจำนวนคำขอสูงสุดต่อวินาทีและสัดส่วนเส้นทาง แล้วทดสอบภาระนั้นกับขีดจำกัดด้าน latency, ข้อผิดพลาด, ฐานข้อมูล และหน่วยความจำที่กำหนดไว้อย่างชัดเจน

ผู้ใช้รายเดือน 10,000 คนเท่ากับกี่คำขอต่อวินาที?

ไม่มีสูตรแปลงตายตัว คุณต้องรู้จำนวนเซสชันต่อผู้ใช้ จำนวนคำขอต่อเซสชัน สัดส่วนทราฟฟิกในช่วงที่คึกคักที่สุด และเหตุการณ์ที่ทำให้ผู้ใช้เข้ามาพร้อมกัน

ค่า p95 latency ที่ยอมรับได้สำหรับ Go API คือเท่าไร?

กำหนดเป้าหมายตามการกระทำของผู้ใช้ ไม่ใช่ตามภาษาหรือเฟรมเวิร์ก การอ่านข้อมูลแบบโต้ตอบอาจต้องใช้เวลาเพียงไม่กี่ร้อยมิลลิวินาที ขณะที่งานเบื้องหลังที่รับคำขอแล้วอาจมีเป้าหมายต่างกัน แต่ทีมต้องเลือกตัวเลขก่อนเห็นผลทดสอบ

การทดสอบโหลดแบ็กเอนด์ควรนานแค่ไหน?

คงระดับโหลดคงที่แต่ละระดับอย่างน้อย 30 นาทีหลังวอร์มอัป แล้วทำ soak test ที่นานขึ้นในช่วงโหลดสูงสุดที่คาดไว้ การทดสอบสั้นอาจพลาดการหมุนเวียนคอนเน็กชัน หน่วยความจำที่ค้างอยู่ งานเบื้องหลัง และคิวที่ค่อย ๆ เพิ่มขึ้น

ควรใช้ virtual users หรือ arrival rate ในการทดสอบโหลด?

ใช้โมเดลอัตราการมาถึงสำหรับการยืนยันความจุ เพราะยังส่งงานเข้ามาเมื่อบริการช้าลง โมเดล virtual user จำนวนคงที่อาจลดอัตราคำขอระหว่างที่ระบบช้า และซ่อนจุดที่คิวเริ่มโต

จะตรวจพบว่าพูลคอนเน็กชันฐานข้อมูลของ Go หมดได้อย่างไร?

ส่งออก DB.Stats() แล้วดู InUse, OpenConnections, WaitCount และ WaitDuration หากตัวนับการรอเพิ่มขึ้นขณะที่คอนเน็กชันที่ใช้งานคงอยู่ที่ค่าสูงสุดที่กำหนด แสดงว่าคำขอกำลังต่อคิวรอพูล

ควรเพิ่มพูล SQL connection ของ Go เมื่อคำขอต้องรอหรือไม่?

อย่าเพิ่มจนกว่าจะรู้ว่าทำไมคอนเน็กชันจึงถูกใช้นาน และ PostgreSQL มีความจุเท่าไร พูลที่ใหญ่ขึ้นอาจย้ายคิวไปไว้ในฐานข้อมูลและทำให้การแย่งทรัพยากรแย่ลง

จะหาคิวรี PostgreSQL ที่ช้าระหว่าง load test ได้อย่างไร?

เก็บสแนปช็อตก่อนและหลังของ pg_stat_statements แล้วจัดอันดับผลต่างของคิวรีตามเวลาประมวลผลรวมและเฉลี่ย ใช้ trace หรือ histogram ที่มีพารามิเตอร์แทนสำหรับ tail latency เพราะมุมมองแบบรวมไม่มีค่า p95 รายคิวรี

จะรู้ได้อย่างไรว่า Go service มี memory leak?

ทำ soak test ที่มีโหลดคงที่ แล้วเปรียบเทียบหน่วยความจำหลัง garbage collection ภายใต้โหลดใกล้เคียงกัน ค่า baseline ที่เพิ่มขึ้นต่อเนื่อง โดยเฉพาะเมื่อ heap objects หรือ goroutines เพิ่มขึ้น ควรตรวจด้วยการเปรียบเทียบ heap และ goroutine profile

สตาร์ตอัปควรจ้าง backend engineer เมื่อไร?

จ้างเมื่อปัญหาประสิทธิภาพเผยให้เห็นความจำเป็นต้องมีผู้รับผิดชอบต่อเนื่องด้านการออกแบบฐานข้อมูล การสังเกตการณ์ การทำงานพร้อมกัน ความถูกต้อง หรือการปฏิบัติการ การแก้ index หรือคิวรีเพียงจุดเดียวอาจไม่ต้องมีตำแหน่งเต็มเวลา แต่พฤติกรรมภายใต้โหลดที่อธิบายไม่ได้ควรมีผู้รับผิดชอบ

Related posts