Photo by Denys Nevozhai, from Unsplash.com

ใครที่เคยใช้ Load Balancer (LB) คงจะเคยได้ยินคำว่า Sticky Session (หรือ Session Affinity) กันมาบ้าง

แนวคิดของ Sticky Session คือการให้ LB ส่ง Request ที่มาจากผู้ใช้ (Client) คนเดียวยัง ไปยังเซอร์เวอร์ตัวเดียวกันตลอด

ตัวอย่างเช่น เรามีเซอร์เวอร์ 3 ตัว ชื่อว่า S1, S2, และ S3 วางอยู่ข้างหลัง LB แล้วมีผู้ใช้ส่ง Request เข้ามายังเว็บเรา

Load Balancer with 3 servers
Load Balancer with 3 servers

ในครั้งแรก Request ถูกส่งไปให้ S1 แต่พอผู้ใช้ส่ง Request ถัดๆไป LB อาจส่ง Request นั้นไปยัง S2 หรือ S3 แทน

แต่หากเราเปิด Sticky Session แล้วล่ะก็ ตัว LB จะส่ง Request จากผู้ใช้คนเดิมไปยัง S1 ตลอด โดยไม่ส่งไปที่ S2 หรือ S3 เลย

ในบทความนี้ เราจะมาทำความเข้าใจว่า Sticky Session ให้ละเอียดยิ่งขึ้น และอธิบายถึงปัญหาที่มักจะเกิดขึ้นในการใช้ Sticky Session

Sticky Session ทำงานยังไง

สมมติว่าเราเป็น LB เราจะรู้ได้อย่างไรว่า Request นี้มาจากผู้ใช้คนเดิมรึเปล่า?

วิธีการที่ LB ส่วนใหญ่ใช้ คือทำการใส่ค่า Session Id ลงไปใน Cookie ของ Response และจำค่าเอาไว้ว่า Session Id นี้ถูกส่งไปที่เซอร์เวอร์ไหน (หรือให้โปรแกรมเราเป็นคนใส่เองก็ได้ แต่ต้องกำหนดให้ตรงกันว่า Cookie ชื่ออะไร)

ส่วนฝั่งผู้ใช้ พอได้รับ Cookie มา ก็จะ Session Id นั้นๆเอาไว้ และใส่ใน Header ของ Request ถัดๆไป

ตัวอย่างเช่น LB เคยได้ Request มาจากผู้ใช้ 3 คน คนละ 1 Request ตัว LB ก็จะใส่จำค่า Session Id ไว้ตามตารางข้างล่าง

Session Id Server
10000000 S1
10000001 S2
10000002 S3

พอ LB ได้รับ Request ถัดมา ก็สามารถเช็คจาก Header ได้ว่าคราวที่แล้วเราส่ง Request ที่มี Session Id นี้ไปยังเซอร์เวอร์ไหน

สมมติว่า Session Id เป็น 10000001 ก็จะส่งไปให้ S2

แต่ถ้าหาก Request ถัดมายังไม่มี Session Id ใน Cookie ทางฝั่ง LB ก็จะจำค่าใหม่ (100003) ไว้ดังตารางข้างล่าง แล้วส่งไปยังเซอร์เวอร์ตัวไหนก็ได้

Session Id Server
10000000 S1
10000001 S2
10000002 S3
10000003 S1

วิธีนี้การันตีได้ว่า LB จะส่ง Request จากผู้ใช้เดิมไปยังเซอร์เวอร์ตัวเดียวตลอด หากไม่เกิดเหตุสุดวิสัยขึ้น

Note LB บางตัวสามารถทำงานโดยการเช็คผ่าน IP Address แทนการใช้ Cookie แต่วิธีนี้อันตรายมากๆ เพราะเราไม่มีทางการันตีได้ว่า IP ของแต่ละผู้ใช้จะต่างกัน เป็นไปได้ว่าผู้ใช้จากบริษัทหนึ่งมาจาก Proxy Server หรือ NAT เดียวกัน ผลที่ตามมาคือเซอร์เวอร์นึงจะรับ Request จากผู้ใช้ที่มาจากบริษัทนี้ทั้งหมด

ปัญหาของ Sticky Session

ในโลกแห่งความจริงอันโหดร้าย เหตุสุดวิสัยจะต้องเกิดขึ้นแน่นอน

S1 อาจจะพัง ด้วยสาเหตุนานาประการ เช่น

  • สายแลนที่ต่อไว้กับ S1 มีคนเดินสะดุดจนหลุดออก
  • มีคนรัน rm -f / เพราะคิดว่าเป็น Dev Server
  • ฮาร์ดดิสก์เต็ม หรือ inode หมด
  • มีคนไปแก้ Config ทำให้ เว็บเซอร์เวอร์รันไม่ได้
  • มีหนูไปฉี่ใส่เมนบอร์ด
  • วันนี้ลืมเอาน้ำหวานไปไหว้เจ้าที่ เลยไม่พอใจ เข้าสิงเซอร์เวอร์ให้พัง

เวลาเซอร์เวอร์ตาย LB จะรู้ เพราะมันจะทำการยิง Health Check Request ไปยังเซอร์เวอร์เป็นระยะๆ พอ Health Check ที่ยิงไปไม่มีอะไรตอบกลับมาจาก S1 ฝั่งLBก็จะรู้ทันทีว่ามีอะไรเกิดขึ้นแล้ว และทำการตัด S1 ออกจากกอง เวลามี Request ใหม่ๆมาก็ส่งไปให้ S2 กับ S3 แทน

S1 cannot be reached by Load Balancer
S1 cannot be reached by Load Balancer

ซึ่งในจังหวะนั้นเอง ดันมี Request ที่มาพร้อมกับ Session Id ที่เคยส่งไปให้ S1

กรณีนี้ LB ไม่มีทางเลือก ต้องส่ง Request นั้นไปยัง S2 หรือ S3 อยู่ดี เพราะ S1 ตายไปเรียบร้อยแล้ว

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

และถ้าเกิดว่าเจ้าที่เกิดเปลี่ยนใจ เนรมิตให้ S1 กลับมาทำงานอีก Request ถัดๆไปก็ไม่กลับมาที่ S1 แล้ว ถ้าหากเราจำข้อมูลอะไรทิ้งไว้ใน S1 แล้วหวังว่าจะมี Request ต่อๆไปมาลบทิ้ง ข้อมูลนั้นอาจจะไม่มีวันถูกลบเลยก็ได้

แทนที่จะพึ่ง Sticky Session, ทำทุกอย่างให้เป็น Stateless แทน

สำหรับโปรแกรมเมอร์ เราจะคาดหวังว่า Request จากผู้ใช้เดียวกันจะมายังเซอร์เวอร์ตัวเดียวอยู่เสมอไม่ได้

ลองนึกภาพว่าเราทำเว็บขายของ แล้วเก็บของที่ลูกค้าเลือกใส่ตะกร้าเอาไว้ใน S1 พอ S1 ตาย Request ใหม่ถูกส่งไปยัง S2

S2 ก็งงสิ เพราะไม่มีในตะกร้าเก็บไว้

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

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

นอกจากจะเป็น UX ที่ห่วยแล้ว ยังทำให้โปรแกรมเราซับซ้อนขึ้นมาก

ในทางปฏิบัติ อย่างน้อยก็ในเลเยอร์ของเว็บเซอร์เวอร์ (Web Server Layer) เราจึงควรออกแบบโปรแกรมให้เป็น Stateless

Stateless คือไม่จำอะไรอยู่ในเว็บเซอร์เวอร์ ถ้าจะจำ ก็ให้เอาไปจำใน Database แทน

Keeping no state in server S1-S3
Keeping no state in server S1-S3

ซึ่งหากต้องการความเร็ว ก็ให้เอาไปไว้ใน In-Memory Database หากไม่ต้องเร็วมาก ก็ใส่ลงใน NoSQL หรือ Relational DB ธรรมดาได้

วิธีนี้อาจจะทำให้เซอร์เวอร์ทำงานช้ากว่า เพราะต้องดึงข้อมูลจาก Database แทนที่จะดึงจากในเครื่องได้เลย

แต่การพัฒนาและการดูแลรักษาจะใช้เวลาน้อยกว่ามาก เพราะโปรแกรมจะซับซ้อนน้อยกว่า

ถ้าทำ Sticky Session + Stateless ล่ะ?

สมมติว่าเราทำทุกอย่างให้เป็น Stateless แล้ว โดยการใส่ In-Memory Database แยกไว้ให้ S1/S2/S3 ดึงข้อมูลร่วมกันได้ เหมือนในภาพข้างบน

เราจัด Sticky Session เพิ่มด้วย แล้วทำการ Cache ข้อมูลลงไปใน S1/S2/S3 ล่ะ

ถ้าหากข้อมูลที่จะใช้ อยู่ใน Cache ของ S1 แล้ว ก็ทำการส่งกลับได้เลย แทนที่จะต้องไปขอข้อมูลจาก Database

วิธีนี้ทำให้เร็วขึ้นจริงครับ แต่ก็จะมีปัญหาอยู่ดีในทางปฏิบัติ

ตัวอย่างเช่น

1. ข้อมูลถูกแก้ผ่านอีกเซอร์เวอร์หนึ่ง

สมมติว่า Request แรก จากผู้ใช้ C1 ถูกส่งไปที่ S1

S1 ประมวลผล ดึงข้อมูลจาก Database มาแล้วเก็บข้อมูลไว้ใน Cache ก่อนส่งกลับไป

หลังจากนั้น มีผู้ใช้ C2 ส่ง Request มาแก้ไขข้อมูลดังกล่าว โดย Request นี้ถูกส่งไปที่ S2

S2 ประมวลผล แก้ข้อมูลใน Database เก็บไว้ใน Cache แล้วส่งกลับ

ณ จุดนี้ ข้อมูบที่เก็บอยู่ใน S1 จะไม่ถูกต้องแล้ว หากผู้ใช้ C1 ติดต่อมายัง S1 เพื่อดึงข้อมูล จะทำอย่างไร?

หากต้องการให้ข้อมูลถูกเป๊ะ 100% (Strong Consistency) เราก็ต้องทำแอพพลิเคชั่นของเราให้มีการ Invalidate Cache หากเกิดการแก้ข้อมูล ซึ่งหากมีการแก้ข้อมูลบ่อยๆ ประสิทธิภาพที่ได้ อาจจะไม่คุ้มค่าการเวลาทีต้องเสียไปในการ Invalidate Cache

2. Unbalanced Load

หาก Session ของผู้ใช้แต่ละคนยาวไม่เท่ากัน อาจเป็นไปได้ที่ S1 จะมีผู้ใช้ค้างอยู่จำนวนเยอะกว่า

ตัวอย่างเช่น เรามีผู้ใช้ C1 ถึง C100 ถูกส่งไปให้ S1 กับ S2 เท่าๆกัน

โดย Request จาก C1, C3, …, C99 (เลขคี่) ถูกส่งไปให้กับ S1 ส่วน Request จาก C2, C4, …, C100 (เลขคู่) ถูกส่งไปให้กับ S2

ณ จุดนี้ เซอร์เวอร์ทั้งสองจะรองรับผู้ใช้เครื่องละ 50 คน เหมือนจะเท่าๆกัน

แต่ถ้าเผอิญว่าผู้ใช้กลุ่มเลขคี่ ส่ง Request มาเฉลี่ย 10 ครั้งต่อนาที แต่ผู้ใช้กลุ่มเลขคู่ ส่งแค่ 5 ครั้งต่อนาที

ดังนั้น S1 จะได้รับทั้งหมด 50 x 10 = 500 Requests/นาที ในขณะที่ S2 จะได้รับแค่ 50 x 5 = 250 Requests/นาที ตามตารางด้านล่าง

Load on S1 (Request/นาที) Load on S2 (Request/นาที)
500 250

ถ้าจะให้แย่กว่านั้นอีก หลังจากเวลาผ่านไป 10 นาที ปรากฏว่า ผู้ใช้ในกลุ่มเลขคู่ของ S2 ทำงานเสร็จเร็ว เลยไม่มี Request อีกแล้ว

ในขณะที่กลุ่มเลขคี่ ยังเหลือคนที่ทำงานอยู่ 10 คน

ปริมาณ Load จะเป็นดังนี้

Load on S1 (Request/นาที) Load on S2 (Request/นาที)
100 (10 x 10) 0

ถัดไป มี Request ใหม่เข้ามาอีกจาก C101 ถึง C200 และถูกกระจายแบบเดียวกัน ปริมาณ Load ก็จะกลายเป็น

Load on S1 (Request/นาที) Load on S2 (Request/นาที)
600 (100 + new 500) 250

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

นี่คือปัญหาการกระจาย Load ไม่ Balance อย่างที่เราต้องการ กรณีนี้จะทำให้ Capacity ที่เรารองรับได้ลดลงกว่าที่ควรจะเป็น เพราะ S1 จะทำงานไม่ไหวแม้ว่า S2 อาจจะยังเหลือทรัพยากรอีกเยอะ

ยิ่งถ้า Session ของผู้ใช้บางคนยาวมาก (เช่นหลายชั่วโมง) Session ยาวๆพวกนี้จะค้างอยู่ในระบบนานพอที่จะทำให้มีโอกาสเกิด Unbalanced Load ได้มากขึ้น

ถ้าคิดว่าเว็บไซต์ของเราอาจมี Session ที่ค้างไว้นาน Sticky Session อาจจะไม่ใช่ตัวเลือกที่เหมาะสม

Note จริงๆแล้ววิธีนี้อาจแก้ด้วยการกระจาย Load แบบวิธีอื่นๆแทนที่จะกระจายเท่าๆกันตลอด (เช่น กระจายไปให้ตัวที่ Latency ต่ำที่สุด, หรือมี Connection ที่เปิดอยู่น้อยที่สุด) แต่ก็ไม่สามารถการันตีได้อยู่ดีว่า Load จะไม่กระจุกตัว

3. ปัญหาเวลาเซอร์เวอร์บางตัวดาวน์

หากเราต้องการหยุดเซอร์เวอร์ตัวหนึ่งชั่วคราว (อาจจะเพื่ออัพเดตซอฟต์แวร์ หรือเซอร์เวอร์มีปัญหาต้อง restart) จะเกิดอะไรขึ้น?

ถ้ามีเซอร์เวอร์ 3 ตัว ปริมาณ Request 1/3 ที่เข้ามา S1 จะต้องถูกกระจายไปยัง S2/S3 ซึ่งไม่มี Cache ซึ่งจะนำมาซึ่งปัญหา 2 อย่าง

  1. Latency ของ Request 1/3 ส่วนจะพึ่งขึ้นอย่างมีนัยยะสำคัญ เพราะต้องไปดึงข้อมูลมาจาก In-Memory DB ข้างนอกแทนที่จะใช้ใน Cache ได้เลย ไอ้การพุ่งขึ้นโดยไม่ได้นัดหมายนี้เป็น False alarm ซึ่งอาจจะทำให้ทีม Operation ต้องแหกขี้ตาขึ้นมากลางดึกได้ เป็นที่สาบแช่งของทีมได้
  2. ปริมาณ Request ที่สูงขึ้นนี้ อาจทำให้ขนาดของ Cache ใน S2/S3 ไม่พอรองรับปริมาณข้อมูลของผู้ใช้ทั้งหมด ลองนึกภาพว่าเราเตรียม Cache ไว้ให้พอดีสำหรับผู้ใช้แค่ 1/3 อยู่ดีๆ ปริมาณข้อมูลดันเพิ่มเป็น 1/2 นั่นแปลว่าจะมีข้อมูล 1/5 (1/2-1/3) ที่ไม่มีที่เก็บใน Cache และต้องถูกเตะออกไป ซึ่งจะทำให้เกิดการ Cache miss บ่อยขึ้น ส่งผลต่อความเสถียรของระบบอีก

บางคนอาจจะบอกว่า งั้นทำไมเราก็เปิดเซอร์เวอร์ S4 ขึ้นมาช่วยสิ โดยเซ็ตค่าให้เปิดอัตโนมัติเวลามี S1-S3 เดี้ยงไปซักตัวนึง

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

แถม LB เองก็อาจไม่ฉลาดพอ ที่จะส่ง Session พวกนั้นเข้า S4 ให้หมดโดยไม่เข้า S2 หรือ S3 เลย

สรุป

แม้คอนเซ็บ Sticky Session จะฟังดูดีมาก แต่มีปัญหาใหญ่ๆเยอะในระบบจริง ถึงฝั่งเซอร์เวอร์จะเป็น Stateless ก็ตาม

ปัญหาแรกคือโปรแกรมที่เขียนจะต้องจัดการทั้งกรณีที่ Sticky session เป็น Request เก่า และ Request ใหม่ ทำให้โปรแกรมซับซ้อนมากขึ้น ใช้เวลาในการพัฒนาและดูแลรักษายากขึ้น

ปัญหาถัดมาคือเรื่องการกระจายของ Load ที่อาจไม่สมดุล เพราะผู้ใช้บางคนอาจใช้งานหนักกว่าคนอื่น หรือมี Session ที่ยาวค้างอยู่ในเซอร์เวอร์หนึ่งๆนาน

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

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