8--连接Redis实现库存预热防超卖
项目引入 Redis 的必要性
库存超卖问题真是面试必问了
1) 库存超卖问题的根本原因
在高并发场景下,假设库存只剩 1 件,同时有 1000 个请求到达:
请求A:SELECT stock FROM seckill WHERE id = 1 → stock = 1 ✓
请求B:SELECT stock FROM seckill WHERE id = 1 → stock = 1 ✓
请求C:SELECT stock FROM seckill WHERE id = 1 → stock = 1 ✓
...(1000个请求全部读到 stock = 1)
请求A:UPDATE seckill SET stock = stock - 1 → stock = 0 ✓
请求B:UPDATE seckill SET stock = stock - 1 → stock = -1 ✗ 超卖!
请求C:UPDATE seckill SET stock = stock - 1 → stock = -2 ✗ 超卖!
数据库层面无法保证并发扣减的原子性,因为读取和写入之间存在时间差。
2) Redis 的优势
| 维度 | MySQL | Redis |
|---|---|---|
| 并发模型 | 多线程竞争 | 单线程事件循环 |
| 原子操作 | 需加锁/事务 | 原生支持 |
| 响应延迟 | 毫秒级 | 微秒级 |
| 吞吐量 | 千级 QPS | 十万级 QPS |
没有 Redis: 10000并发 → MySQL崩溃 → 超卖 + 数据不一致
有 Redis: 10000并发 → Redis过滤 → 单线程扣减 → 不超卖
↓
扣减成功 → 发MQ消息 → 异步落库 → 保护MySQL
3) Redis + Lua 脚本解决方案
秒杀开始前,将库存预热到 Redis:
活动开始前:MySQL(stock=100) → Redis(stock=100)
用户请求到达时,使用 Lua 脚本在 Redis 中单线程执行:
-- 检查库存
local stock = redis.call("GET", "seckill:stock:1")
if not stock then return -1 end
if tonumber(stock) <= 0 then return 0 end
-- 扣减库存
redis.call("DECR", "seckill:stock:1")
return 1
Lua 脚本的优势:
- Redis 单线程执行,无需额外加锁
- 读取 + 判断 + 写入在同一个原子操作内完成
- 绝不发生超卖
一. Redis 与 Redis库
确保Redis已成功安装
同时安装Redis库
go get github.com/redis/go-redis/v9
二. 连接Redis并使用Go操作Redis
1) RedisConfig结构体
config/config.go
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
}
type Config struct {
Server ServerConfig
Database DatabaseConfig
JWT JWTConfig
Redis RedisConfig // 新增
}
2) 更新config.yaml
config.config.yaml
redis:
host: "127.0.0.1"
port: 6379
password: "your_redis_password_here" # 替换为你的 Redis 密码,如果没有设置密码,可以留空
db: 0 # Redis数据库编号,默认为0,如果你有多个数据库,可以修改为其他数字(如1、2等)
3) 编写Redis初始化逻辑
internal/cache/redis.go
package cache
import (
"context"
"fmt"
"github.com/Chuan81/secgo-mall/config"
"github.com/redis/go-redis/v9"
)
var Rdb *redis.Client
func InitRedis() {
rCfg := config.GlobalConfig.Redis
Rdb = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", rCfg.Host, rCfg.Port),
Password: rCfg.Password,
DB: rCfg.DB,
})
ctx := context.Background()
if err := Rdb.Ping(ctx).Err(); err != nil {
panic(fmt.Sprintf("Failed to connect to Redis: %v", err))
}
println("Connected to Redis successfully")
}
4) 启动服务器时初始化Redis
更新cmd/secgo-mall/main.go
import(
"github.com/Chuan81/secgo-mall/internal/cache" // 新增
)
//...
func main() {
// 初始化配置
config.InitConfig()
// 初始化数据库连接
database.InitMySQL()
// 初始化Redis连接
cache.InitRedis()
//...
三. 秒杀库存操作
创建internal/cache/seckill.go
package cache
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
var ctx = context.Background()
// StockKey 生成秒杀库存的Redis Key
func StockKey(seckillId uint) string {
return fmt.Sprintf("seckill:stock:%d", seckillId)
}
// UserBuyKey 生成用户购买记录的Redis Key
func UserBuyKey(seckillId, userId uint) string {
return fmt.Sprintf("seckill:user:%d:%d", seckillId, userId)
}
// PreloadStock 预加载秒杀库存到Redis
func PreloadStock(seckillId uint, stock int) error {
return Rdb.Set(ctx, StockKey(seckillId), stock, 0).Err() // 设置一个过期时间,防止库存数据长时间占用Redis
}
// DeductStock 扣减秒杀库存,返回是否成功(Lua脚本保证原子性)
var deductStockScript = redis.NewScript(`
local stock = redis.call("GET", KEYS[1])
if not stock then
return -1 -- 库存不存在
end
if tonumber(stock) <= 0 then
return 0 -- 库存不足
end
redis.call("DECR", KEYS[1])
return 1 -- 扣减成功
`)
// DeductStock 扣减秒杀库存,返回是否成功
func DeductStock(seckillId uint) (int, error) {
result, err := deductStockScript.Run(ctx, Rdb, []string{StockKey(seckillId)}).Int()
if err != nil {
return -1, err // 处理Redis错误
}
return result, nil // -1: 库存不存在, 0: 库存不足, 1: 扣减成功
}
四. 启动项目测试Redis是否成功连接


浙公网安备 33010602011771号