การทำ Performance Test, Tuning และการหา Resource Leak ใน Java App

Radit P.
Ascend Developers
Published in
5 min readNov 18, 2020

--

การทำ performance test และ performance tuning เป็นเรื่องที่สำคัญสำหรับ service ที่ต้องรองรับการใช้งานของ user จำนวนมาก เพื่อให้เรามั่นใจว่าจะไม่มีปัญหาเมื่อเราเอาขึ้นไปใช้งานจริง แต่ถ้าเราทำไปโดยไม่ได้มีขั้นตอนหรือหลักการที่แน่นอน ก็จะทำให้เสียเวลาไปมากขึ้นกว่าที่ควรจะเป็นโดยอาจไม่ได้ผลที่น่าพอใจ จึงเป็นที่มาของบทความนี้ที่ขยายความมาจาก Guideline ของการทำ performance test ที่กำหนดขึ้นมาใช้ภายในทีม และได้เพิ่มเติม tips ต่างๆ ที่อาจจะมีประโยชน์และช่วยให้ได้ผลมากขึ้นกว่าเดิมครับ

ตั้งเป้าหมาย

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

  • Response time : เวลา processing time ที่ app รวมถึง latency ที่ network ด้วย
  • Throughput: จำนวน transaction ที่สามารถ process ได้ในเวลาหนึ่ง หรือที่เราเรียกกันว่า TPS (Transaction per second)
  • SLA: service level agreement เป็นสิ่งที่ระบุค่า response time หรือ throughput ขั้นต่ำที่ยอมรับได้บน production ตัวอย่างเช่น เราอาจจะได้รับตัวเลขคาดการณ์จาก PO ว่าภายในหนึ่งปี app ของเราจะต้องรองรับจำนวนจาก user 40,000 คนภายในหนึ่งชั่วโมงได้ เพราะฉะนั้นถ้า service ของเราทำ performance test ได้ผลเกินกว่าค่า SLA นี้ก็แปลว่าผ่าน

ทั้งนี้เป้าหมายก็ขึ้นอยู่กับความจำเป็น service ของเราด้วย ตัวอย่างเช่นถ้าเป็น service ที่ใช้ภายใน เราก็อาจจะไม่มี SLA ที่เป็นตัวเลขแน่นอน แต่ถึงกระนั้นเราก็ควรจะต้องรู้ค่า throughput ที่หนึ่ง instance ของ app เราจะสามารถรับได้ เพื่อที่ว่าต่อไปเมื่อมีจำนวน user มากขึ้น เราก็จะสามารถใช้ค่านี้เป็นตัวบอกได้ว่าเราจำเป็นต้อง scale จำนวน instance ขึ้นไปมากเท่าไหร่ถึงจะเพียงพอ

หา Baseline ก่อน

Baseline คือผลการทำ performance test ที่เราจะใช้เป็นค่าตั้งต้นก่อนที่เราจะทำการปรับจูนใดๆ เพื่อที่เราจะนำผลนี้ไปใช้เปรียบเทียบได้ทีหลัง

  • เราจะเริ่มด้วยการเตรียม deploy app ของเราใน environment ที่จะใช้ในการทำ performance test โดยใช้ resource cpu/memory ขั้นต่ำที่พอจะรัน app ของเราหนึ่ง instance ได้
  • เขียน performance test scripts ที่ตรงกับ request ที่เกิดขึ้นได้ในสถานการณ์จริง โดย Script ที่ได้ไม่ควรจะรันด้วยเวลาสั้นเกินไป ไม่งั้นเราอาจจะไม่เห็นปัญหาอย่าง Memory Leak ที่ต้องใช้เวลาพักหนึ่งถึงจะแสดงออกมา และก็ไม่ควรจะใช้เวลารันนานเกินไป เพราะจะทำให้เราต้องใช้เวลามากในการรันแต่ละครั้ง จนไม่สามารถทำ performance เสร็จได้ทันเวลา
  • Tool ที่เราใช้ในการทำ performance test ก็คือ JMeter ซึ่งเป็น tool opensource ที่เขียนด้วย Java สำหรับการทำ load test การจำลอง request จากหลาย user ที่เข้ามาพร้อมกันทำได้โดยการใช้ Thread Group
Number of Threads

Number of threads เปรียบเหมือนจำนวน user ที่จะส่ง request เข้ามาหา app ของเราในเวลาพร้อมๆ กัน

และเราสามารถใช้ Constant Throughput Timer ในการกำหนดจำนวน request ต่อนาทีที่เราต้องการ ทั้งนี้ขึ้นอยู่กับว่าเราต้องการจำกัด throughput ในระหว่างที่ทำ performance test หรือไม่

ตัวอย่างการกำหนด Constant Throughput Timer

Throughput จำนวน transaction ที่ app ของเราสามารถ process ได้จนสำเร็จในเวลาหนึ่ง ๆ

ผล Throughput ที่ออกมาใน report

Hit per second จำนวน request ที่เข้าไปหา app ของเราในหนึ่งวินาที โดนนับทันทีที่มีการส่ง request ออกไป

ค่า Hit per Second ใน report
  • เริ่มการรัน performance test
  • ในระหว่างที่รันนั้น เราควรจะต้อง monitor การใช้ CPU และ memory ของ app ไปด้วย รวมถึง metric อื่น เช่นการ consume message จาก message queue หรือการอ่านเขียน database เพื่อดูว่าการใช้ resource ต่างๆนั้นไปถึง limit รึยังหรือมีส่วนไหนที่เป็นคอขวดอยู่
  • หา load สูงสุดที่ app ของเรารับได้ ทำได้ด้วยการค่อย ๆ เพิ่มจำนวน thread ของ JMeter ที่ใช้ในการยิง request เข้าไป โดยต้องดูให้การใช้ cpu และ mem ไม่ได้ขึ้นไปเกิน 80% แล้วเราก็จะได้ค่าที่เป็น throughput สูงสุดของ app เราโดยที่ยังไม่มี error
การเพิ่ม thread ใน JMeter ควรจะ ramp-up จากจำนวน thread น้อยๆ ก่อน เพื่อให้เวลาในการสร้าง thread pool สำหรับ DB/network connection
  • ลองรันหลายรอบเพื่อจะมั่นใจว่าผลที่ได้นั้นคงที่ตลอด
  • ค่าที่เราได้สุดท้ายนั้นก็คือค่า Baseline ที่เราจะนำมาใช้เปรียบเทียบได้เวลาที่เราทำ performance tuning ต่อไป ว่าหลังจากที่เราทำ tuning แล้วได้ผลที่ดีขึ้นหรือแย่ลงเมื่อเทียบกับ baseline นี้

ทำ Performance Tuning

ทีนี้ถ้าผลของ Baseline ยังไม่น่าพอใจเมื่อเทียบกับเป้าหมาย ก็จำเป็นที่เราจะต้องทำ performance tuning กัน

  • ดูค่าจาก metrics และ log เพื่อหาจุดที่เป็นคอขวดใน performance ของ app เรา เริ่มจากหาจุดที่ใช้เวลาในการ process นานที่สุด เพื่อจะทำการปรับจูน

ตัวอย่างการทำ tuning

ปรับค่า Xms, Xmx ใน JVM อาจจะช่วยได้ในกรณีที่ app ของเราใช้ memory มากกว่าที่ค่าปัจจุบันจะรับได้ การเพิ่ม Xms ให้เป็นค่าที่สูงตั้งแต่แรกจะทำให้ตัว JVM จองที่ของ memory ไว้เยอะพอตั้งแต่แรกที่จะพร้อมรับ load ได้ทันที โดยที่ไม่ต้องใช้เวลาในการ ramp-up และเสียเวลา cpu ไปกับการทำ GC เยอะ

ปรับ code และ algorithm ใน app ของเรา จากจุดที่ใช้เวลาในการ process นานนั้น เราก็จะอาจจะลองตรวจหาจุดที่จะสามารถปรับปรุงให้ทำงานได้เร็วขึ้นได้

การใช้ library ที่ไม่ thread-safe หรือมี code ที่ใช้ resource มากเกินจำเป็นก็จะทำให้ performance ช้าลงได้

ลดการเขียนอ่าน database ด้วยการใช้ cache เช่น Ehcache หรือ Redis

เพิ่ม resource ของ CPU และ memory หรือเพิ่มจำนวน instance ของ app

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

การหา Resource Leak

สิ่งหนึ่งที่เราจะเจอได้ในการทำ performance test คือการพบว่า app ของเรามีปัญหา resource leak ตัวอย่างเช่น app มีการใช้ memory มากขึ้นเรื่อยๆ ในระหว่างที่รัน perf test ไป หรือมีการใช้ CPU มากกว่าที่ควรจะเป็น ซึ่งเกิดได้จากหลากหลายสาเหตุและมีหลายวิธีที่ใช้ตรวจหาได้ ในบทความเราจะสนใจที่การหา memory leak เป็นหลัก แต่ก็สามารถนำไปปรับใช้กับปัญหาอื่นได้เช่นกันครับ

เราจะเห็น pattern ของ memory leak ได้จากการ monitor การจัดการ memory ของตัว JVM ซึ่งแบ่งเป็น 2 ส่วนใหญ่ๆ คือ

  • Heap space ส่วนที่เก็บ Java Objects และเป็นส่วนที่จะมีปัญหา leak เกิดได้บ่อย เช่นจาก Object ที่ยังถูก reference อยู่ใน code เรื่อย ๆ และไม่ถูกทำ Garbage Collect ไป ส่วนของ heap จะแบ่งออกเป็นส่วนย่อยได้ดังนี้
    — Eden space เป็นพื้นที่สำหรับ object ที่ถูกสร้างขึ้นมาใหม่ และจะถูกเคลียร์เมื่อมีการทำ Minor GC ซึ่งเกิดขึ้นบ่อย
    — Survivor space เป็น object ที่ยังถูก reference อยู่และไม่ถูกเคลียร์จาก Minor GC
    — Old Gen: Object ที่เหลือรอดจะมาสะสมอยู่ที่นี่ เป็น object ที่มีอายุยืนและจะถูกเคลียร์ต่อเมื่อมีการทำ Major GC เท่านั้น ซึ่งจะต้องรอเวลานานกว่าจะมีการทำ Major GC ครั้งหนึ่ง

Pattern ของ memory leak ที่เห็นได้ชัดเจนคือการที่ memory ใน old-gen ไม่กลับมาเท่าเดิมหลังจากเกิดการทำ Major GC แปลว่าเราจะต้องรอดูหลังเกิด Major GC ถึงจะเห็นได้ว่ามีอาการ leak ที่จุดนี้

pattern ปกติ
pattern ที่มีแนวโน้มเกิด memory leak จะเห็นว่า size จะค่อยๆเพิ่มขึ้นหลังเกิด GC
  • Non-heap คือส่วนที่เหลือซึ่งเก็บตัว call stacks, metaspace, native memory เป็นอีกส่วนสามารถเกิด mem leak ได้เช่นกัน แม้จะมีโอกาสน้อยกว่า และการหาต้นตอจะทำได้ยากกว่า
    — สาเหตุของ mem leak ในส่วนนี้มีได้ทั้งจาก code ที่มีการเก็บข้อมูลในส่วน non-heap แล้วไม่ได้ทำการ clear ออกให้หมดหรืออาจเป็น bug ในตัว JVM เอง

สิ่งสำคัญอย่างแรกขึ้นอยู่กับว่าเราสามารถ reproduce อาการของ memory leak ด้วยการรันในเครื่อง local ของเราได้หรือไม่

  • ถ้าสามารถทำได้ เราสามารถใช้ tool สำหรับทำ profiler เช่น VisualVM (https://visualvm.github.io/) หรือ YourKit (https://www.yourkit.com/) ในการดู stat ของ memory ที่จะแยกกราฟออกมาให้ดูตามแต่ละส่วนของ memory ใน JVM และสามารถใช้ tool พวกนี้ในการทำ heap dump ออกมาได้
ตัวอย่างการ monitor heap และ thread ด้วน VisualVM ในรูปนี้จะเห็นตัวอย่างของปัญหา Thread Leak — ในกราฟบน size ของ heap จะเพิ่มขึ้นทีละนิดหลังการทำ GC ส่วนในกราฟล่างจะเห็นจำนวน thread ที่เพิ่มขึ้นอย่างชัดเจน
  • ถ้าปัญหาเกิดเฉพาะบนเครื่องใน test environment ของเราหรือบน production ที่อาจรันอยู่ภายใน docker container การจะใช้ profiler ไป connect อาจทำได้ยากกว่า เราสามารถ monitor metrics ของ JVM ได้จาก Micrometer (https://micrometer.io/) ส่วนการจะได้ heap dump มานั้น ได้คือด้วยการใส่ JVM argument -XX:+HeapDumpOnOutOfMemoryError เข้าไปเวลาที่รัน app และเมื่อ app เกิด OutOfMemoryError ขึ้นมา file heap dump ก็จะถูก generate ออกมาครับ แต่ถ้า app ของเรารันอยู่ใน container orchestration อย่าง Kubernetes ตัว instance ก็จะถูก kill ไปพร้อมกัน เราจึงต้องสร้าง PVC ขึ้นมาเพื่อเก็บ file heap dump เพื่อให้มันไม่หายไปพร้อมกับตัว instance
การ monitor JVM stat จาก Micrometer
  • ทำ analyze เพื่อหา memory leak ด้วย Memory Analyzer (MAT) (https://www.eclipse.org/mat/) นำ heap dump ที่ได้จากวิธีการข้างบนมาเปิดด้วย tool ตัวนี้ มันก็จะทำการวิเคราะห์ออกมาเป็น report เพื่อบอกจุดที่น่าสงสัยที่อาจเป็นต้นตอของอาการ mem leak ได้เลย
ตัวอย่าง Leak Suspects จาก Memory Analyzer
  • อีกทางเลือกใหม่นอกจากการทำ heap dump ที่ต้องรอให้ app เกิด OutOfMemory Error ก่อน คือการใช้ Java Flight Recorder (JFR) ที่เป็นฟีเจอร์ builtin ของ JDK 8 ขึ้นไปในการทำ profiling ซึ่งมีผลกับ performance น้อยมาก ทำได้ด้วยการใส่ argument JVM แบบด้านล่าง
-XX:StartFlightRecording=duration=5m,settings=profile,filename=leak.jfr

ตัวอย่างนี้เป็นการสั่งให้ generate file ชื่อ leak.jfr ออกมาหลังจากที่ app start ไปได้ 5 นาที

  • เราสามารถนำไฟล์นี้ไปเปิดเพื่อดูข้อมูลได้ใกล้เคียงกับการใช้ profiler tool ด้วยการใช้ JDK Mission Control (https://www.oracle.com/java/technologies/jdk-mission-control.html) ซึ่งเป็น tool ที่มีมาให้ใน JDK เช่นกัน หรือถ้าหาไม่เจอก็สามารถ dowload จาก link ข้างบนได้ สามารถสร้าง report ที่ทำให้เห็นว่า app เรามีจุดไหนที่เป็นคอขวดอยู่ได้ด้วย
ตัวอย่างการดู stat ของ Memory จาก Java Flight Recorderใน JDK Mission Control
  • นอกจากการใช้ tool ข้างต้นนี้เราก็ควรจะทำ code review เพื่อหาจุดที่น่าสงสัยด้วย เช่นมองหา code change ล่าสุดที่ทำให้เกิดปัญหาได้ แล้วรองรันโดยเปิด profiler เทียบกันดู ก็จะเป็นอีกทางที่ช่วยทำให้หาต้นตอได้

สรุป

เราควรกำหนดขั้นตอนในการทำ performance test ให้เป็นมาตรฐานเช่นเดียวกับ process อื่นในการทำ software development เพื่อให้เกิดความชัดเจนและเข้าใจตรงกันในทีม ส่วนเรื่องการทำ tuning หรือการหา resource leak นั้นก็มีวิธีที่หลากหลายแล้วแต่เงื่อนไขของปัญหาที่เจอ ที่ยกมาเป็นแค่เพียงตัวอย่างของวิธีที่สามารถทำได้ง่ายและสะดวกครับ

--

--