همین اول بگم، همه چیز به دیزاین و طراحی سیستم برمیگرده. اما بهتره ابزارهایی که داریم رو بیشتر بشناسیم تا توی طراحیها با تسلط بیشتری بهشون نگاه کنیم. توی این مقاله میخوام با Pipeline ها در ردیس آشنا شیم، یکم با Go کد بزنیم و بنچمارک بگیریم.
توی سایت ردیس چنین تعریفی اومده که "بعضی وقت ها یک سری درخواست میخوای به سرور بفرستی که برای مثال کامند دوم برای اجراش نیاز به پاسخ کامند اول نداره ، در نهایت جواب همه کامند ها رو با هم میگیری" برای مثال من میخوام به ردیس بگم که یک کاربر اضافه کن (با Map)، وقتی اضافه شد نام کاربریش رو عوض کن، حالا برو کاربر دوم رو اضافه کن و بعد نام کاربریش رو عوض کن.
اگه به صورت معمولی به ازای اضافه کردن کاربر، تغییر نامکاربریش ، اضافه کردن کاربر دوم و تغییر نامکاربریش ۴ تا درخواست به ردیس میفرستم. اگه پینگ سرورم به ردیس ۵۰ میکروثانیه باشه درخواست دوم منتظر اتمام درخواست اول (۵۰ میکروثانیه) میمونه و بعد درخواست خودش رو میفرسته. همینطور درخواست سوم منتظر دوم میمونه و درخواست چهارم منتظر درخواست سوم. پس این وسط ۲۰۰ میکروثانیه رو هدر دادیم. این عدد زیاد بزرگ نیست. اما اگه هزار تا درخواست بخوایم بدیم چند میکروثانیه رو هدر دادیم؟ ۵۰*۱۰۰۰ که میشه ۵۰ میلیثانیه. حالا ما اگه در لحظه هزار درخواست به ردیس داشته باشیم چه اتفاقی میوفته؟ ۵۰ میلیثانیه پاسخ هامون کندتر میشه.
تا الان از جنبه RTT یا همون Round-trip delay به قضیه نگاه کردیم. اما فقط این نیست! به ازای هر درخواست که به ردیس زده میشه یک syscall از نوع read یا write داریم. اگه همه درخواستهای Read یا Writeمون رو باهم بفرستیم جای هزار syscall فقط یک syscall از اون نوع داریم که پرفورمنس رو خیلی بهتر میکنه و فشار روی سرور کمتر میشه.
یک سیستم وبلاگی رو درنظر بگیرید. در صفحه اول قراره ۱۰۰ پست برتر هرساعت رو نشون بدیم. میخوایم یک طراحی ساده برای این سیستم داشته باشیم طوری که بدونیم هم Responsive هست ( اگه تغییری روی عکس /عنوان/خلاصه یکی از پست ها بوجود اومد توی یک بازه زمانی قابل قبولی تغییرات اعمال بشه ). همینطور برای ما خیلی مهمه سریع باشه و درخواست های زیادی رو بتونه هندل کنه. (مثلا هر پاد ۱۰ هزار درخواست درثانیه رو هندل کنه خیلی خوب میشه)
ما میخوایم لایهی محتوا رو تبدیل به ۳ زیرلایه کنیم. لایه اول که دیتاها رو توی دیتابیس ذخیره میکنه. لایه دوم کش دیتاهای توی دیتابیس که هر ساعت تغییرات رو از دیتابیس میخونه. لایه سوم کش توی مموری هر پاد که با TTL ده دقیقه دیتایی که از ردیس گرفته رو توی مموی ذخیره میکنه.
توی این مقاله ارتباط بین لایه کش ردیس و دیتابیس رو توضیح دادم که چطور ۸۰۰ ریکوئست برثانیه رو برسونیم ۱۴هزار تا. توی این تمرکزمون روی ارتباط بین لایه کش In-Memory و Redis هست.
ردیس هم کش In-Memory هست. پس چرا دارم با یک کش In-Memory دیگه مقایسش میکنم؟
من توی این مقاله و مقاله قبلی در مورد این موضوع صحبت کردم که بهتره جای اینکه تک تک درخواست بزنیم که اطلاعات فلان پست رو بده، توی یک بازه ایی همه پست ها رو با هم بگیریم. اما یه جا آخر توی سیستم باید من بگم اطلاعات پست ۲۳۱ رو بهم بده؟ بهترین جا برای این کار کش In-Memory هر پادمون هست. چون دیگه RTT ایی درکار نیست. دیتا توی خود مموری سیستم هست و تک تک میتونیم به مموری سیستم درخواست بزنیم.
اینطور در نظر بگیرید. روی لبه سیستم ما ۴ تا سرور داریم که درخواست های کاربر ها رو میگیرن. این ۴ تا سرور مموریشون با مموری کش Redis یکی نیست. چون Redisرو توی یک کلاستر سرور جدا داریم. حالا وقتی ۱۰ تا درخواست به یکی از این ۴ تا سرور رفت جای اینکه به ازای هر درخواست یکی یکی به ردیس Requestبزنن که اطلاعات فلان پست رو بده، نگه میداره و اون ۱۰ تا درخواست رو با هم میفرسته. وقتی از ردیس Responseرو گرفت اون رو توی مموری خودش ۱۰ دقیقه نگه میداره و طی اون ۱۰ دقیقه هر درخواست جدیدی اومد یکی یکی توی مموری خودش چک میکنه. اینطور سیستم ما کاملا Scalable هست.
من روی سیستم یک سرور ردیس بالا اوردم. قراره این سناریو رو با زبون Go پیادهسازی کنیم. یک دیتابیس Redis داریم که با استفاده از دیتاتایپ Hash اطلاعات کاربر ها رو ذخیره میکنیم. ( مثلا یک کاربر میتونه نام، ایمیل و سن داشته باشه). نیاز داریم نام و ایمیل ۱۰۰۰ تا کاربر رو داشته باشیم. توی حالت اول یکی یکی ریکوئست میزنیم(میگیم نام و ایمیل فلان کاربر رو بده) و توی حالت دوم ۱۰۰۰ تا رو با هم میفرستیم سمت ردیس.
کد اول، ساختن افراد توی دیتابیس:
func Create(rdb *redis.Client) { for i := 0; i < 10000; i++ { rdb.HMSet(context.Background(), "user:"+strconv.Itoa(i), "user", "Mohammad", "age", 21, "email", "example@gmail.com") } }
یک نمونه کامند تولیدیش برای ردیس:
HMSET user:12 user Mohammad age 21 email example@gmail.com
کد دوم، دریافت تکی اطلاعات کاربرها
func FindNormal(rdb *redis.Client) { for i := 1000; i < 2000; i++ { rdb.HMGet(context.Background(), "user:"+strconv.Itoa(i), "user", "email") } }
نمونه کامندش:
HMGET user:12 user email
کد سوم، دریافت با استفاده از پایپلاین
func FindBatch(rdb *redis.Client) { p := rdb.Pipeline() for i := 1000; i < 2000; i++ { p.HMGet(context.Background(), "user:"+strconv.Itoa(i), "user", "email") } p.Exec(context.Background()) }
خب، وقت اجرا و بنچمارک گرفته: (گرفتن اطلاعات هزار کاربر)
Pipeline took 2.481666ms Single requests took 31.444584ms
خروجی همونطوری بود که انتظارش رو داشتیم. با پایپلاین ۲ میلیثانیه طول کشید درحالی که اگر درخواستهای تکی بزنیم ۳۰ میلیثانیه!
ما که این همه راه اومدیم. توی ردیس یک میلیون رکورد میریزم و ۱۰ هزار تا همزمان بگیریم ببینیم چطور میشه. ( که پیشنیاز سیستمی که طراحی میخواستیم بکنیم رو برآورده کرده باشیم)
Pipeline took 22.51976ms Single requests took 288.796202ms
بنظر منطقی میاد که حتما پایپلاین ها رو برای طراحی در نظر بگیریم D:
اگه Go کار میکنید احتمالا داکیومنت زیادی برای پایپلاین ها توی Go پیدا نمیکنید. این نمونه کد رو هم میذارم نشون بدم چطور اطلاعات رو میشه دریافت کرد و توی تابع خروجی داد:
func FindBatchUsers(rdb *redis.Client, ctx context.Context, fields []string, userIds []int) (map[int]map[string]string, error) { p := rdb.Pipeline() resps := make([]*redis.SliceCmd, 0, len(userIds)) for _, id := range userIds { resps = append(resps, p.HMGet(ctx, "user:"+strconv.Itoa(id), fields...)) } _, err := p.Exec(ctx) if err != nil { return nil, err } ret := make(map[int]map[string]string) for i, id := range userIds { resp := resps[i] if resp.Err() != nil { ret[id] = nil } else { ret[id] = map[string]string{} for j, field := range fields { ret[id][field] = resp.Val()[j].(string) } } } return ret, nil }