基于 Golang 和 Redis 解決分布式系統(tǒng)下的并發(fā)問題
在分布式系統(tǒng)和數(shù)據(jù)庫的交互中,并發(fā)問題如同暗流般潛伏,稍有不慎就會(huì)掀起應(yīng)用的驚濤駭浪。試想一下,我們正在構(gòu)建一個(gè)股票交易平臺(tái),允許不同用戶同時(shí)購買公司股票。每個(gè)公司都有一定數(shù)量的可用股票,用戶只能在剩余股票充足的情況下進(jìn)行購買。
Golang 與 Redis 的解決方案:構(gòu)建穩(wěn)固的交易系統(tǒng)
為了解決這個(gè)問題,我們可以借助 Golang 和 Redis 的強(qiáng)大功能,構(gòu)建一個(gè)安全可靠的交易系統(tǒng)。
數(shù)據(jù)層搭建:GoRedis 助力高效交互
首先,我們使用 goredis 客戶端庫創(chuàng)建一個(gè)數(shù)據(jù)層(Repository),用于與 Redis 數(shù)據(jù)庫進(jìn)行交互:
type Repository struct {
client *redis.Client
}
var _ go_redis_concurrency.Repository = (*Repository)(nil)
func NewRepository(address, password string) Repository {
return Repository{
client: redis.NewClient(&redis.Options{
Addr: address,
Password: password,
}),
}
}
購買股票功能實(shí)現(xiàn):并發(fā)問題初現(xiàn)端倪
接下來,我們實(shí)現(xiàn) BuyShares 函數(shù),模擬用戶購買股票的操作:
func (r *Repository) BuyShares(ctx context.Context, userId, companyId string, numShares int, wg *sync.WaitGroup) error {
defer wg.Done()
companySharesKey := BuildCompanySharesKey(companyId)
// --- (1) ----
// 獲取當(dāng)前可用股票數(shù)量
currentShares, err := r.client.Get(ctx, companySharesKey).Int()
if err != nil {
fmt.Print(err.Error())
return err
}
// --- (2) ----
// 驗(yàn)證剩余股票是否充足
if currentShares < numShares {
fmt.Print("error: 公司剩余股票不足\n")
return errors.New("error: 公司剩余股票不足")
}
currentShares -= numShares
// --- (3) ----
// 更新公司可用股票數(shù)量
_, err = r.client.Set(ctx, companySharesKey, currentShares, 0).Result()
return err
}
該函數(shù)包含三個(gè)步驟:
- 獲取公司當(dāng)前可用股票數(shù)量。
- 驗(yàn)證剩余股票是否足以滿足用戶購買需求。
- 更新公司可用股票數(shù)量。
看似邏輯清晰,但當(dāng)多個(gè)用戶并發(fā)執(zhí)行 BuyShares 函數(shù)時(shí),問題就出現(xiàn)了。
模擬并發(fā)場景:問題暴露無遺
為了模擬并發(fā)場景,我們創(chuàng)建多個(gè) Goroutine 同時(shí)執(zhí)行 BuyShares 函數(shù):
const (
total_clients = 30
)
func main() {
// --- (1) ----
// 初始化 Repository
repository := redis.NewRepository(fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port), config.Redis.Pass)
// --- (2) ----
// 并發(fā)執(zhí)行 BuyShares 函數(shù)
companyId := "TestCompanySL"
var wg sync.WaitGroup
wg.Add(total_clients)
for idx := 1; idx <= total_clients; idx++ {
userId := fmt.Sprintf("user%d", idx)
go repository.BuyShares(context.Background(), userId, companyId, 100, &wg)
}
wg.Wait()
// --- (3) ----
// 獲取公司剩余股票數(shù)量
shares, err := repository.GetCompanyShares(context.Background(), companyId)
if err != nil {
panic(err)
}
fmt.Printf("公司 %s 剩余股票數(shù)量: %d\n", companyId, shares)
}
假設(shè)公司 TestCompanySL 初始擁有 1000 股可用股票,每個(gè)用戶購買 100 股。我們期望的結(jié)果是,只有 10 個(gè)用戶能夠成功購買股票,剩余用戶會(huì)因?yàn)楣善辈蛔愣盏藉e(cuò)誤信息。
然而,實(shí)際運(yùn)行結(jié)果卻出乎意料,公司剩余股票數(shù)量可能出現(xiàn)負(fù)數(shù),這意味著多個(gè)用戶在讀取可用股票數(shù)量時(shí),獲取到的是同一個(gè)未更新的值,導(dǎo)致最終結(jié)果出現(xiàn)偏差。
Redis 并發(fā)解決方案:精準(zhǔn)打擊,逐個(gè)擊破
為了解決上述并發(fā)問題,Redis 提供了多種解決方案,讓我們來一一剖析。
原子操作:簡單場景下的利器
原子操作能夠在不加鎖的情況下,保證對(duì)數(shù)據(jù)的修改操作具有原子性。在 Redis 中,可以使用 INCRBY 命令對(duì)指定 key 的值進(jìn)行原子遞增或遞減。
func (r *Repository) BuyShares(ctx context.Context, userId, companyId string, numShares int, wg *sync.WaitGroup) error {
defer wg.Done()
// ... (省略部分代碼) ...
// 使用 INCRBY 命令原子更新股票數(shù)量
_, err = r.client.IncrBy(ctx, companySharesKey, int64(-numShares)).Result()
return err
}
然而,在我們的股票交易場景中,原子操作并不能完全解決問題。因?yàn)樵诟鹿善睌?shù)量之前,還需要進(jìn)行剩余股票數(shù)量的驗(yàn)證。如果多個(gè)用戶同時(shí)讀取到相同的可用股票數(shù)量,即使使用原子操作更新,最終結(jié)果仍然可能出現(xiàn)錯(cuò)誤。
事務(wù):保證操作的原子性
Redis 事務(wù)可以將多個(gè)命令打包成一個(gè)原子操作,要么全部執(zhí)行成功,要么全部回滾。通過 MULTI、EXEC、DISCARD 和 WATCH 命令,可以實(shí)現(xiàn)對(duì)數(shù)據(jù)的原子性操作。
- MULTI:標(biāo)記事務(wù)塊的開始。
- EXEC:執(zhí)行事務(wù)塊中的所有命令。
- DISCARD:取消事務(wù)塊,放棄執(zhí)行所有命令。
- WATCH:監(jiān)視指定的 key,如果 key 在事務(wù)執(zhí)行之前被修改,則事務(wù)執(zhí)行失敗。
在我們的例子中,可以使用 WATCH 命令監(jiān)視公司可用股票數(shù)量的 key。如果 key 在事務(wù)執(zhí)行之前被修改,則說明有其他用戶并發(fā)修改了數(shù)據(jù),當(dāng)前事務(wù)執(zhí)行失敗,從而保證數(shù)據(jù)的一致性。
func (r *Repository) BuyShares(ctx context.Context, userId, companyId string, numShares int, wg *sync.WaitGroup) error {
defer wg.Done()
companySharesKey := BuildCompanySharesKey(companyId)
// 使用事務(wù)保證操作的原子性
tx := r.client.TxPipeline()
tx.Watch(ctx, companySharesKey)
// ... (省略部分代碼) ...
_, err = tx.Exec(ctx).Result()
return err
}
然而,在高并發(fā)場景下,使用事務(wù)可能會(huì)導(dǎo)致大量事務(wù)執(zhí)行失敗,影響系統(tǒng)性能。
LUA 腳本:將邏輯移至 Redis 服務(wù)端執(zhí)行
為了避免上述問題,可以借助 Redis 的 LUA 腳本功能,將業(yè)務(wù)邏輯移至 Redis 服務(wù)端執(zhí)行。LUA 腳本在 Redis 中以原子方式執(zhí)行,可以有效避免并發(fā)問題。
local sharesKey = KEYS[1]
local requestedShares = ARGV[1]
local currentShares = redis.call("GET", sharesKey)
if currentShares < requestedShares then
return {err = "error: 公司剩余股票不足"}
end
currentShares = currentShares - requestedShares
redis.call("SET", sharesKey, currentShares)
該 LUA 腳本實(shí)現(xiàn)了與 BuyShares 函數(shù)相同的邏輯,包括獲取可用股票數(shù)量、驗(yàn)證剩余股票是否充足以及更新股票數(shù)量。
在 Golang 中,可以使用 goredis 庫執(zhí)行 LUA 腳本:
var BuyShares = redis.NewScript(`
local sharesKey = KEYS[1]
local requestedShares = ARGV[1]
local currentShares = redis.call("GET", sharesKey)
if currentShares < requestedShares then
return {err = "error: 公司剩余股票不足"}
end
currentShares = currentShares - requestedShares
redis.call("SET", sharesKey, currentShares)
`)
func (r *Repository) BuyShares(ctx context.Context, userId, companyId string, numShares int, wg *sync.WaitGroup) error {
defer wg.Done()
keys := []string{BuildCompanySharesKey(companyId)}
err := BuyShares.Run(ctx, r.client, keys, numShares).Err()
if err != nil {
fmt.Println(err.Error())
}
return err
}
使用 LUA 腳本可以有效解決并發(fā)問題,并且性能優(yōu)于事務(wù)機(jī)制。
分布式鎖:靈活控制并發(fā)訪問
除了 LUA 腳本,還可以使用分布式鎖來控制對(duì)共享資源的并發(fā)訪問。Redis 提供了 SETNX 命令,可以實(shí)現(xiàn)簡單的分布式鎖機(jī)制。
在 Golang 中,可以使用 redigo 庫的 Lock 函數(shù)獲取分布式鎖:
func (r *Repository) BuyShares(ctx context.Context, userId, companyId string, numShares int, wg *sync.WaitGroup) error {
defer wg.Done()
companySharesKey := BuildCompanySharesKey(companyId)
// 獲取分布式鎖
lockKey := "lock:" + companySharesKey
lock, err := r.client.Lock(ctx, lockKey, redislock.Options{
RetryStrategy: redislock.ExponentialBackoff{
InitialDuration: time.Millisecond * 100,
MaxDuration: time.Second * 3,
},
})
if err != nil {
return fmt.Errorf("獲取分布式鎖失敗: %w", err)
}
defer lock.Unlock(ctx)
// ... (省略部分代碼) ...
return nil
}
使用分布式鎖可以靈活控制并發(fā)訪問,但需要謹(jǐn)慎處理鎖的釋放和超時(shí)問題,避免出現(xiàn)死鎖情況。
總結(jié)
Redis 提供了多種解決并發(fā)問題的方案,包括原子操作、事務(wù)、LUA 腳本和分布式鎖等。在實(shí)際應(yīng)用中,需要根據(jù)具體場景選擇合適的方案。
- 原子操作適用于簡單場景,例如計(jì)數(shù)器等。
- 事務(wù)可以保證多個(gè)操作的原子性,但性能較低。
- LUA 腳本可以將業(yè)務(wù)邏輯移至 Redis 服務(wù)端執(zhí)行,性能較高,但需要熟悉 LUA 語法。
- 分布式鎖可以靈活控制并發(fā)訪問,但需要謹(jǐn)慎處理鎖的釋放和超時(shí)問題。
希望本文能夠幫助你更好地理解和解決 Redis 并發(fā)問題,構(gòu)建更加穩(wěn)定可靠的分布式系統(tǒng)。