首先, 我们简单理解下乐观锁
和悲观锁
的概念。
悲观锁
顾名思义, 很悲观; 认为谁都可能对数据进行修改, 所以每次修改数据时都需要进行数据上锁。
乐观锁
顾名思义, 很乐观; 认为谁都可以对数据进行修改, 所以每次修改数据时都不会对数据进行上锁。但是数据修改提交时, 数据库会根据版本记录机制
在同一时间只能修改成功一个。
理解了这两个基础原理后, 其实我们就可以大概清楚
乐观锁
和悲观锁
其实都可以实现秒杀, 解决商品超卖的问题。但是悲观锁
每次修改数据时都会对数据进行上锁, 比如setnx
; 而乐观锁
只需要判断数据版本是否发生变更, 如果没变更就修改成功, 反之就失败。 从性能上来讲, 显然乐观锁
更好。
我认为 Redis 乐观锁, 其实就是 WATCH 监视
和 TRANSACTION 事务
的结合体。
接下来, 我就来具体说说 Redis 乐观锁实现高并发下的秒杀活动
乐观锁
大多数是基于数据版本(VERSION)的记录机制实现的。即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 "version" 字段来实现读取出数据时,将此版本号一同读出。之后更新时,对此版本号加1。此时,将提交数据的版本号与数据库表对应记录的当前版本号进行比对,如果提交的数据版本号大于数据库当前版本号,则予以更新,否则认为是过期数据。Redis 中可以使用watch
命令会监视给定的 key,当exec
时候如果监视的 key 从调用 watch
后发生过变化,则整个事务会失败。也可以调用watch
多次监视多个 key。这样就可以对指定的 key 加乐观锁了。注意watch
的 key 是对整个连接有效的,事务也一样。如果连接断开,监视和事务都会被自动清除。当然了exec
,discard
,unwatch
命令都会清除连接中的所有监视。
事务
Redis 中的事务(Transaction)是一组命令的集合。事务同命令一样都是 Redis 最小的执行单位
,一个事务中的命令要么都执行,要么都不执行。Redis 事务的实现需要用到 MULTI
和 EXEC
两个命令,事务开始的时候先向 Redis 服务器发送 MULTI
命令,然后依次发送需要在本次事务中处理的命令,最后再发送 EXEC
命令表示事务命令结束。Redis 的事务是下面4个命令来实现的。
multi
开启 Redis 的事务,置客户端为事务态。exec
提交事务,执行从multi
到此命令前的命令队列,置客户端为非事务态。discard
取消事务,置客户端为非事务态。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 已抢到的用户数据
评论一下?