การออกแบบฐานข้อมูลสำหรับ Serverless ความท้าทายใหญ่ที่สุดในใจเราคือการสร้าง Infrastructure ที่รองรับ Per-request Pricing อย่างมีกำไร เราเชื่อว่า Upstash บรรลุแล้ว หลัง Launch ผลิตภัณฑ เราพบอีกความท้าทายใหญ่: Database Connections!
ตามที่รู้ Serverless Functions Scale จาก 0 ถึง Infinity หมายความว่าเมื่อ Functions ได้ Traffic มาก Cloud Provider สร้าง Containers ใหม่ (Lambda Functions) แบบ Parallel และ Scale Out Backend ของคุณ หากสร้าง Database Connection ใหม่ภายใน Function คุณจะถึง Connection Limit ของ Database ได้เร็วมาก
หากพยายาม Cache Connection นอก Lambda Functions ปัญหาอีกอย่างเกิดขึ้น เมื่อ AWS Freeze Lambda Function มันไม่ปิด Connection ดังนั้นคุณอาจจบลงด้วย Idle/Zombie Connections หลายตัวที่ยังคุกคาม
ปัญหานี้ไม่เฉพาะ Redis ใช้ได้กับทุก Database ที่พึ่งพา TCP Connections (MySQL, PostgreSQL, MongoDB ฯลฯ) คุณเห็น Serverless Community สร้างโซลูชันแบบ Client-side เช่น serverless-mysql เราในฐานะ Upstash มีข้อได้เปรียบในการ Implement และ Maintain Server-side จึงตัดสินใจลดปัญหาด้วยการ Monitor Connections และ Evict Idle Ones อัลกอริทึมมีดังนี้: สำหรับ Max Concurrent Connections เรามี 2 Limits สำหรับ Database คือ Soft Limit และ Hard Limit เมื่อ Database ถึง Soft Limit เราเริ่ม Terminate Idle Connections ยอมรับ New Connection Requests จนถึง Hard Limit หากถึง Hard Limit จะเริ่ม Reject New Connections
Connection Eviction Algorithm
if( current_connection_count < SOFT_LIMIT ) { ACCEPT_NEW_CONNECTIONS }
if( current_connection_count > SOFT_LIMIT && current_connection_count < HARD_LIMIT ) {
ACCEPT_NEW_CONNECTIONS
START_EVICTING_IDLE_CONNECTIONS
}
if( current_connection_count > HARD_LIMIT ) { REJECT_NEW_CONNECTIONS }
หมายเหตุ: Max Concurrent Connection Limits ใน Upstash Docs คือ Soft Limits
Ephemeral Connections (การเชื่อมต่อชั่วคราว)
หลัง Deploy อัลกอริทึมข้างต้น เราเห็นการลดลงมากของ Rejected Connections ในทุก Region แต่ถ้าอยากปลอดภัยกว่านั้น แก้ปัญหาที่ฝั่งคุณได้ด้วย แทนที่จะ Reuse Connection ให้เปิด Redis Connection ภายใน Function แต่ปิดทันทีเมื่อทำเสร็จ ดังนี้:
exports.handler = async (event) => {
const client = new Redis(process.env.REDIS_URL);
/* do stuff with redis */
await client.quit();
/* do other stuff */
return { response: "response" };
};
โค้ดด้านบนช่วยลด Concurrent Connection Count ผู้คนถามถึง Latency Overhead ของ New Connections Redis Connections รู้จักกันดีว่า Lightweight
Redis Connections จริงๆ แล้ว Lightweight ไหม?
เรารัน Benchmark Test ดูว่า Redis Connections 輕量 มากแค่ไหน ทดสอบเปรียบเทียบ Latency ของ 2 Approaches:
1. EPHEMERAL CONNECTIONS: ไม่ Reuse Connection สร้าง Connection ใหม่สำหรับแต่ละคำสั่งและปิดทันที บันทึก Latency ของ Client Creation, ping() และ client.quit() ร่วมกัน ดู benchEphemeral() ในส่วนโค้ดด้านล่าง
2. REUSE CONNECTIONS: สร้าง Connection ครั้งเดียวและ Reuse เดียวกันสำหรับทุกคำสั่ง บันทึก Latency ของ ping() Operation ดู benchReuse() ด้านล่าง
async function benchReuse() {
const client = new Redis(options);
const hist = hdr.build();
for (let index = 0; index < total; index++) {
let start = performance.now() * 1000; // to μs
client.ping();
let end = performance.now() * 1000; // to μs
hist.recordValue(end - start);
await delay(10);
}
client.quit();
console.log(hist.outputPercentileDistribution(1, 1));
}
async function benchEphemeral() {
const hist = hdr.build();
for (let index = 0; index < total; index++) {
let start = performance.now() * 1000; // to μs
const client = new Redis(options);
client.ping();
client.quit();
let end = performance.now() * 1000; // to μs
hist.recordValue(end - start);
await delay(10);
}
console.log(hist.outputPercentileDistribution(1, 1));
}
ดู Repo หากอยากรัน Benchmark เอง
เรารัน Benchmark ใน AWS EU-WEST-1 Region 2 Setups แรกคือ SAME ZONE Client และ Database อยู่ AZ เดียวกัน ที่สองคือ INTER ZONE Client รันใน AZ ต่างจาก Database ใช้ Upstash Standard Type เป็น Database Servers
เราพบ Overhead ของการสร้างและปิด Connection ใหม่ (Ephemeral Approach) คือ 75 Microseconds (99th Percentile) Overhead คล้ายกันใน Inter-zone Setup (80 Microseconds)
จากนั้นเราทดสอบซ้ำใน AWS Lambda Functions ผลต่างออกไป โดยเฉพาะเมื่อตั้ง Memory ต่ำ (128Mb) พบ Overhead ใหญ่ขึ้นของ Redis Connections เห็น Latency Overhead ถึง 6-7 msec ภายใน AWS Lambda Functions
ข้อสรุปเกี่ยวกับ Redis Connections:
- Redis Connections จริงๆ แล้ว Lightweight บนระบบที่มี CPU Power พอควร แม้ t2.micro
- CPU Power ด้วย Default AWS Lambda Config แย่มาก เพิ่มต้นทุน TCP Connections อย่างมีนัยสำคัญเทียบกับ Total Execution Time ของ Lambda Function
- หากใช้ Lambda Functions ด้วย Default/Minimum Memory ควร Cache Redis Connection นอก Function
Frozen Container => Zombie Connection
หลังรู้ว่า Connection อาจมี Overhead โดดเด่นในบาง AWS Lambda Setups เราตัดสินใจทดสอบเพิ่มเติมเกี่ยว Reusing Connection ใน AWS Lambda พบอีก Issue หนึ่ง Edge Case ที่ยังไม่มีใครรายงาน
Timeline ของการเกิดเหตุ:
STEP1 - timer-0sec: ส่ง Request Cache Connection นอก Lambda Function
if (typeof client === "undefined") {
var client = new Redis("REDIS_URL");
}
module.exports.hello = async (event) => {
let response = await client.get("foo");
return { response: response + "-" + time };
};
STEP2 - timer-5sec: AWS Freeze Container หลังเวลาสั้น
STEP3 - time-60sec: Upstash มี Timeout 60 วินาทีสำหรับ Idle Connections Kill Connection แต่ไม่ได้ ACK จาก Client เพราะ Frozen Server Connection เข้าสู่ State FIN_WAIT_2
STEP4 - time-90sec: Upstash Server Kill Connection เสร็จ ออกจาก FIN_WAIT_2 State
STEP5 - time-95sec: Client ส่ง Request เดิม ได้ ETIMEDOUT Exception เพราะ Client สมมติ Connection เปิดอยู่แต่อยู่จริงไม่ได้ 🤦🏻 🤦🏻 🤦🏻
STEP6 - time-396sec: 5 นาทีหลัง Request ล่าสุด AWS Kill Container เสร็จ
STEP7 - time-400sec: Client ส่ง Request อีกครั้ง ทำงานได้ดี เพราะ Container สร้างใหม่ Initialization Step ไม่ถูก Skip Connection ใหม่ถูกสร้าง
เห็นได้ว่า AWS Thaw Container และ Reuse Connection แต่ Connection ถูกปิดจาก Server Side แต่อาจสื่อสารไม่ได้เพราะ Function Frozen มี Synchronization Problem ระหว่าง Upstash Evict Idle Connection และ AWS Handle Idle Function หากเรา Kill Idle Connection ก็ต่อเมื่อ AWS Terminate Function จะไม่มี Issue
เราเปลี่ยน Upstash Connection Timeout เป็น 310 วินาที สมมติ AWS Terminate Idle Function ใน 300 วินาที หลังเปลี่ยน ปัญหาหายไป ปัญหาคือ AWS ไม่ Transparent เกี่ยวกับการ Terminate Idle Functions ต้องทดสอบต่อและ Detect หาก Issue เกิดซ้ำ
Issue นี้คล้ายกับ Issue บน serverless-mysql Library ใน Comments แนะนำ Retry Request เมื่อ ETIMEDOUT Exception แต่ Retry มี 2 Drawbacks: 1) อาจ Retry Write Request ที่ Processed แล้วแต่ Timeout ด้วย Network Problem จริง 2) Extra Latency ของ Failed Request
GraphQL ช่วยได้อีกด้วย
วิธีหนึ่งแก้ Connection Issues คือ Connectionless API Upstash รองรับ GraphQL API นอกเหนือจาก Redis Protocol GraphQL เป็น HTTP-based จึงไม่มี Connection Limit Issue ดู Docs สำหรับ Supported Commands ระวังว่า GraphQL API มี Latency Overhead ~5msec เทียบ Redis Protocol
สรุป
เรา Customize Upstash Databases ให้ Smooth Experience สำหรับ Serverless Applications อัลกอริทึม Server-side ใหม่ลบ Inactive Connections ที่ AWS Lambda สร้างมาก คุณลด Connection Count ได้ด้วยการ Open/Close Redis Client ภายใน Lambda Function แต่อาจมี Latency Overhead หาก Function Memory น้อยกว่า 1GB
สรุปคำแนะนำสำหรับ Serverless Use Cases:
- หาก Use Case ระดับ Latency Sensitive (เช่น 6msec ใหญ่เกินไป) ให้ Reuse Redis Client
- หากเจอ Concurrent Clients มากมาย (Over 1000) ให้ Reuse Redis Client
- หาก Use Case ไม่ Latency Sensitive ให้ Open/Close Redis Client ภายใน Function
- หาก Function มี Memory อย่างน้อย 1GB ให้ Open/Close Redis Client ภายใน Function