redis+lua实现原子库存增减

背景

高并发场景下,设计订单系统时,常遇到写写/读写/写读并发冲突导致的脏读,容易引起的超卖问题。通常方案:使用锁包裹非原子语句集来保证并发读写一致性,但锁的存在牺牲了其并发性能。以订单扣减为例:
伪代码:

## 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为例:

  1. 因为其单线程命令队列的方式,提供了多样的原子语句:incr dcry hincrby eval…
  2. 还提供了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的情况,可自由发挥

猜你喜欢

转载自blog.csdn.net/qq_22211217/article/details/119879888