Go 实战 :抽奖系统核心技术点实现

Go 实战 :抽奖系统核心技术点实现

考验系统并发能力最常见的无非是秒杀、抽奖等业务场景。我们不妨以一个支持高并发的抽奖系统的设计和实现为例来进行 Go 语言的实战学习。

要实现一个抽奖系统,我们首先需要一个比较合理的抽奖算法,保证每个人抽到奖品的概率是一致的,同时又需要避免奖品被很快抽完的情况。所以本文的第一部分会用三种思路来讲解如何去实现一个合理的抽奖算法。

要具备高并发的能力,Go 的 http 组件是支持高并发能力的(底层由 Go 的协程支持),但是数据存储层也需要支持高并发、高吞吐量的存储系统,mysql 肯定是支持不了的,所以这里采用 Redis 实现。所以本文第二部分会介绍如何使用 Go 去操作 Redis。

抽奖系统中,高并发请求下,很容易出现两个线程或者协程同时对共享内存中的数据进行更改的问题。所以为了保证数据的原子性,我们对数据的操作都是以 CAS 的方式。Redis 中可以实现对数据的原子增减,但是无法实现数据的翻倍或者其他改变。为了支持所有场景,同时为了学习知识,这里我们借用 Redis 的事务来实现 Redis 的 CAS。所以第三部分主要介绍 Go 操作 Redis 来实现 cas

整个抽奖系统概览如下,本章 chat 主要阐述其中的部分实现核心点,包括:使用 Go 实现抽奖算法,Go 操作 Redis ,以及通过 Redis 实现 cas

Go 实现抽奖算法

抽奖算法是决定一个抽奖系统是否成功的关键要素之一

我们先来假设一下场景: 假如目前公司需要举行一个活动,一共有A、B、C 三种奖品。奖品数量分别为 200、2000、5000、需要在 6.1 ~ 6.3 这三种奖品发完。

最容易想到的一种做法是对 奖品 A、B、C 分别设置一个概率,每个用户根据设置好的概率进行派奖。但仔细想想这样会存在一个问题:每个活动我们对于抽奖的人数预测都是未知的。当抽奖人数多或者概率很高时,奖品很容易在很短的时间内被发完。当抽奖人数少或者概率很低时,奖品很有可能到最后还有很多都没发完… 所以完全凭借设置抽奖概率来抽奖是不太靠谱的。

为了避免上述情况,于是我们开始思考,既然抽奖的人数是未知的,我们无法控制抽奖人数,那能不能换个思路,我们来控制奖品的发放时间呢?在上面假设的场景中,我们分别有 A、B、C 三种奖品 200、2000、5000 个。需要在 6.1 日 0:00 ~ 6.3 日 24:00 点这段期间进行抽奖。也就是说一共有 7200 奖品,在 72 个小时内抽完。假如我们把这 72 个小时按照时间片进行分割,是不是就能防止奖品被抽完或者过剩太多的情况了?

由公式

ΔΔ t = endTime−startTimeawardAmountendTime−startTimeawardAmount 计算可以得出时间间隔为 1/100 小时,也就是 36s 随机发一个奖品

此时,在 ΔΔ t 时间内 A 被抽中的概率是 200 / (200 + 2000 + 5000) = 1/36 , B 被抽中的概率是 2000 / (200 + 2000 + 5000) = 10/36 ,C 被抽中的概率是 5000 / (200 + 2000 + 5000) = 25 /36

算法的基本思路确定后,怎么去实现呢?

我们将奖品分成 7200 份之后,每一个奖品都会有一个抽奖时间段,在这个时间段内奖品会在某一个时间点进行发放。假如用户抽奖的时间点在这个时间点之前,因为奖品没有发放,所以无法中奖。在这个时间点之后,则有概率获得奖品,这个概率可以由用户进行配置。假如有一个用户抽中了奖品,则其他用户在这个时间段内再也无法抽中奖品了。

思路一

第一种思路就是通过结构化数据库去实现,例如 mysql

这里需要建立三张表

  • 奖品信息表 : award_info

    • 奖品记录表 : award_record

    活动开始前,需要根据奖品信息表 award_info 里面的数据初始化奖池表 award_pool。用户抽奖时通过以下 sql 进行抽奖

select id from award_pool where award_release_time <= now() and award_release_num > 0 limit 1 

如果能够查询到数据,则执行更新 update 语句,否则抽奖失败

    update award_pool set award_release_num = award_release_num - 1 where id = #{id} and award_release_num > 0

如果 update 执行成功,用户中奖,写中奖纪录到 award_record 表,否则抽奖失败

思路二

在思路一中,在抽奖之前,我们对需要初始化 awardpool 这张表来进行发奖。抽奖时,需要执行一条 select 语句 和 一条 update 语句。这种做法,在奖品量非常多时,初始化 awardpool 这张表会浪费很多时间,在用户并发请求量非常大,select 语句也会消耗很多性能。于是有了第二种思路,不进行奖品初始化操作,而是通过实时计算的方式进行抽奖。

计算奖品释放时间点 :

    具体实现:

func GetAward(awardBatches []AwardBatch) (AwardBatch, error) {

    startTime , _ := ParseStringToTime(conf.Award.StartTime)
    endTime , _ := ParseStringToTime(conf.Award.EndTime)

    fmt.Println("startTime : ", startTime)
    fmt.Println("endTime : ", endTime)

    award , err := RandomGetAwardBatch(awardBatches)
    if err != nil {
        return AwardBatch{}, err
    }

    totalAmount := award.GetTotalAmount()
    totalBalance := award.GetTotalBalance()
    updateTime := award.GetUpdateTime()

    detaTime := (endTime - startTime) / totalAmount
    currentTime := time.Now().Unix()

    random := rand.New(rand.NewSource(updateTime))
    // 计算下一个奖品的释放时间
    releaseTime := startTime + (totalAmount - totalBalance) * detaTime +  int64(random.Int()) % detaTime

    fmt.Println("releaseTime : " + fmt.Sprintf("%d", releaseTime) + " currentTime : " + fmt.Sprintf("%d",currentTime))

    if (currentTime < releaseTime) {
        return AwardBatch{} , errors.New(" currentTime not in award release period ")
    }

    return award, nil
}

func RandomGetAwardBatch(awardBatches []AwardBatch) ( AwardBatch , error ) {

    if len(awardBatches) == 0 {
        return AwardBatch{} , errors.New("empty param awardBatches")
    }

    weight := int64(0)

    for _, awardBatch := range awardBatches {
        weight += awardBatch.GetTotalBalance()
    }

    if weight == 0 {
        return AwardBatch{}, errors.New("weight is 0")
    }

    random := rand.New(rand.NewSource(weight))

    num := random.Int63n(weight)

    for _, awardBatch := range awardBatches {
        num -= awardBatch.GetTotalBalance()

        if num < 0 {
            return awardBatch , nil
        }
    }

    return AwardBatch{}, errors.New("randomGetAwardBatch should shoot at least one batch")

}

思路三

思路二相对于思路一来说,节省了初始化 award_pool 奖池 和 select 操作的时间。但还是会存在一个问题,由于采用的是关系型数据库 mysql 存储,所以能够支持并发数是有限的。当抽奖用户量很大时,数据库肯定是无法支撑的。所以为了能够支持高并发,我们需要摈弃 mysql ,采取高性能、支持高并发量的 k/v 式数据库。这里我们使用 Redis

为了达到思路二类似的效果,我们设计两种数据结构 :

奖品种类、奖品总数和中奖时间采用 zset 进行存储,zset 中 value 为奖品名称,value 的分值 score 为奖品的中奖时间。通过 ZRANGE award_info 0 -1 WITHSCORES 即可得到所有奖品以及每种奖品的更新抽奖时间

奖品剩余数量采用哈希表进行存储,这样通过 HGETALL 即可查询到所有奖品以及奖品剩余数量

基于这种思路,我们就有了第三种代码实现如下(这里只贴部分核心代码,完整代码请参考 GitHub)

    // 随机抽取一个奖品
    func RandomGetAwardBatch() \*AwardBatch {

        conn := RedisWrapper.GetConn()

        if conn == nil {
            log.Println("conn is nil")
            return nil
        }

        defer conn.Close()

        retMap, err := Redis.Int64Map(conn.Do("HGETALL", getAwardBalanceKey()))

        if err != nil || retMap == nil {
            log.Println("Redis HGETALL award error", err)
            return nil
        }

        totalBalance := int64(0)
        for _, value := range retMap {
            totalBalance += value
        }

        fmt.Println("retMap : ", retMap)

        if totalBalance == 0 {
            log.Println("total balance is 0")
            return nil
        }

        log.Println("totalBalance :", totalBalance)

        awardBatches := GetAllAwardBatch()

        for index , awardBatch := range awardBatches {
            awardBatches[index].totalBalance = retMap[awardBatch.GetName()]
        }

        log.Println("awardBatches :", awardBatches)

        random := rand.New(rand.NewSource(totalBalance))

        num := random.Int63n(totalBalance)

        for _ , awardBatch := range awardBatches {

            // 奖品已经抽完
            if awardBatch.GetTotalBalance() <= 0 {
                log.Println("奖品已经抽完")
                continue
            }

            num = num - awardBatch.GetTotalBalance()
            if num < 0 {
                return &awardBatch
            }
        }

        return nil
    }

    func GetAwardBatch() *AwardBatch {

        awardBatch := RandomGetAwardBatch()

        if awardBatch == nil {
            log.Println("sorry, you didn't win the prize.")
            return nil
        }

        // 判断是否到达奖品释放时间点
        startTime , _ := ParseStringToTime(Conf.Award.StartTime)
        endTime , _ := ParseStringToTime(Conf.Award.EndTime)
        totalAmount := awardBatch.totalAmount
        totalBalance := awardBatch.totalBalance
        lastUpdateTime := awardBatch.GetUpdateTime()
        random := rand.New(rand.NewSource(lastUpdateTime))

        detaTime := (endTime - startTime) / awardBatch.totalAmount

        // 计算下一个奖品释放的时间点
        releaseTime := startTime + (totalAmount - totalBalance) * detaTime + int64(random.Int()) % detaTime

        log.Println("relaseTime : ", time.Unix(releaseTime, 0).Format("2006-01-02 15:04:05"))

        if time.Now().Unix() < releaseTime {
            // 未到达奖品释放的时间点,即不中奖
            log.Println("sorry, you didn't win the prize")
            return nil
        }

        return awardBatch
    }

以上就是三种实现抽奖算法的思路,以及抽奖算法的 Go 实现。对比思路一、二、三,最终我们决定使用思路三进行实现。在思路上中,牵涉到对 Redis 的操作,所以这里我们需要先了解使用 Go 如何进行 Redis 操作。

Go 操作 Redis

Redis 是一个开源的高性能 key-value 存储系统,被广泛运用于高并发读写请求的业务场景。对 Redis 不了解的同学可以先去熟悉一下:https://Redis.io/documentation

那么 Go 是如何支持 Redis 操作的呢? 这里就不得不提到 rediGo , GitHub 地址为 GitHub.com/Gomodule/rediGo 下面可能会用一个高并发抽奖系统的场景来具体讲解 Redis 的操作和 cas 的实现

rediGo是 Go 语言的一个 Redis 客户端实现

安装 rediGo

     Go get GitHub.com/garyburd/rediGo/Redis

对一个第三方包的了解首先从 GitHub 上开始,这里我们进入rediGo GitHub 官方 api https://Godoc.org/GitHub.com/Gomodule/rediGo/Redis 通过Godoc 文档我们很快可以快速上手

建立连接

首先来看官方 Godoc 介绍

    Connections

    The Conn interface is the primary interface for working with Redis.Applications create connections by calling the 
    Dial,DialWithTimeout or NewConn functions. In the future, functions will be added for creating sharded and other types of connections.

通过 Godoc 可知,Redis 建立连接有三种方式,Dial、DialWithTimeout、NewConn。

我们只需采用其中一种方式就可以快速建立连接 :

    package Redis import ( "fmt" "GitHub.com/garyburd/rediGo/Redis" "log" ) // 返回一个 Redis 连接 func GetConn() Redis.Conn{
        conn , err := Redis.Dial("tcp", fmt.Sprintf("%s:%d", "127.0.0.1", 6379))

        if err != nil {
            log.Println("connect Redis error ", err)
            return nil
        }

        return conn
    }

需要注意的是,上述方法返回了一个 Redis 连接,取得这个连接之后,一定要记得关闭。如下 :

        conn := getConn()
        if conn == nil {
            log.Println("conn is nil")
        }
        defer conn.Close()

执行 Redis 命令

通过 Godoc 得知,我们有两种方式可以执行 Redis 命令

方式一 : 通过 Conn 接口的 Do(commandName string, args …interface{}) 方法

    Do(commandName string, args ...interface{}) (reply interface{}, err error)
    例如 :

    n, err := conn.Do("APPEND", "key", "value")
    在抽奖系统中,我们需要通过读取 Redis 来获取所有奖品信息,代码如下:

    // 这里封装了一个方法 返回一个 Redis Conn
    conn := RedisWrapper.GetConn()

    if conn == nil {
        log.Println("conn is nil")
        return awardBatches
    }
    // 注意关闭 Redis conn
    defer conn.Close()

    // 获取存放奖品信息的 Redis key
    awardInfoKey := getAwardInfoKey()
    // 调用 Do 方法读取对应的奖品信息 ,通过 Redis.Values 方法转化为一个数组
    values , err := Redis.Values(conn.Do("ZRANGE",awardInfoKey,0,-1,"WITHSCORES"))

方式二 :通过 pipeline

Redis pipeline 可以一次性发送多条命令并在执行完后一次性将结果返回,pipeline 的底层实现是队列,所以是 FIFO,可以保证队列中命令执行的顺序性。

实现一个 pipeline 需要下面三个方法:

    // 往队列中添加指令
    Send(commandName string, args ...interface{}) error
    // 一次性发送指令到服务端
    Flush() error
    // 接收服务端返回的数据
    Receive() (reply interface{}, err error)

例如在抽奖系统中,我们需要初始化奖品信息,此时我们使用的是 pipeline ,代码如下 :

    func InitAwardPool() {

        // 这里封装了一个方法 返回一个 Redis Conn
        conn := RedisWrapper.GetConn()

        if conn == nil {
            log.Println("conn is nil")
            return
        }

        defer conn.Close()

        conn.Send("ZADD", getAwardInfoKey(), time.Now().Unix(), "A")
        conn.Send("ZADD", getAwardInfoKey(), time.Now().Unix(), "B")
        conn.Send("ZADD", getAwardInfoKey(), time.Now().Unix(), "C")

        conn.Send("HSET", getAwardBalanceKey(), "A", Conf.AwardMap["A"])
        conn.Send("HSET", getAwardBalanceKey(), "B", Conf.AwardMap["B"])
        conn.Send("HSET", getAwardBalanceKey(), "C", Conf.AwardMap["C"])

        conn.Flush()

        for i := 0 ; i < 6; i++ {
            // 接收数据,这里不需要处理接收的数据,所以用 \_
            _ , err := conn.Receive()

            if err != nil {
                log.Println("conn send error", err)
            }
        }
    }


实现 CAS

在高并发抽奖系统的场景中,假设我们有一个对当前奖品余量减少 1 的操作。 对奖品余量数的更改,我们希望应该是原子操作。更改包括两个操作:

a. 读出 Redis 余量数 b. 设置余量数为当前余量数 - 1

这里包括一读一写操作。 由于 Redis 是单进程,所以在进行单独读操作和单独写操作都是线程安全的。但是读 + 写就不一定安全了。

这里举个例子:

假如 balance 为 200, 有 A、B 两个客户端请求,都需要进行 balance - 1 操作, 如下图 :

我们希望的结果是 A、B 都按照 读 —— 写 —— 读 —— 写的顺序执行,最终的结果是 198 。但由于 Redis 是单条指令执行,所以有可能 A 读 —— B 读 —— A 写 —— B 写 ,这样的执行顺序,A、B 读取出来的 balance 都是 200 ,A 执行写入 balance - 1 = 199 , B 同时执行 balance - 1 = 199 , 所以最终 balance = 199 , 而不是我们预期的 198。

要避免上述情况,我们希望读 + 写是一个原子操作。也就是说一定是 读 —— 写 —— 读 —— 写 这种顺序,读和写操作之间不能插入其他的读或者写操作。这其实就是一种 CAS 能力。但是 Redis 是没有提供这种能力的,这就需要我们自己基于 Redis 去实现 CAS

1. 什么是 CAS ?

CAS 即 CompareAndSwap (比较并替换)的缩写

CAS 需要有3个操作数:内存地址 V,旧的-size-adjust:none;-webkit-font-smoothing:antialiased;box-sizing:border-box;margin:0px 0px 1.1em;outline:0px;">2. Redis 事务

要了解 Redis CAS 的实现,首先要了解以下 Redis 的事务机制。

事务一般有三个步骤 : begin、commit、rollback 。 begin 指示事务的开始,commit 指示事务的提交,rollback 指示事务的回滚

在 Redis 中对应的命令是 :multi、exec、discard。 multi 指示事务的开始,exec 指示事务的执行,discard 指示事务的丢弃

这里可以参考一下 Redis 的 文档 , 地址为 : http://Redisdoc.com/transaction/index.html

通过阅读文档可知, Redis 事务并没有实现原子性,即使事务中有某个/某些命令在执行时产生了错误, 事务中的其他命令仍然会继续执行。

例如 :

可以看到,在执行 incr award 这条指令的时候报错了,但是后面的 set book Golang 这条指令仍然能正确执行。

3. watch 命令

在了解了 Redis 的事务机制之后,我们发现 Redis 事务机制并不支持原子性。所以通过 Redis 事务还是无法实现 CAS ,这里就需要引入 watch 命令了。

使用 watch 命令可以监控 Redis 的某个 key,假如在 Redis 事务执行 exec 过程中,这个 key 发生了变动,则该事务不会被执行。

为了演示 watch 命令,这里我们新启两个 Redis 客户端,如下:

客户端 1 :

hing:antialiased;box-sizing:border-box;margin:0px 0px 1.1em;outline:0px;">客户端 2 :

在客户端 1 执行完 watch watchedKey 和 multi 命令后,客户端 2 执行 set 命令,我们可以看到此时客户端 1 的 set 命令会执行失败。说明在事务执行过程中,假如 watch 的 key 有更改,则关于该事务会执行失败。

4. Redis CAS 实现

通过 Redis 事务和 watch 命令,我们可以通过 Redis 去实现 CAS,在抽奖系统中,我们也应用了 CAS,相关代码如下 :

    // 更新 lastUpdateTime 和 balance, 如果更新不成功,视为抽奖失败
    conn := RedisWrapper.GetConn()

    if conn == return nil
    }

    defer conn.Close()

    conn.Send("WATCH", getAwardBalanceKey())
    conn.Send("MULTI")
    conn.Send("ZADD",getAwardInfoKey(),time.Now().Unix(), awardBatch.GetName())
    conn.Send("HSET",getAwardBalanceKey(), awardBatch.GetName(), awardBatch.totalBalance - 1)
    conn.Send("EXEC")

    err := conn.Flush()
    if err != nil {
        log.Println("Redis error, " , err)
        return nil
    }


抽中奖品之后,我们需要对剩余奖品数进行更新操作,上面代码通过 watch 命令去监视了保存奖品余量的哈希表,假如对奖品剩余数进行更新操作的时候,有其他协程修改奖品剩余数,则事务执行失败。此抽奖过程没有完成,视为用户没有中奖。

结语

至此,我们已经实现了一个支持高并发的抽奖系统的核心代码,要实现一个抽奖系统,除了抽奖算法、使用 Redis 存储数据以及 CAS 之外,还需要会使用 Go 的基本语法,了解 Go 的 http 模块的使用、log 的使用、第三方包的使用、配置文件的解析等。其实总的代码并不复杂,代码量也不多,所以其他部分读者可以自行去研究一下。欢迎读者圈交流


欢迎关注我的公众号,回复关键字“Golang” ,将会有大礼相送!!! 祝各位面试成功!!!

发布了112 篇原创文章 · 获赞 2 · 访问量 5553

猜你喜欢

转载自blog.csdn.net/weixin_41818794/article/details/104394533
今日推荐