NetKubeLab EN

HTTP — โพรโทคอลที่สัญญาไว้น้อยมาก

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

บทความ DNS จบลงที่การแปลงชื่อเป็นเลขที่อยู่ และบทความ TCP กับ UDP จบลงที่สายข้อมูลที่เชื่อถือได้ระหว่างสอง โปรแกรม

HTTP คือสิ่งที่วิ่งอยู่บนสายนั้น และเป็นชั้นที่คนเขียนโปรแกรมสัมผัสโดยตรงมากที่สุด

และมันสัญญาไว้น้อยกว่าที่คนส่วนใหญ่คิดมาก RFC 1945 ปี 1996 บรรยายตัวเองว่า "a generic, stateless, object-oriented protocol" คำที่สำคัญที่สุดคือคำกลาง มันไม่จำอะไรเลย และเกือบทุกอย่างที่สร้างขึ้นมาสามสิบปีหลังจากนั้นคือการแก้ผลของ คำนั้น

HTTP สัญญาไว้น้อยกว่าที่คนคิด

RFC 9110 ซึ่งเป็นฉบับปัจจุบันเขียนไว้ชัดกว่านั้นอีก — "HTTP is defined as a stateless protocol, meaning that each request message's semantics can be understood in isolation"

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

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

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

ถ้ายังไม่เคยดู เริ่มตรงนี้

คำขอ HTTP หนึ่งใบดูได้ทั้งใบด้วยคำสั่งเดียว และคำตอบที่ได้กลับมาก็อ่านออกทั้งหมด

$ curl -sI --http1.1 https://netkubelab.com/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: public, max-age=0, must-revalidate

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

เก็บสามบรรทัดนี้ไว้ในใจก่อน Transfer-Encoding Connection และ Cache-Control ทั้งสามจะกลับมาเป็นหัวข้อของตัวเองข้างล่าง

คำขอกับคำตอบ หน้าตาเป็นอย่างไร

โครงของคำขอและคำตอบ โดยมีบรรทัดว่างคั่นระหว่างส่วนหัวกับเนื้อหา

RFC 9112 ให้ไวยากรณ์ของบรรทัดแรกไว้สั้นมาก

  request-line = method SP request-target SP HTTP-version
  status-line  = HTTP-version SP status-code SP [ reason-phrase ]

SP คือช่องว่างหนึ่งตัว ทั้งหมดมีแค่นี้ ไม่มีความยาว ไม่มีตัวนับ ไม่มีเลขระบุ เวอร์ชันแบบไบนารี

บรรทัดว่างคือเส้นแบ่ง

หลังบรรทัดแรกคือส่วนหัว บรรทัดละหนึ่งฟิลด์ แล้วส่วนหัวจบด้วยบรรทัดว่างหนึ่ง บรรทัด ไม่มีอย่างอื่นมาบอกเลยว่าจบ

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

header เป็นแค่คู่ชื่อกับค่า

ไม่มีชนิดข้อมูล ไม่มีโครงสร้าง มีแค่ชื่อ ทวิภาค แล้วค่า และ RFC 9110 ระบุว่า "Field names are case-insensitive"

ข้อนี้ฟังดูไม่สำคัญจนกว่าจะเห็นของจริงสองแบบข้างกัน คำตอบ HTTP/1.1 ข้างบนใช้ Content-Type ตัวใหญ่ ส่วนคำตอบ HTTP/2 ข้างล่างใช้ content-type ตัวเล็กทั้งหมด

เหตุผลไม่ใช่รสนิยม RFC 9113 บังคับไว้ว่า "A field name MUST NOT contain uppercase characters" เพราะเมื่อชื่อไม่มีความหมายเชิงตัวพิมพ์อยู่แล้ว การบังคับ ให้เหลือรูปเดียวทำให้บีบอัดได้ดีขึ้นและไม่ต้องแปลงก่อนเทียบ

เมธอด และคุณสมบัติสามอย่างที่ไม่เหมือนกัน

ตารางเมธอดกับคุณสมบัติ safe idempotent และ cacheable ซึ่งแต่ละเมธอดมีไม่เหมือนกัน

คนมักจำว่า GET คืออ่าน POST คือเขียน ซึ่งหยาบเกินไป สิ่งที่ RFC นิยามไว้จริงคือ คุณสมบัติสามอย่างที่แยกกันชัดเจน

safe — RFC 9110 นิยามว่า "its defined semantics are essentially read-only; that is, the client does not request, and does not expect, any state change on the origin server"

idempotent"the intended effect of a request ... is the same whether the request is sent once or multiple times with identical request fields"

cacheable — คำตอบเก็บไว้ใช้ซ้ำได้

  method   safe  idempotent  cacheable
  GET       y       y            y
  HEAD      y       y            y
  POST      -       -            *
  PUT       -       y            -
  DELETE    -       y            -
  OPTIONS   y       y            -

สังเกตว่า PUT กับ DELETE ไม่ safe แต่ idempotent ซึ่งเป็นช่องที่คนมองข้าม บ่อยที่สุด ลบของชิ้นเดิมสิบครั้งได้ผลเท่ากับลบครั้งเดียว ถึงจะเปลี่ยนสถานะก็ตาม

และ * ของ POST หมายถึงแคชได้ก็ต่อเมื่อประกาศไว้ชัดเจน ไม่ใช่โดยปริยาย

ทำไม idempotent ถึงสำคัญกับการส่งซ้ำ

RFC 9110 เขียนผลของมันไว้ตรง ๆ ว่า "automatic retry logic for requests that use non-idempotent methods is not recommended, since the intended effect might have already been applied on the origin server, even if the response has not been received by the client"

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

ถ้าเมธอดเป็น idempotent การเดาผิดไม่มีราคา ส่งซ้ำก็ได้ผลเดิม ถ้าไม่ใช่ การเดาผิด แปลว่ามีรายการซ้ำเกิดขึ้น

รหัสสถานะ

รหัสสถานะห้าชั้น ตั้งแต่ 1xx ถึง 5xx พร้อมตัวอย่างของแต่ละชั้น

หลักแรกคือทั้งหมดที่ต้องจำ RFC 9110 แบ่งไว้ห้าชั้น

  1xx  informational   ยังไม่จบ รอต่อ
  2xx  successful      สำเร็จ
  3xx  redirection     ต้องทำอะไรต่ออีกอย่าง
  4xx  client error    คำขอมีปัญหา
  5xx  server error    เซิร์ฟเวอร์มีปัญหา

เส้นแบ่งระหว่าง 4xx กับ 5xx คือความผิดอยู่ที่ใคร ไม่ใช่ความรุนแรง ส่ง JSON ผิดรูปแบบไปได้ 400 ซึ่งเป็นความผิดของผู้ส่ง ส่วนฐานข้อมูลล่มได้ 500 ซึ่งไม่ใช่

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

เนื้อหาจบตรงไหน

สามวิธีที่เนื้อหาจบ คือนับตาม Content-Length อ่านก้อนจนเจอก้อนขนาดศูนย์ หรืออ่านจนสายปิด

คำถามนี้ฟังดูง่ายจนกว่าจะรู้ว่า RFC 9112 ต้องใช้กฎแปดข้อเรียงลำดับความสำคัญเพื่อ ตอบมัน ย่อได้เป็นสี่กรณีหลัก

  1  Transfer-Encoding present   chunked, ends at the zero-size chunk
  2  Content-Length present      count that many octets
  3  request with neither        the body length is zero
  4  response with neither       read until the connection closes

Content-Length

ตรงไปตรงมาที่สุด บอกจำนวนไบต์ไว้ล่วงหน้า ผู้รับนับให้ครบแล้วหยุด

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

chunked

ทางออกคือแบ่งเป็นก้อน แต่ละก้อนบอกขนาดของตัวเองไว้ข้างหน้าเป็นเลขฐานสิบหก แล้วจบทั้งหมดด้วยก้อนขนาดศูนย์

ผู้ส่งจึงเริ่มส่งได้ทันทีโดยไม่ต้องรู้ขนาดรวม ซึ่งเป็นเหตุผลที่คำตอบจากเว็บนี้เอง ที่เห็นข้างบนใช้ Transfer-Encoding: chunked — หน้า HTML ถูกส่งออกไปตั้งแต่ยัง สร้างไม่เสร็จ

และกลไกนี้หายไปทั้งหมดใน HTTP/2 RFC 9113 ระบุว่า Transfer-Encoding เป็น หนึ่งในฟิลด์ที่ "MUST NOT be used in HTTP/2 connections" เพราะการแบ่งเป็น ก้อนกลายเป็นงานของชั้นเฟรมแทน

เมื่อสองฝั่งอ่านข้อความเดียวกันไม่ตรงกัน

คำขอที่มีทั้ง Content-Length และ Transfer-Encoding ทำให้ฝั่งหน้ากับฝั่งหลังตีความคนละแบบ จนส่วนที่เหลือไปต่อหน้าคำขอของคนถัดไป

กฎสี่ข้อข้างบนมีลำดับความสำคัญด้วยเหตุผล ถ้าข้อความหนึ่งมีทั้งสองฟิลด์ RFC 9112 บอกว่า "the Transfer-Encoding overrides the Content-Length"

ปัญหาคือคำว่า ถ้า นั้นเกิดขึ้นได้จริง และไม่ใช่ทุกคนที่ทำตามกฎเดียวกัน

ในระบบจริงคำขอหนึ่งใบผ่านหลายมือ พร็อกซี โหลดบาลานเซอร์ ตัวกรอง แล้วจึงถึง เซิร์ฟเวอร์จริง ถ้าตัวหนึ่งในแถวนั้นอ่าน Content-Length ขณะที่อีกตัวอ่าน Transfer-Encoding ทั้งสองจะเห็นข้อความคนละยาว

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

ผลคือผู้โจมตีเขียนบางส่วนของคำขอที่คนอื่นจะส่งได้ โดยไม่ต้องแตะเครื่องของเขาเลย เรียกว่า request smuggling

RFC 9112 อ้างถึงมันตรง ๆ ว่าข้อความแบบนี้ "might indicate an attempt to perform request smuggling ... and ought to be handled as an error" และกำหนดทาง ปฏิบัติไว้ว่าถ้าจะตอบก็ได้ แต่ "the server MUST close the connection after responding to such a request"

คำสั่งให้ปิดสายคือหัวใจของทางแก้ เพราะเมื่อไม่มีสายให้ใช้ร่วมกันต่อ ก็ไม่มี คำขอของคนถัดไปให้ไปต่อหน้า

หนึ่งการเชื่อมต่อ ทำได้ทีละอย่าง

เทียบเวลาที่ใช้ทำงานสามคำขอเดียวกัน ระหว่าง HTTP/1.0 ที่ต่อใหม่ทุกครั้ง HTTP/1.1 ที่ใช้สายเดียวแต่ทีละอัน และ HTTP/2 ที่ทำพร้อมกัน

ปัญหาเดียวที่ขับเคลื่อนการเปลี่ยนเวอร์ชันของ HTTP ทั้งหมดคือประโยคในหัวข้อนี้

HTTP/1.0 — ต่อใหม่ทุกครั้ง

RFC 1945 เขียนพฤติกรรมยุคนั้นไว้ว่า "current practice requires that the connection be established by the client prior to each request and closed by the server after sending the response"

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

HTTP/1.1 — สายเดิม แต่ยังทีละอัน

RFC 9112 บอกว่า "HTTP/1.1 defaults to the use of 'persistent connections'" คือใช้สายเดิมส่งคำขอถัดไปได้เลย

และมี pipelining ให้ส่งคำขอหลายใบโดยไม่ต้องรอคำตอบ แต่ RFC เขียนเงื่อนไขที่ทำลาย ประโยชน์ของมันไว้ในประโยคเดียวกัน — เซิร์ฟเวอร์ "MUST send the corresponding responses in the same order that the requests were received"

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

HTTP/2 — เลิกเป็นข้อความ

RFC 9113 สรุปปัญหาไว้ให้เองว่า "HTTP/1.0 allowed only one request to be outstanding at a time on a given TCP connection. HTTP/1.1 added request pipelining, but this only partially addressed request concurrency and still suffers from application-layer head-of-line blocking"

ทางแก้คือเปลี่ยนจากข้อความเป็นไบนารี และแบ่งเป็นเฟรม

  bits  field
   24   length
    8   type
    8   flags
    1   reserved
   31   stream identifier
        ---
   72   bits = 9 bytes

ฟิลด์ที่สำคัญที่สุดคือ stream identifier เพราะมันทำให้เฟรมของงานคนละชิ้น สลับกันวิ่งบนสายเดียวได้ RFC นิยาม stream ว่า "an independent, bidirectional sequence of frames" และคำว่า independent คือทั้งหมดของเรื่องนี้

ราคาที่จ่ายคือสิ่งที่ทำให้ HTTP ชนะตั้งแต่แรกหายไป มันอ่านด้วยตาเปล่าไม่ได้อีก แล้ว ต้องมีเครื่องมือถอดเฟรมเสมอ

คิวเกิดตรงไหน

หัวคิวติดสามแบบ ตั้งแต่ติดที่ชั้นแอปพลิเคชันใน HTTP/1.1 ติดที่ชั้นขนส่งใน HTTP/2 และแก้ได้ใน HTTP/3

เรื่องนี้ต่อจากบทความ TCP กับ UDP โดยตรง และเป็นตัวอย่าง ที่ดีของการแก้ปัญหาที่ชั้นผิด

HTTP/1.1 ติดที่ชั้นแอปพลิเคชัน เพราะ RFC บังคับให้ตอบตามลำดับ คำตอบที่ช้า ปิดกั้นคำตอบที่พร้อมแล้ว

HTTP/2 แก้ข้อนั้นได้ แล้วเจอปัญหาเดิมที่ชั้นล่าง สตรีมสลับกันวิ่งได้จริง แต่ทั้งหมดยังอยู่บนสาย TCP เส้นเดียว และ TCP ไม่รู้ว่าข้างในแบ่งเป็นสตรีม พอแพ็กเก็ตหายหนึ่งใบ TCP หยุดส่งต่อทุกอย่างที่มาหลังจากนั้น

RFC 9114 อธิบายผลนี้ไว้ตรง ๆ ว่า "a lost or reordered packet causes all active transactions to experience a stall regardless of whether that transaction was directly impacted by the lost packet"

HTTP/3 แก้ด้วยการเลิกใช้ TCP ย้ายไปอยู่บน QUIC ซึ่งวิ่งบน UDP และจัดการ ความน่าเชื่อถือแยกรายสตรีม สตรีมที่ไม่เกี่ยวกับแพ็กเก็ตที่หายจึงเดินต่อได้

บทเรียนที่ใช้ได้กว้างคือ การแก้ปัญหาที่ชั้นบนโดยที่ชั้นล่างยังคิดแบบเดิม ได้ผลแค่ครึ่งเดียวเสมอ

แคช

วงจรของแคชสามสถานะ พร้อมจำนวนไบต์ที่โอนจริงในแต่ละกรณี

แคชคือกลไกที่ให้ผลมากที่สุดต่อความพยายามที่ลงไป และเป็นกลไกที่ตั้งค่าผิดกันมาก ที่สุดด้วย

วัดกับเว็บนี้เองได้ ไฟล์ CSS ของเว็บประกาศ cache-control มาแบบนี้

$ curl -sI --http2 https://netkubelab.com/assets/www.css
HTTP/2 200
content-type: text/css; charset=utf-8
cache-control: public, max-age=14400, must-revalidate
etag: W/"4888fd76a5dedc18b63d5a9b24a08797"

สดกับหมดอายุ

RFC 9111 นิยามไว้ว่าคำตอบที่ fresh คือคำตอบที่อายุยังไม่เกินอายุความสด ส่วน stale คือเกินแล้ว

max-age=14400 คือสี่ชั่วโมง ในช่วงนั้นเบราว์เซอร์หยิบจากแคชได้เลย ไม่ต้องออกจากเครื่องแม้แต่รอบเดียว

พอเกินสี่ชั่วโมง มันไม่ได้แปลว่าต้องโหลดใหม่ แปลว่าต้องไปถามก่อน และเครื่องมือ ที่ใช้ถามคือ etag

$ ET='W/"4888fd76a5dedc18b63d5a9b24a08797"'
$ curl -sI -H "If-None-Match: $ET" \
       https://netkubelab.com/assets/www.css
HTTP/2 304

304 แปลว่าของเดิมยังใช้ได้ และผลของมันวัดเป็นไบต์ได้

  without etag   14380 bytes
  with etag          0 bytes

หนึ่งหมื่นสี่พันไบต์เหลือศูนย์ โดยยังได้ความมั่นใจว่าไฟล์ไม่เปลี่ยน

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

no-cache กับ no-store

สองคำนี้คนสลับกันบ่อยที่สุดในทั้งโพรโทคอล และ RFC 9111 นิยามไว้ต่างกันมาก

  max-age=N        สดได้ N วินาที
  no-cache         เก็บได้ แต่ต้องถามก่อนใช้ทุกครั้ง
  no-store         ห้ามเก็บเลยตั้งแต่แรก
  must-revalidate  พอหมดอายุแล้วห้ามใช้ต่อจนกว่าจะถามสำเร็จ
  private          แคชร่วมห้ามเก็บ เก็บได้เฉพาะของผู้ใช้คนเดียว
  public           เก็บได้ แม้ปกติจะไม่ให้เก็บ

no-cache ไม่ได้แปลว่าห้ามแคช RFC เขียนว่ามัน "MUST NOT be used to satisfy any other request without forwarding it for validation" คือเก็บไว้ได้ แต่ต้องถามก่อนใช้ทุกครั้ง ซึ่งยังประหยัดแบนด์วิดท์มากถ้าคำตอบเป็น 304

no-store ต่างหากที่แปลว่าห้ามแคช RFC เขียนว่า "a cache MUST NOT store any part of either the immediate request or the response" และนี่คือค่าที่ควร ใช้กับหน้าที่มีข้อมูลส่วนตัว ไม่ใช่ no-cache

เมื่อมันโกหก

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

"200 แปลว่าสำเร็จ" แปลว่าคำขอ HTTP สำเร็จเท่านั้น เนื้อหาข้างในอาจเป็นข้อความ แจ้งข้อผิดพลาดก็ได้ API จำนวนมากตอบ 200 พร้อมเนื้อหาที่บอกว่าล้มเหลว ซึ่งทำให้ ทุกอย่างที่มองเฉพาะรหัสสถานะมองไม่เห็นปัญหา

"HTTPS แปลว่าปลอดภัย" แปลว่าเส้นทางระหว่างเบราว์เซอร์กับเซิร์ฟเวอร์ถูกเข้ารหัส และยืนยันตัวตนของเซิร์ฟเวอร์แล้ว ไม่ได้บอกอะไรเลยเกี่ยวกับสิ่งที่เซิร์ฟเวอร์นั้น ทำกับข้อมูลของคุณ

"HTTP/2 เร็วกว่าเสมอ" ไม่ใช่ บนเครือข่ายที่แพ็กเก็ตหายบ่อย HTTP/2 อาจช้ากว่า HTTP/1.1 ที่เปิดหลายสาย เพราะสายเดียวที่ติดขัดหยุดทุกอย่าง ส่วนหลายสายเสียแค่ สายเดียว

"ตั้ง no-cache แล้วปลอดภัย" ไม่ ข้อมูลยังถูกเก็บลงดิสก์ ค่าที่ต้องใช้คือ no-store

ตัวอย่างจริงจากงานจริง

กรณีที่ 1 — โหลดไฟล์เดิมซ้ำทุกครั้งทั้งที่ไม่เคยเปลี่ยน

หน้าเว็บโหลดช้าทุกครั้งที่เข้า ทั้งที่ไฟล์รูปกับ CSS ไม่ได้แก้มาหลายเดือน

อ่านอย่างไร ดูส่วนหัวของคำตอบว่ามีอะไรให้แคชทำงานได้บ้าง

$ curl -sI https://example.com/assets/app.css \
       | grep -iE 'cache|etag|last'

ถ้าไม่มีบรรทัดไหนกลับมาเลย นั่นคือคำตอบ ไม่มี Cache-Control ไม่มี ETag ไม่มี Last-Modified แคชจึงไม่มีเกณฑ์อะไรให้ตัดสินใจ และค่าเริ่มต้นที่ ปลอดภัยที่สุดคือโหลดใหม่ทุกครั้ง

การเพิ่ม ETag อย่างเดียวก็เปลี่ยนจากโหลดเต็มเป็น 304 ได้แล้ว การเพิ่ม max-age ต่อไปอีกจะตัดแม้แต่รอบไปถาม

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

กรณีที่ 2 — คำตอบขาดกลางคัน หรือค้างไปเลย

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

อ่านอย่างไร เทียบค่าที่ประกาศไว้กับจำนวนไบต์ที่ได้จริง

$ curl -sS -o /dev/null -D - -w 'downloaded %{size_download}\n' \
       https://example.com/api/report
Content-Length: 52418
downloaded 52418

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

สาเหตุที่พบบ่อยคือมีอะไรแก้เนื้อหาระหว่างทาง เช่นตัวบีบอัดหรือตัวแทรกสคริปต์ แล้วไม่ได้แก้ Content-Length ตามไปด้วย

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

กรณีที่ 3 — กดปุ่มครั้งเดียว ได้รายการซ้ำสองรายการ

ผู้ใช้รายงานว่าสั่งซื้อครั้งเดียวแต่ได้ใบสั่งซื้อสองใบ และเกิดเฉพาะตอนเน็ตช้า

อ่านอย่างไร นี่คือกรณีที่ RFC เตือนไว้ตรง ๆ ในหัวข้อ idempotent

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

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

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

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

กรณีที่ 4 — HTTP/2 เปิดอยู่จริงหรือเปล่า

ตั้งค่าเปิด HTTP/2 ไว้แล้ว แต่ไม่แน่ใจว่าใช้งานอยู่จริง

อ่านอย่างไร บังคับแต่ละเวอร์ชันแล้วดูสิ่งที่ต่างกัน

$ curl -sI --http1.1 https://netkubelab.com/ | head -4
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
$ curl -sI --http2 https://netkubelab.com/assets/www.css | head -3
HTTP/2 200
content-type: text/css; charset=utf-8

สังเกตสองอย่างที่ต่างกัน บรรทัดแรกไม่มีคำว่า OK ต่อท้ายในเวอร์ชันสอง เพราะ ข้อความประกอบรหัสถูกลบทิ้งไปแล้ว และชื่อฟิลด์เป็นตัวพิมพ์เล็กทั้งหมด ตามที่ RFC 9113 บังคับ

อย่างที่สามที่บอกได้แน่นอนคือ Transfer-Encoding ที่หายไป เพราะเป็นฟิลด์ที่ HTTP/2 ห้ามใช้

สิ่งที่ยังไม่ได้พิสูจน์ curl บอกว่าตกลงกันได้ที่ HTTP/2 ไม่ได้แปลว่า เบราว์เซอร์ของผู้ใช้จริงจะได้เหมือนกัน เพราะการตกลงเวอร์ชันเกิดตอนจับมือ TLS ซึ่งขึ้นกับความสามารถของทั้งสองฝั่ง

อ้างอิง

มาตรฐานปัจจุบัน

  • RFC 9110 — HTTP Semantics มิถุนายน 2022 Standards Track แทนที่เอกสารเก่าเก้าฉบับ ที่มาของนิยาม stateless คุณสมบัติ safe idempotent cacheable และรหัสสถานะห้าชั้น
  • RFC 9112 — HTTP/1.1 มิถุนายน 2022 ไวยากรณ์ของบรรทัดแรก กฎแปดข้อของการหาจุดจบเนื้อหา และคำเตือน เรื่อง request smuggling
  • RFC 9111 — HTTP Caching มิถุนายน 2022 นิยาม fresh กับ stale และความหมายที่แท้จริงของ no-cache กับ no-store
  • RFC 9113 — HTTP/2 มิถุนายน 2022 โครงเฟรมเก้าไบต์ นิยามของ stream ข้อบังคับเรื่องตัวพิมพ์เล็ก และรายการฟิลด์ที่ห้ามใช้
  • RFC 9114 — HTTP/3 มิถุนายน 2022 คำอธิบายผลของ head-of-line blocking ที่ชัดที่สุด

ต้นกำเนิด

  • RFC 1945 — Hypertext Transfer Protocol HTTP/1.0 พฤษภาคม 1996 Berners-Lee, Fielding และ Frystyk ที่มาของคำว่า generic stateless object-oriented และพฤติกรรมหนึ่งคำขอหนึ่งการเชื่อมต่อ

ของจริงบนเครื่อง

  • curl -sI --http1.1 และ curl -sI --http2 ต่อเว็บนี้เอง ซึ่งแสดงความต่าง ของตัวพิมพ์ในชื่อฟิลด์ และ Transfer-Encoding ที่หายไปในเวอร์ชันสอง
  • curl -H 'If-None-Match: ...' ที่ให้ 304 กลับมา และตัวเลขที่โอนจริงลดจาก 14,380 ไบต์เหลือศูนย์

อ่านต่อในชุดนี้

  • TCP กับ UDP — สายที่ HTTP วิ่งอยู่บน และที่มาของ head-of-line blocking ที่ HTTP/2 แก้ไม่ได้
  • DNS — ขั้นตอนก่อนหน้าที่แปลงชื่อเป็นเลขที่อยู่
  • ping — เครื่องมือที่ตอบคำถามคนละข้อกับที่ HTTP ตอบ

อ่านหน้านี้เป็นภาษาอังกฤษ

← กลับไปหน้าความรู้พื้นฐาน