غلبه بر مشکل گله رعد و برق در برنامه های Go با الگوی مدار شکن

تصور کنید در ساعات شلوغی صبح در حال حرکت به سمت دفتر هستید. به دلیل ترافیک سنگین، بزرگراه ها مملو از وسایل نقلیه کند حرکت بود. هنگامی که چراغ راهنمایی سبز می شود، هزاران وسیله نقلیه در یک زمان سعی می کنند رانندگی کنند و حتی ترافیک بدتری ایجاد می کنند. این وضعیت مشابه چیزی است که در دنیای محاسبات «مشکل گله رعد و برق» نامیده می شود.
مشکل گله رعد چیست؟
Thundering Herd اصطلاحی در محاسبات توزیعشده و سیستمهای مقیاس بزرگ است که به وضعیتی اطلاق میشود که در آن چندین فرآیند یا رشته تلاش میکنند تا یک منبع را به طور همزمان پس از یک تاخیر یا رویداد خاص به دست آورند.
این مشکل اغلب در سناریوهایی رخ می دهد که تعداد زیادی از فرآیندها در انتظار یک منبع محدود هستند، مانند پایگاه داده، حافظه پنهان مشترک، سرویس شبکه یا سرویس راه دور.
هنگامی که این منبع در نهایت در دسترس قرار گرفت، تمام فرآیندهای انتظار سعی می کنند به طور همزمان به آن دسترسی پیدا کنند، که باعث افزایش فعالیت می شود که می تواند سرویس را بیش از حد بارگذاری کند یا حتی مختل کند.
اثرات Thundering Herd می تواند باعث کاهش عملکرد، تاخیر یا حتی خرابی سرویس شود. برای غلبه بر این مشکل، مکانیسمهایی مانند زمانبندی، throttling یا استفاده از الگوهای طراحی مانند Circuit Breaker مورد نیاز است تا تعداد درخواستها را به یکباره به منابع محدود محدود کند.
مثال موردی
فرض کنید یک برنامه تحت وب دارید که داده های کاربر را از پایگاه داده MySQL واکشی می کند و آن را در کش Redis ذخیره می کند تا سرعت دسترسی را بهبود بخشد. هنگامی که داده های کاربر در حافظه پنهان یافت نمی شود، بسیاری از درخواست ها سعی می کنند داده ها را به طور همزمان از پایگاه داده بازیابی کنند، که باعث بار بیش از حد بر روی پایگاه داده و کاهش عملکرد کلی برنامه می شود.
به کد زیر نگاه کنید:
package main
import (
"database/sql"
"encoding/json"
"fmt"
"github.com/go-redis/redis"
_ "github.com/go-sql-driver/mysql"
"log"
"math/rand"
"time"
)
type User struct {
Id int
Username string
Email string
}
type Config struct {
redisClient *redis.Client
dbClient *sql.DB
}
func NewUser(dbClient *sql.DB, redisClient *redis.Client) *Config {
return &Config{
dbClient: dbClient,
redisClient: redisClient,
}
}
func (e *Config) getDataFromMysql(username string) (*User, error) {
row := e.dbClient.QueryRow("SELECT * FROM users WHERE username = ?", username)
user := &User{}
err := row.Scan(&user.Id, &user.Username, &user.Email)
if err != nil {
return nil, err
}
err = e.saveToRedis(username, user)
if err != nil {
return nil, err
}
return user, nil
}
func (e *Config) getDataFromRedis(username string) (*User, error) {
val, err := e.redisClient.Get(username).Result()
if err != nil {
log.Printf("failed to get redis with key [%s], err: %v", username, err)
log.Println("get data from mysql")
user, err := e.getDataFromMysql(username)
if err != nil {
return nil, err
}
return user, nil
}
user := &User{}
err = json.Unmarshal([]byte(val), &user)
if err != nil {
return nil, err
}
return user, nil
}
func (e *Config) saveToRedis(key string, data *User) error {
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
ttl := 10 * time.Second
err = e.redisClient.Set(key, jsonData, ttl).Err()
if err != nil {
return err
}
return nil
}
func main() {
mysqlConn, err := sql.Open("mysql", "root:root@tcp(localhost:3306)/employees")
if err != nil {
panic(err)
}
redisConn := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
})
client := NewUser(mysqlConn, redisConn)
username := "jhon_doe"
// Simulate get cache miss as asyncronous
for i := 0; i < 100; i++ {
go func() {
time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
val, err := client.getDataFromRedis(username)
if err != nil {
fmt.Println(err)
} else {
fmt.Printf("Got value: %v\n", val)
}
}()
}
select {}
}
حالا بیایید کد بالا را اجرا کنیم و در میانه کار سعی می کنیم پایگاه داده MySQL را که در اختیار داریم خاموش کنیم.
در فرآیند فوق میتوانیم ببینیم، زمانی که دادهها در کش Redis یافت نمیشوند، همه درخواستها سعی میکنند دادهها را به طور همزمان از پایگاه داده MySQL بازیابی کنند. تصور کنید وقتی می خواهید داده ها را از MySQL بازیابی کنید، درخواست های زیادی وجود دارد. این می تواند مشکل گله رعد و برق ایجاد کند و پایگاه داده را با درخواست های بیش از حد بارگیری کند و همچنین هنگامی که پایگاه داده بازیابی می شود بلافاصله درخواست های زیادی دریافت می کند که همچنان ارسال می شوند.
الگوی مدار شکن
راه حل های زیادی وجود دارد که می توان برای مقابله با این مشکل گله رعد و برق استفاده کرد. یکی از راه ها استفاده است الگوی مدار شکن. قبل از اینکه به حل مشکل در کد بالا بپردازیم، ابتدا بیایید دریابیم که الگوی قطع کننده مدار چیست. در دنیای نرم افزار، Circuit Breaker Pattern یک الگوی طراحی است که برای جلوگیری از خرابی سیستم به دلیل عدم پاسخگویی یا خرابی اجزای خارجی استفاده می شود. این الگو شبیه نحوه عملکرد یک مدارشکن الکتریکی در خانه است.
در زندگی روزمره، ما اغلب از وسایل برقی در خانه مانند اتو، توستر یا ماشین لباسشویی استفاده می کنیم. اگر از تعداد زیادی لوازم خانگی به طور همزمان استفاده شود، این می تواند بار زیادی را بر روی سیستم الکتریکی خانه شما وارد کند. برای جلوگیری از آسیب یا آتش سوزی، یک دستگاه ایمنی الکتریکی به نام “Circuit Breaker” نصب می شود.
به همین ترتیب، در یک سیستم توزیعشده، اجزایی مانند پایگاههای داده، سرویسهای وب یا سایر سیستمهای خارجی میتوانند به دلایل مختلف مانند بارگذاری بیش از حد، مشکلات شبکه یا خطاهای داخلی از کار بیفتند یا پاسخگو نباشند. اگر برنامه شما به طور مداوم سعی می کند به اجزای غیر پاسخگو دسترسی پیدا کند، این می تواند منجر به استفاده بیش از حد از منابع، انباشته شدن درخواست ها و در نهایت شکست کامل سیستم شود.
Circuit Breaker Pattern این مشکل را با نظارت بر موفقیت و شکست درخواستها به اجزای خارجی برطرف میکند. اگر در یک بازه زمانی معین خرابی های زیادی رخ دهد، Circuit Breaker “باز می شود” و به طور موقت از درخواست های جدید به اجزای خارجی جلوگیری می کند. تا زمانی که Circuit Breaker باز است، درخواستهای جدید رد میشوند یا به مکانیزم بازگشتی امن، مانند بازگرداندن دادههای ذخیرهشده در حافظه پنهان یا پاسخ پیشفرض هدایت میشوند.
پس از تأخیر زمانی معین، Circuit Breaker دوباره سعی می کند خود را ببندد و درخواست های جدید را به اجزای خارجی اجازه دهد. در صورت موفقیت آمیز بودن درخواست، مدار شکن بسته می ماند.
با این حال، اگر خرابی دوباره رخ دهد، Circuit Breaker دوباره باز می شود و چرخه تکرار می شود. با استفاده از الگوی Circuit Breaker، برنامه شما در برابر خرابی اجزای خارجی مقاومتر میشود، از مصرف بیش از حد منابع جلوگیری میکند و به اجزای خارجی زمان میدهد تا قبل از پذیرش درخواستهای جدید بازیابی شوند. این امر ثبات کلی، قابلیت اطمینان و استحکام سیستم را بهبود می بخشد.
پیاده سازی راه حل ها با الگوی مدار شکن
اکنون از الگوی مدار شکن برای غلبه بر مشکل گله رعد و برق بالا استفاده می کنیم. Circuit Breaker به عنوان محافظی عمل می کند که دسترسی به یک منبع (در این مورد، پایگاه داده MySQL) را در صورت وقوع یک سری از خرابی ها محدود می کند. اگر در یک بازه زمانی معین خرابی های زیادی رخ دهد، Circuit Breaker “باز می شود” و از درخواست های جدید از منبع ناموفق جلوگیری می کند. این هم کد کامل:
package main
import (
"database/sql"
"encoding/json"
"fmt"
"github.com/go-redis/redis"
_ "github.com/go-sql-driver/mysql"
"log"
"math/rand"
"time"
)
type User struct {
Id int
Username string
Email string
}
type Config struct {
redisClient *redis.Client
dbClient *sql.DB
circuit *CircuitBreaker
}
type CircuitBreaker struct {
failureThreshold int
consecutiveFailure int
open bool
openedAt time.Time
}
const circuitBreakerResetTimeout = 2 * time.Second
func NewCircuitBreaker(failureThreshold int) *CircuitBreaker {
return &CircuitBreaker{
failureThreshold: failureThreshold,
consecutiveFailure: 0,
open: false,
}
}
func NewUser(dbClient *sql.DB, redisClient *redis.Client, failureThreshold int) *Config {
return &Config{
dbClient: dbClient,
redisClient: redisClient,
circuit: NewCircuitBreaker(failureThreshold),
}
}
func (cb *CircuitBreaker) IsOpen() bool {
if cb.open {
// Check last time opened
if time.Since(cb.openedAt) >= circuitBreakerResetTimeout {
cb.open = false
cb.consecutiveFailure = 0
log.Println("Circuit Breaker closed")
} else {
return true
}
}
return false
}
func (cb *CircuitBreaker) IncrementConsecutiveFailure() {
cb.consecutiveFailure++
if cb.consecutiveFailure >= cb.failureThreshold {
cb.open = true
cb.openedAt = time.Now()
log.Println("Circuit Breaker opened")
}
}
func (e *Config) getDataFromMysql(username string) (*User, error) {
row := e.dbClient.QueryRow("SELECT * FROM users WHERE username = ?", username)
user := &User{}
err := row.Scan(&user.Id, &user.Username, &user.Email)
if err != nil {
return nil, err
}
err = e.saveToRedis(username, user)
if err != nil {
return nil, err
}
return user, nil
}
func (e *Config) getDataFromRedis(username string) (*User, error) {
if e.circuit.IsOpen() {
return nil, fmt.Errorf("circuit breaker is open")
}
val, err := e.redisClient.Get(username).Result()
if err != nil {
log.Printf("failed to get redis with key [%s], err: %v", username, err)
log.Println("get data from mysql")
user, err := e.getDataFromMysql(username)
if err != nil {
e.circuit.IncrementConsecutiveFailure()
return nil, err
}
return user, nil
}
user := &User{}
err = json.Unmarshal([]byte(val), &user)
if err != nil {
return nil, err
}
return user, nil
}
func (e *Config) saveToRedis(key string, data *User) error {
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
ttl := 2 * time.Millisecond
err = e.redisClient.Set(key, jsonData, ttl).Err()
if err != nil {
return err
}
return nil
}
func main() {
mysqlConn, err := sql.Open("mysql", "root:mysqlsecret@tcp(localhost:3306)/employees")
if err != nil {
panic(err)
}
redisConn := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
})
failureThreshold := 3
client := NewUser(mysqlConn, redisConn, failureThreshold)
username := "user20001"
// Simulate get cache miss as asyncronous
for i := 0; i < 100; i++ {
go func() {
time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
val, err := client.getDataFromRedis(username)
if err != nil {
fmt.Println(err)
} else {
fmt.Printf("Got value: %v\n", val)
}
}()
}
select {}
}
اکنون هر گونه تغییر در کد بالا را مشاهده خواهیم کرد. اولین کاری که انجام میدهیم این است که Truct را در آن تغییر میدهیم Config
با افزودن یک فیلد مداری که یک شی CircuitBreaker را در خود جای می دهد، ایجاد کنید:
type Config struct {
redisClient *redis.Client
dbClient *sql.DB
circuit *CircuitBreaker
}
با اضافه شدن این فیلد مدار، شی Config اکنون یک شی CircuitBreaker نیز دارد که برای اعمال الگوی Circuit Breaker استفاده خواهد شد. بعد، یک ساختار جدید CircuitBreaker و یک تابع NewCircuitBreaker اضافه شده است:
type CircuitBreaker struct {
failureThreshold int
consecutiveFailure int
open bool
openedAt time.Time
}
ساختار CircuitBreaker دارای سه فیلد است:
- شکست آستانه: حداکثر تعداد خرابی های مجاز قبل از باز شدن مدار شکن.
- ConsecutiveFailure: تعداد خرابی های متوالی رخ داده است.
- باز: وضعیت باز بودن یا نبودن Circuit Breaker در حال حاضر.
- OpenAt: زمان باز شدن مدار شکن را ضبط می کند.
func NewCircuitBreaker(failureThreshold int) *CircuitBreaker {
return &CircuitBreaker{
failureThreshold: failureThreshold,
consecutiveFailure: 0,
open: false,
}
}
تابع NewCircuitBreaker سازنده ای برای ایجاد یک شیء CircuitBreaker با شکست داده شده، مقدار متوالی شکست روی 0، و مجموعه باز روی نادرست (مدار شکن در ابتدا در حالت بسته است).
در نهایت، تغییری در تابع NewUser برای ثبت قطع کننده مدار وجود دارد تا بتوان از آن در تابعی که مقداردهی اولیه می کند استفاده کرد. Config
هدف – شی:
func NewUser(dbClient *sql.DB, redisClient *redis.Client, failureThreshold int) *Config {
return &Config{
dbClient: dbClient,
redisClient: redisClient,
circuit: NewCircuitBreaker(failureThreshold),
}
}
در کد بالا ما هم اضافه می کنیم Is Open
تابع:
func (cb *CircuitBreaker) IsOpen() bool {
if cb.open {
// Check last time opened
if time.Since(cb.openedAt) >= circuitBreakerResetTimeout {
cb.open = false
cb.consecutiveFailure = 0
log.Println("Circuit Breaker closed")
} else {
return true
}
}
return false
}
تابع IsOpen ابتدا بررسی می کند که آیا مدار شکن باز است (cb.open). اگر باز باشد، عملکرد بررسی میکند که آیا مدت زمانی که از باز شدن مدار شکن گذشته است (time.Since(cb.openedAt)) به مدار BreakerResetTimeout رسیده یا بیشتر شده است.
اگر زمان سپری شده به محدودیت زمان انتظار رسیده باشد، Circuit Breaker دوباره بسته می شود (cb.open = false) و ConsecutiveFailure به 0 برمی گردد.
این عملکرد همچنین یک پیغام گزارش “Circuit Breaker بسته” را ضبط می کند تا به شما اطلاع دهد که مدار شکن دوباره بسته شده است. اگر زمان سپری شده به محدودیت زمان انتظار نرسیده باشد، این تابع به درستی برمی گردد تا نشان دهد که مدار شکن هنوز باز است.
اگر Circuit Breaker باز نباشد، تابع false برمی گردد.
و فراموش نکنید که ثابت را اضافه کنید circuitBreakerResetTimeout
برای ارائه یک زمان بررسی خودکار برای باز شدن مجدد کلید مدار در IsOpen
عملکرد بالا
const circuitBreakerResetTimeout = 2 * time.Second
و سپس IncrementConsecutiveFailure
تابع نیز روشی از ساختار CircuitBreaker است. این تابع مسئول شمارش تعداد خرابی های متوالی است که هنگام تلاش برای دسترسی به یک منبع خارجی رخ داده است.
func (cb *CircuitBreaker) IncrementConsecutiveFailure() {
cb.consecutiveFailure++
if cb.consecutiveFailure >= cb.failureThreshold {
cb.open = true
cb.openedAt = time.Now()
log.Println("Circuit Breaker opened")
}
}
ابتدا این تابع مقدار را افزایش می دهد consecutiveFailure
مقدار (تعداد خرابی های متوالی) با اضافه کردن آن با 1. سپس، تابع بررسی می کند که آیا consecutiveFailure
رسیده یا از آن فراتر رفته است failureThreshold
(حداکثر حد خرابی مشخص شده).
اگر شکست متوالی به آستانه شکست برسد یا از آن فراتر رود، Circuit Breaker به حالت باز (باز = درست) تغییر می کند و زمان باز شدن مدار شکن را برای مقایسه در تابع IsOpen بالا ثبت می کند. هنگامی که Circuit Breaker باز میشود، تمام درخواستهای جدید به منابع خارجی رد میشوند یا به مکانیزم بازگشتی امن هدایت میشوند. علاوه بر این، این تابع همچنین یک پیغام گزارش “Circuit Breaker open” را ضبط می کند تا به شما اطلاع دهد که مدار شکن باز شده است.
پس از ایجاد تمام تنظیمات و توابع لازم، اکنون پیاده سازی را برای رسیدگی به مشکل گله رعد و برق انجام خواهیم داد. ابتدا در تابع main مقدار اولیه را تعیین می کنیم failureThreshold
، که در این حالت حد 3 برابر شکست است.
func main() {
// Others code ...
failureThreshold := 3
client := NewUser(mysqlConn, redisConn, failureThreshold)
// Others code ...
}
آخرین کاری که در پیاده سازی مدارشکن انجام می دهیم این است getDataFromRedis
عملکرد، بیایید در مورد آن بحث کنیم.
func (e *Config) getDataFromRedis(username string) (*User, error) {
if e.circuit.IsOpen() {
return nil, fmt.Errorf("circuit breaker is open")
}
// others code ...
اول، در getDataFromRedis
تابعی را اضافه می کنیم تا بررسی کنیم که آیا Circuit Breaker باز است یا نه با فراخوانی e.circuit.IsOpen(). اگر Circuit Breaker باز باشد، تابع بلافاصله خطای “Circuit Breaker is open” را برمی گرداند و روند بازیابی داده ها از Redis یا MySQL را ادامه نمی دهد. این کار برای جلوگیری از درخواستهای جدید به منابع خارجی در زمانی که Circuit Breaker باز است انجام میشود.
اگر Circuit Breaker باز نباشد، تابع سعی می کند با استفاده از Redis داده ها را بازیابی کند e.redisClient.Get(username).Result()
. اگر هنگام بازیابی داده ها از Redis خطایی رخ دهد، تابع خطا را ثبت می کند و سعی می کند با فراخوانی داده ها را از MySQL بازیابی کند. e.getDataFromMysql(username)
.
// others code ...
val, err := e.redisClient.Get(username).Result()
if err != nil {
log.Printf("failed to get redis with key [%s], err: %v", username, err)
log.Println("get data from mysql")
user, err := e.getDataFromMysql(username)
if err != nil {
e.circuit.IncrementConsecutiveFailure() // <-- call function IncrementConsecutiveFailure
return nil, err
}
return user, nil
}
// others code ...
اگر هنگام بازیابی داده ها از MySQL خطایی رخ دهد، تابع e.circuit.IncrementConsecutiveFailure() را فراخوانی می کند تا تعداد خرابی های متوالی در Circuit Breaker را افزایش دهد. اگر تعداد خرابی های متوالی به یک حد معین (failureThreshold) برسد، Circuit Breaker باز می شود. پس از آن، تابع خطا و صفر را به عنوان مقادیر کاربر برمی گرداند. همانطور که در مثال زیر زمانی که کد بالا را اجرا می کنیم و پایگاه داده MySQL ما از بین می رود.
پس از چندین شکست متوالی در بازیابی داده ها از MySQL، Circuit Breaker باز می شود و دیگر تلاشی برای بازیابی داده ها از MySQL وجود نخواهد داشت. در این مرحله تمام درخواستهای دریافتی رد میشوند و یا یک بازگشت مناسب برای کاربر ارائه میکنیم. و Circuit Breaker دوباره تا زمان محدودی که در ثابت مشخص کردیم بسته می شود circuitBreakerResetTimeout
.
با اجرای الگوی قطع کننده مدار، تابع getDataFromRedis از درخواست های جدید به منابع خارجی (MySQL) در زمانی که مدار شکن باز است جلوگیری می کند. علاوه بر این، عملکرد هنگام بازیابی داده ها از MySQL، خرابی ها را نیز کنترل می کند و در صورت رسیدن به حد معینی از خرابی، Circuit Breaker را باز می کند. این به جلوگیری از مشکلات گله رعد و برق کمک می کند و به منابع خارجی زمان می دهد تا قبل از پذیرش مجدد درخواست های جدید بازیابی شوند.
مشکل گله تندر یک مشکل رایج در سیستم های توزیع شده است و می تواند باعث کاهش قابل توجه عملکرد شود. با استفاده از الگوهای طراحی مانند Circuit Breaker، میتوانید بر این مشکلات غلبه کنید و از اجرای روان و کارآمد برنامههای خود اطمینان حاصل کنید. همیشه هنگام طراحی سیستم هایی که به منابع محدود وابسته هستند، مشکل بالقوه گله رعد و برق را در نظر بگیرید و راه حل های مناسب را برای جلوگیری از آن پیاده سازی کنید.