問題背景
最近業(yè)務(wù)上遇到這樣的場景,覺得很有代表性,所以拿來說一說。我們有一個獎品發(fā)放系統(tǒng),當(dāng)用戶申請獎品的時候,首先需要判斷用戶有沒有申請過獎品,如果沒有申請過,則去獎品總量扣除一個,然后再把用戶申請記錄寫回數(shù)據(jù)庫。
流程如下:
| 時序 | 事件 |
|---|---|
| t1 | 檢測用戶申請過獎品 |
| t2 | 獎品扣除 |
| t3 | 插入用戶申請記錄 |
| t4 | 返回申請成功 |
如果是正常的但線程執(zhí)行完全沒有問題,但是我們是并發(fā)的。所以很有可能會有好多事件都到t2導(dǎo)致多次申請,或者獎品多次扣除等情況。
解決方案
方案1:事務(wù)
最開始想到的就是數(shù)據(jù)庫的事務(wù)了,在t1開始前啟動事務(wù),t1查詢使用for update加鎖,其他事務(wù)繼續(xù)執(zhí)行相同條件的for update的時候則會block,直到上個事務(wù)執(zhí)行完成。這種方案很完美,但是只限于InnoDB,在tidb上行不通。tidb的事務(wù)是樂觀鎖,所以在t1的時候不會阻塞,那么可以在t3提交的時候conflict再回滾也可以啊。抱歉,tidb并不會產(chǎn)生插入沖突,因為tidb的鎖不支持gap lock和next-key lock,所以如果我們的獎品申請表沒有唯一索引沖突的話,完全可以插入(因為我某些設(shè)計,所以這里我們不能使用唯一索引)。
這個方案是行不通了,那么換種思路,既然tidb不支持for update block,那么我們是不是可以使用分布式鎖來解決。
方案2:分布式鎖
在檢測用戶有沒有申請過獎品之前,我們可以以用戶id為key申請分布式鎖(可以使用redis實現(xiàn)),申請成功則進行下一步,其他用戶阻塞等待鎖釋放。
具體實現(xiàn)如下:
package logic
import (
"errors"
"fmt"
"github.com/LucasGao67/blog001/dao"
"github.com/LucasGao67/blog001/util"
"sync"
)
var lock sync.Locker
func AppleAward(userId int64, remark string) error {
// 1. 去申請鎖
key := fmt.Sprintf("test:{%d}", userId)
//lock.()
util.LockBlock(key)
defer util.UnLodk(key)
// 2. 去查詢
info, err := dao.Award.FindOne(userId)
if err != nil {
msg := "查詢獎品申請信息失敗"
fmt.Printf("%s :%s\n", msg, err.Error())
return errors.New("查詢獎品申請信息失敗")
}
if info != nil {
msg := "已經(jīng)申請不能重復(fù)申請"
fmt.Println(msg)
return errors.New(msg)
}
// 3. 插入
if _, err := dao.Award.InsertOne(userId, remark); err != nil {
fmt.Println(err.Error())
return err
}
// 結(jié)束
return nil
}
測試
package logic
import (
"fmt"
"github.com/LucasGao67/blog001/util"
"sync"
"testing"
"time"
)
func init() {
util.SqlInit()
util.RedisInit()
}
func TestAppleAward(t *testing.T) {
// 插入1000條數(shù)據(jù)測試
st := time.Now()
numSucc := 0
numFail := 0
wg := sync.WaitGroup{}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func(i int) {
err := AppleAward(1000+int64(i), "test 001")
if err != nil {
numFail++
} else {
numSucc++
}
wg.Done()
}(i)
}
wg.Wait()
et := time.Now()
fmt.Printf("耗時: %ds\n", et.Unix()-st.Unix())
fmt.Printf("成功: %d\n", numSucc)
fmt.Printf("失?。?%d\n", numFail)
}
這樣如果userid都一樣是沒有問題的,但是一旦userid重復(fù)申請,都會阻塞到檢測userid狀態(tài)上,也就算上述的t1。所以這邊加鎖需要優(yōu)化下
方案三 分布式鎖優(yōu)化
采用最經(jīng)典的二次校驗,先查詢,滿足條件,加鎖,再校驗
func lockUtil(key string, exec func() error) (needUnlock bool, err error) {
if err := exec(); err != nil {
return false, err
}
if err := util.LockBlock(key); err != nil {
// 申請鎖失敗
return false, err
}
// 需要二次校驗
if err := exec(); err != nil {
return true, err
}
}
func AppleAwardV2(userId int64, remark string) error {
// 1. 去申請鎖
key := fmt.Sprintf("test:{%d}", userId)
needUnlock, err := lockUtil(key, func() error {
info, err := dao.Award.FindOne(userId)
if err != nil {
msg := "查詢獎品申請信息失敗"
fmt.Printf("%s :%s\n", msg, err.Error())
return errors.New("查詢獎品申請信息失敗")
}
if info != nil {
msg := "已經(jīng)申請不能重復(fù)申請"
fmt.Println(msg)
return errors.New(msg)
}
return nil
})
if needUnlock {
util.UnLodk(key)
}
if err != nil {
return err
}
// 3. 插入
if _, err := dao.Award.InsertOne(userId, remark); err != nil {
fmt.Println(err.Error())
return err
}
return nil
}