背景
高并发场景下,设计订单系统时,常遇到写写/读写/写读并发冲突导致的脏读,容易引起的超卖问题。通常方案:使用锁包裹非原子语句集来保证并发读写一致性,但锁的存在牺牲了其并发性能。以订单扣减为例:
伪代码:
## step1 查询库存
do get
## step2 库存判断
do check and pass
## step3 当前库存-1
do sub
## step4 创建订单
do others
并发情况下:
## step1 a 查询库存 1
do get
## step1 b 查询库存 1(a还未扣减导致b幻读)
do get
## step2 a 库存判断 1>0
do check and pass
## step2 b 库存判断 1>0
do check and pass
## step3 a 当前库存 1-1=0
do sub
## step3 b 当前库存 1-1=0
do sub
## step3 a 创建订单 +1
do others
## step3 b 创建订单 +1(超卖)
do others
lock做法:
do try lock
## step1 查询库存
do get
## step2 库存判断
do check and pass
## step3 当前库存-1
do sub
## step4 创建订单
do others
do try unlock
并行变为串行且try lock需要进行自旋重试
do try lock
## step1 a 查询库存 1
do check and pass
## step2 a 库存判断 1>0
do check and pass
## step3 a 当前库存 1-1=0
do sub
## step3 a 创建订单 +1
do others
do try unlock
do try lock
## step1 a 查询库存 0
do check and pass
## step2 a 库存判断 1<0
do check and fail
do try unlock
分析
为何需要锁?
为了实现并发非原子操作的冲突。
如果使用原子操作是否是可以去掉锁?
原子操作为那几步?
## step1 查询库存
do get
## step2 库存判断
do check and pass
## step3 当前库存-1
do sub
如何实现原子操作?
以redis为例:
- 因为其单线程命令队列的方式,提供了多样的原子语句:incr dcry hincrby eval…
- 还提供了MULTI+EXEC实现多条语句的原子执行(保证多客户端顺序)
本文主要使用eval+lua方式,结合1.2特性实现原子操作:
实现
go实现
go-redis为例:
lua脚本:
结合hincrby扩张hincrby自增达到上限则返回0 否则返回1,则扣减库存逻辑可以转换为库存发送
local num = tonumber(redis.call('HGET', KEYS[1], ARGV[1]))
if num then
if num + tonumber(ARGV[2]) > tonumber(ARGV[3]) then
return 0
end
end
redis.call('hincrby', KEYS[1], ARGV[1], ARGV[2])
return 1
含义:
KEYS ARGV 为 EVAL的参数
KEYS[1] table
ARGV[1] field
ARGV[2] num
ARGV[3] limit
# 获取当前发放库存
local num = tonumber(redis.call('HGET', KEYS[1], ARGV[1]))
# 无发放库存则不做上限判断
if num then
# 判断库存是否达上限
if num + tonumber(ARGV[2]) > tonumber(ARGV[3]) then
return 0
end
end
# 发放库存原子增加
redis.call('hincrby', KEYS[1], ARGV[1], ARGV[2])
return 1
go测试代码
redis.go
package main
import (
"github.com/garyburd/redigo/redis"
"strings"
"time"
)
const (
defaultHost = "127.0.0.1:6379" //默认连接
defaultDb = "0" //默认db
defaultTTL = 300 //默认ttl 5分钟
defaultTimeout = 60 //默认读写超时时间 1分钟
defaultMaxIdle = 60 //默认最大空闲数 60pqs
defaultMaxActive = 1000 //默认限制最大连接数 1000
defaultIdleTimeout = 3 * time.Second //默认获取连接超时
defaultWait = true //允许等待
defaultSplit = ":"
)
type RedisUpstream struct {
redisHost string
redisDB string
redisPwd string
timeout int64
initFlag bool
redisPool *redis.Pool
}
func GetRedis() (*RedisUpstream,error) {
r := &RedisUpstream{}
r.redisHost = defaultHost
r.redisDB = defaultDb
r.timeout = defaultTTL
r.redisPwd = "root"
r.redisPool = &redis.Pool{
Dial: r.redisConnect,
MaxIdle: defaultMaxIdle,
MaxActive: defaultMaxActive,
IdleTimeout: defaultIdleTimeout,
Wait: defaultWait,
}
_, err := r.redisConnect()
if err != nil {
return r,err
}
r.initFlag = true
return r,nil
}
//连接redis
func (r *RedisUpstream) redisConnect() (redis.Conn, error) {
c, err := redis.Dial("tcp", r.redisHost)
if err != nil {
return nil, err
}
if len(r.redisPwd) != 0 {
_, err = c.Do("AUTH", r.redisPwd)
if err != nil {
return nil, err
}
}
_, err = c.Do("SELECT", r.redisDB)
if err != nil {
return nil, err
}
redis.DialConnectTimeout(time.Duration(defaultTimeout) * time.Second)
redis.DialReadTimeout(time.Duration(defaultTimeout) * time.Second)
redis.DialWriteTimeout(time.Duration(defaultTimeout) * time.Second)
return c, nil
}
redis_test.go
package main
import (
"bytes"
"errors"
"github.com/garyburd/redigo/redis"
"runtime"
"strconv"
"sync"
"testing"
)
func HincrbyAndLimit(table string, field string, num int64, limit int64) (int, error) {
red, err := GetRedis()
if err != nil {
return 0, err
}
con := red.redisPool.Get()
defer con.Close()
script := redis.NewScript(1, `local num = tonumber(redis.call('HGET', KEYS[1], ARGV[1]))
if num then
if num + tonumber(ARGV[2]) > tonumber(ARGV[3]) then
return 0
end
end
redis.call('hincrby', KEYS[1], ARGV[1], ARGV[2])
return 1`)
flag, err := redis.Int(script.Do(con, table, field, num, limit))
if err != nil {
return 0, err
}
return flag, nil
}
//获取当前协程id
func GetGoroutineID() uint64 {
b := make([]byte, 64)
runtime.Stack(b, false)
b = bytes.TrimPrefix(b, []byte("goroutine "))
b = b[:bytes.IndexByte(b, ' ')]
n, _ := strconv.ParseUint(string(b), 10, 64)
return n
}
func TestHincrbyAndLimit(t *testing.T) {
getRedis, err := GetRedis()
if err != nil {
t.Error(err)
}
con := getRedis.redisPool.Get()
defer con.Close()
//清除测试数据
con.Do("DEL","table")
//100并发
wg := &sync.WaitGroup{}
wg.Add(100)
for i := 0; i < 100; i++ {
go func() {
defer wg.Done()
//库存100 每次发送1
flag, err := HincrbyAndLimit("table", "field1", 1, 100)
if err != nil {
t.Error(err)
}
if flag==0 {
t.Error(errors.New("操作失败"))
}
t.Logf("扣减成功 go-id = %d",GetGoroutineID())
}()
}
wg.Wait()
//发送101库存
flag, err := HincrbyAndLimit("table", "field1", 1, 100)
if err != nil {
t.Error(err)
}
if flag==1 {
t.Error(errors.New("超卖"))
}
t.Logf("扣减失败 go-id = %d",GetGoroutineID())
}
func BenchmarkHincrbyAndLimit(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := HincrbyAndLimit("table", "field1", 1, 3000)
if err != nil {
b.Error(err)
}
}
}
单元测试结果:通过
=== RUN TestHincrbyAndLimit
--- PASS: TestHincrbyAndLimit (0.14s)
redis_test.go:67: 扣减成功 go-id = 18
redis_test.go:67: 扣减成功 go-id = 8
redis_test.go:67: 扣减成功 go-id = 14
redis_test.go:67: 扣减成功 go-id = 9
redis_test.go:67: 扣减成功 go-id = 12
redis_test.go:67: 扣减成功 go-id = 66
redis_test.go:67: 扣减成功 go-id = 16
redis_test.go:67: 扣减成功 go-id = 22
redis_test.go:67: 扣减成功 go-id = 61
redis_test.go:67: 扣减成功 go-id = 81
redis_test.go:67: 扣减成功 go-id = 64
redis_test.go:67: 扣减成功 go-id = 32
redis_test.go:67: 扣减成功 go-id = 79
redis_test.go:67: 扣减成功 go-id = 11
redis_test.go:67: 扣减成功 go-id = 26
redis_test.go:67: 扣减成功 go-id = 76
redis_test.go:67: 扣减成功 go-id = 90
redis_test.go:67: 扣减成功 go-id = 80
redis_test.go:67: 扣减成功 go-id = 62
redis_test.go:67: 扣减成功 go-id = 55
redis_test.go:67: 扣减成功 go-id = 50
redis_test.go:67: 扣减成功 go-id = 59
redis_test.go:67: 扣减成功 go-id = 28
redis_test.go:67: 扣减成功 go-id = 60
redis_test.go:67: 扣减成功 go-id = 70
redis_test.go:67: 扣减成功 go-id = 94
redis_test.go:67: 扣减成功 go-id = 56
redis_test.go:67: 扣减成功 go-id = 67
redis_test.go:67: 扣减成功 go-id = 107
redis_test.go:67: 扣减成功 go-id = 40
redis_test.go:67: 扣减成功 go-id = 82
redis_test.go:67: 扣减成功 go-id = 38
redis_test.go:67: 扣减成功 go-id = 93
redis_test.go:67: 扣减成功 go-id = 21
redis_test.go:67: 扣减成功 go-id = 84
redis_test.go:67: 扣减成功 go-id = 23
redis_test.go:67: 扣减成功 go-id = 54
redis_test.go:67: 扣减成功 go-id = 92
redis_test.go:67: 扣减成功 go-id = 83
redis_test.go:67: 扣减成功 go-id = 34
redis_test.go:67: 扣减成功 go-id = 108
redis_test.go:67: 扣减成功 go-id = 41
redis_test.go:67: 扣减成功 go-id = 10
redis_test.go:67: 扣减成功 go-id = 74
redis_test.go:67: 扣减成功 go-id = 98
redis_test.go:67: 扣减成功 go-id = 37
redis_test.go:67: 扣减成功 go-id = 88
redis_test.go:67: 扣减成功 go-id = 69
redis_test.go:67: 扣减成功 go-id = 77
redis_test.go:67: 扣减成功 go-id = 86
redis_test.go:67: 扣减成功 go-id = 19
redis_test.go:67: 扣减成功 go-id = 103
redis_test.go:67: 扣减成功 go-id = 58
redis_test.go:67: 扣减成功 go-id = 42
redis_test.go:67: 扣减成功 go-id = 89
redis_test.go:67: 扣减成功 go-id = 63
redis_test.go:67: 扣减成功 go-id = 15
redis_test.go:67: 扣减成功 go-id = 36
redis_test.go:67: 扣减成功 go-id = 51
redis_test.go:67: 扣减成功 go-id = 71
redis_test.go:67: 扣减成功 go-id = 87
redis_test.go:67: 扣减成功 go-id = 45
redis_test.go:67: 扣减成功 go-id = 96
redis_test.go:67: 扣减成功 go-id = 33
redis_test.go:67: 扣减成功 go-id = 72
redis_test.go:67: 扣减成功 go-id = 25
redis_test.go:67: 扣减成功 go-id = 73
redis_test.go:67: 扣减成功 go-id = 75
redis_test.go:67: 扣减成功 go-id = 105
redis_test.go:67: 扣减成功 go-id = 106
redis_test.go:67: 扣减成功 go-id = 99
redis_test.go:67: 扣减成功 go-id = 35
redis_test.go:67: 扣减成功 go-id = 101
redis_test.go:67: 扣减成功 go-id = 100
redis_test.go:67: 扣减成功 go-id = 53
redis_test.go:67: 扣减成功 go-id = 52
redis_test.go:67: 扣减成功 go-id = 102
redis_test.go:67: 扣减成功 go-id = 49
redis_test.go:67: 扣减成功 go-id = 13
redis_test.go:67: 扣减成功 go-id = 20
redis_test.go:67: 扣减成功 go-id = 31
redis_test.go:67: 扣减成功 go-id = 29
redis_test.go:67: 扣减成功 go-id = 47
redis_test.go:67: 扣减成功 go-id = 65
redis_test.go:67: 扣减成功 go-id = 57
redis_test.go:67: 扣减成功 go-id = 95
redis_test.go:67: 扣减成功 go-id = 24
redis_test.go:67: 扣减成功 go-id = 48
redis_test.go:67: 扣减成功 go-id = 43
redis_test.go:67: 扣减成功 go-id = 30
redis_test.go:67: 扣减成功 go-id = 91
redis_test.go:67: 扣减成功 go-id = 85
redis_test.go:67: 扣减成功 go-id = 78
redis_test.go:67: 扣减成功 go-id = 46
redis_test.go:67: 扣减成功 go-id = 44
redis_test.go:67: 扣减成功 go-id = 104
redis_test.go:67: 扣减成功 go-id = 39
redis_test.go:67: 扣减成功 go-id = 97
redis_test.go:67: 扣减成功 go-id = 68
redis_test.go:67: 扣减成功 go-id = 27
redis_test.go:78: 扣减失败 go-id = 7
PASS
Process finished with exit code 0
性能测试结果: 平均9ms左右
goos: darwin
goarch: amd64
pkg: zyj.com/redis
BenchmarkHincrbyAndLimit-12 144 8408440 ns/op
PASS
Process finished with exit code 0
goos: darwin
goarch: amd64
pkg: zyj.com/redis
BenchmarkHincrbyAndLimit-12 112 10505595 ns/op
PASS
Process finished with exit code 0
goos: darwin
goarch: amd64
pkg: zyj.com/redis
BenchmarkHincrbyAndLimit-12 133 8067327 ns/op
PASS
Process finished with exit code 0
总结
- redis+lua 实现原子操作,原理为利用底层eval借助MULTI+EXEC方式保证了lua脚本的原子性
- 使用lua脚本代替lock方式,最小代价解决并发库存读写冲突问题
- lua脚本未判断扣减至0的情况,可自由发挥