侧边栏壁纸
  • 累计撰写 47 篇文章
  • 累计收到 0 条评论

Redis 乐观锁解决高并发秒杀活动超卖问题

2022-7-17 / 0 评论 / 240 阅读
温馨提示:
本文最后更新于 2022-7-17,已超过半年没有更新,若内容或图片失效,请留言反馈。

首先, 我们简单理解下乐观锁悲观锁的概念。

悲观锁
    顾名思义, 很悲观; 认为谁都可能对数据进行修改, 所以每次修改数据时都需要进行数据上锁。

乐观锁
    顾名思义, 很乐观; 认为谁都可以对数据进行修改, 所以每次修改数据时都不会对数据进行上锁。但是数据修改提交时, 数据库会根据版本记录机制 在同一时间只能修改成功一个。

理解了这两个基础原理后, 其实我们就可以大概清楚

乐观锁悲观锁其实都可以实现秒杀, 解决商品超卖的问题。但是悲观锁每次修改数据时都会对数据进行上锁, 比如setnx ; 而乐观锁 只需要判断数据版本是否发生变更, 如果没变更就修改成功, 反之就失败。 从性能上来讲, 显然乐观锁 更好。

我认为 Redis 乐观锁, 其实就是 WATCH 监视TRANSACTION 事务 的结合体。

接下来, 我就来具体说说 Redis 乐观锁实现高并发下的秒杀活动

乐观锁

大多数是基于数据版本(VERSION)的记录机制实现的。即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 "version" 字段来实现读取出数据时,将此版本号一同读出。之后更新时,对此版本号加1。此时,将提交数据的版本号与数据库表对应记录的当前版本号进行比对,如果提交的数据版本号大于数据库当前版本号,则予以更新,否则认为是过期数据。Redis 中可以使用watch命令会监视给定的 key,当exec时候如果监视的 key 从调用 watch 后发生过变化,则整个事务会失败。也可以调用watch多次监视多个 key。这样就可以对指定的 key 加乐观锁了。注意watch的 key 是对整个连接有效的,事务也一样。如果连接断开,监视和事务都会被自动清除。当然了execdiscardunwatch命令都会清除连接中的所有监视。

事务

Redis 中的事务(Transaction)是一组命令的集合。事务同命令一样都是 Redis 最小的执行单位,一个事务中的命令要么都执行,要么都不执行。Redis 事务的实现需要用到 MULTIEXEC 两个命令,事务开始的时候先向 Redis 服务器发送 MULTI 命令,然后依次发送需要在本次事务中处理的命令,最后再发送 EXEC 命令表示事务命令结束。Redis 的事务是下面4个命令来实现的。

  1. multi 开启 Redis 的事务,置客户端为事务态。
  2. exec 提交事务,执行从multi到此命令前的命令队列,置客户端为非事务态。
  3. discard 取消事务,置客户端为非事务态。
  4. watch 监视键值对,作用是 如果事务提交exec时发现监视的监视对发生变化,事务将被取消。

最后, 我用 Golang 代码简单实现基于乐观锁的秒杀, 其他语言也是一样的原理。

package main

import (
    "context"
    "fmt"
    redisv8 "github.com/go-redis/redis/v8"
    "github.com/raylin666/go-cache/redis"
    "strconv"
    "time"
)

/**

模拟场景:
    商品总数量为30个, 单次抢购的用户数量为50人, 每个人只能抢到1个商品。

**/

var (
    // 商品总数量
    total_goods = 30
    // 已被抢购的商品数量缓存 Key
    goods_key = "goods_numbers"
    // 已抢到商品的用户缓存 Key
    user_exists_key = "user_success"
)

func redisConnect() *redis.Client {
    var opts = new(redis.Options)
    opts.Network = "tcp"
    opts.Addr = "127.0.0.1:6379"
    opts.Password = "123456"
    opts.DB = 0
    client, err := redis.New(context.TODO(), opts)
    if err != nil {
        panic(err)
    }

    return client
}

// 抢购逻辑处理
func watchRushToBuy(client *redis.Client, userId int) error {
    return client.Watch(func(tx *redisv8.Tx) error {
        // 用户已经抢到商品, 不能重复抢购
        if tx.HExists(context.TODO(), user_exists_key, strconv.Itoa(userId)).Val() {
            fmt.Println(fmt.Sprintf("%d 用户已经抢到商品, 不能重复抢购", userId))
            return nil
        }

        vint, getErr := tx.Get(context.TODO(), goods_key).Int()
        if getErr != nil && getErr != redisv8.Nil {
            return nil
        }

        // 不能超过商品总数量
        if vint >= total_goods {
            fmt.Println(fmt.Sprintf("%d 用户您好, 记得下次早点来哦, 商品被抢完啦!", userId))
            return nil
        }

        // 抢购事务处理
        _, txErr := tx.TxPipelined(context.TODO(), func(pipeliner redisv8.Pipeliner) error {
            txCmd := pipeliner.Incr(context.TODO(), goods_key)
            if txCmd.Err() != nil {
                return txCmd.Err()
            }

            hsetCmd := pipeliner.HSet(context.TODO(), user_exists_key, userId, 1)
            // 设置用户领取成功状态失败, 回退商品数量
            if hsetCmd.Err() != nil {
                pipeliner.Decr(context.TODO(), goods_key)
                return hsetCmd.Err()
            }

            return nil
        })

        if txErr != nil {
            return txErr
        }

        // 抢购成功, 处理抢购后的逻辑流程 ...

        fmt.Println(fmt.Sprintf("恭喜 ID 为 %d 的用户抢到啦", userId))

        return nil
    }, goods_key)
}

func main()  {
    // 连接 Redis
    client := redisConnect()

    tryFunc := func(userId int) {
        // 未抢购成功的用户可重试抢购
        for j := 0; j < 3; j++ {
            // Redis 监听 Key 变化并开启事务处理
            watchErr := watchRushToBuy(client, userId)
            // 重试抢购
            if watchErr != nil {
                fmt.Println(fmt.Sprintf("%d 用户重试抢购失败 - %v", userId, watchErr))
                continue
            }

            return
        }
    }

    // 模拟用户的并发请求 (不论执行多少次, 或者并发数量加大, 都能正常抢购且不会超卖商品)
    for i := 0; i < 50; i++ {
        go func(i int) {
            // 抢购逻辑
            tryFunc(i)
        }(i)
    }

    time.Sleep(1 * time.Second)

    value, _ := client.Conn.Get(context.TODO(), goods_key).Int64()

    fmt.Println(value)
}


下图是部分抢购中、已抢购完、Redis 已抢到的用户数据

评论一下?

OωO
取消