redis lua 脚本 实现抢购秒杀商品
介绍
目前秒杀的实现方案主要有两种,1.用mq实现,比如RocketMQ,服务器将请求过来的数据先让RocketMQ存起来,然后再慢慢消费掉。 2.用redis 来实现,同样的道理,用redis 将抢购信息进行存储。然后再慢慢消费。 同时,服务器给与用户快速响应。
ok.那我们就来讲解如何实现用Redis 来进行抢购秒杀。
思路流程
整体流程:
1.服务器接收到了大量用户请求过来(1s 2000个请求)。比如传了用户信息,产品信息,和购买数量信息。此时 服务器采用redis 的lua 脚本 去调用redis 中间件。lua 脚本的逻辑是 减库存,并生成订单信息。然后迅速给与服务器反馈(库存是否够,够返回 1 ,不够返回 0)。
2.服务器迅速给与用户的请求反馈。提示抢购成功.或者抢购失败
3.开启定时任务,将redis里面的订单信息取出来。然后进行持久化操作。
4.后面客户就可以查询 mysql 的订单信息了。
代码展示
架构采用springboot+redis+mysql+myBatis.
表结构 ,需要 产品表和订单表。
CREATE TABLE t_product(
id INT(12) NOT NULL AUTO_INCREMENT COMMENT '编号',
product_name VARCHAR(60) NOT NULL COMMENT '产品名称',
stock INT(10) NOT NULL COMMENT '库存',
price DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '单价',
VERSIONs INT(10) NOT NULL DEFAULT 0 COMMENT '版本号',
note VARCHAR(256) NULL COMMENT '备注',
PRIMARY KEY(id)
)
CREATE TABLE t_purchase_record(
id INT(12) NOT NULL AUTO_INCREMENT COMMENT ‘编号’,
user_id INT(12) NOT NULL COMMENT ‘用户编号’,
product_id INT(12) ,
price DECIMAL(16,2) NOT NULL COMMENT ‘总价’,
purchase_date timestamp NOT NULL DEFAULT NOW() COMMENT ‘购买日期’ ,
note VARCHAR(512) NULL COMMENT ‘备注’,
PRIMARY KEY(id)
);
配置文件信息
pom依赖
注意红框即可
核心业务代码展示
调用redis 的lua 脚本进行减库存和生成订单信息.
@Override
//userId 用户id,productId 产品id ,quantity 购买数量
public boolean purchaseRedis(Long userId, Long productId, int quantity) {
//购买时间
Long purchaseDate = System.currentTimeMillis();
//采用jedis 来操作redis
Jedis jedis = null;
try {
//purchaseScript 是lua 脚本。
System.out.println(purchaseScript);
//获取原始链接
jedis = (Jedis) stringRedisTemplate.getConnectionFactory().getConnection().getNativeConnection();
//如果没有加载过,则先将脚本加载到Redis服务器,让其返回sha1
if (sha1 == null) {
sha1 = jedis.scriptLoad(purchaseScript);
}
//执行脚本 ,返回结果
//参数 2 表示有两个key. 即 PURCHASE_SCHEDULE_SET,和PURCHASE_PRODUCT_LIST 表示
//key. 在redis 里面可以用key[1]和key[2]来获取。
//后面userId,productId,quantity,purchaseDate 是订单参数。
//在redis 可以用 ARGV[1] ,ARGV[2]来取参数
Object res = jedis.evalsha(sha1, 2, PURCHASE_SCHEDULE_SET, PURCHASE_PRODUCT_LIST, userId + "", productId + "", quantity + "", purchaseDate + "");
long result = (Long) res;
return result == 1;
} finally {
if (jedis != null && jedis.isConnected()) {
jedis.close();
}
}
}
lua 脚本解释
String purchaseScript =
//先将产品编号保存到集合中
"redis.call('sadd',KEYS[1],ARGV[2]) \n"
//购买列表 .. 符号是拼接的意思。 即 第二个key purchase_list_
//和第二个 value(产品id)
//调用方法是: jedis.evalsha(.....)
+ "local productPurchaseList = KEYS[2]..ARGV[2] \n"
//用户编号 取值
+ "local userId = ARGV[1] \n"
//产品健 取值拼接
+ "local product = 'product_'..ARGV[2] \n"
//购买数量 取值
+ "local quantity = tonumber(ARGV[3])\n"
//当前库存 在redis 执行hget product 'stock'
//一定要提前在redis里面存入对应的库存值 hset product 'stock' 30000
+ "local stock=tonumber(redis.call('hget',product,'stock'))\n"
//价格 在redis 执行 hget product price .
// 一定要提前在redis 里面存入对应的产品价格值
// hset product 'price' 600.00
+ "local price = tonumber(redis.call('hget',product,'price')) \n"
//购买时间 取值
+ "local purchase_date=ARGV[4] \n"
//库存不足 进行库存校验,是否生成订单
+ "if stock<quantity then return 0 end \n"
//减库存 更新库存
+ "stock = stock-quantity \n"
+ "redis.call('hset',product,'stock',tostring(stock)) \n"
//计算价格 计算价格
+ "local sum = price * quantity \n"
//合并购买记录数据 拼接订单信息
+ "local purchaseRecord = userId..','..quantity..','"
+ "..sum..','..price..','..purchase_date \n"
//将购买记录保存到List里 将订单信息保存
+ "redis.call('rpush',productPurchaseList,purchaseRecord) \n"
//返回成功
+ "return 1 \n";
//redis 购买记录集合前缀
private static final String PURCHASE_PRODUCT_LIST = "purchase_list_";
//抢购商品集合
private static final String PURCHASE_SCHEDULE_SET = "product_schedule_set";
从redis里取订单信息持久化到mysql
这里我没有采用定时任务,因为是模拟,我直接采用post 请求来触发这个操作。
@Override
public Result purchaseTask() {
//采用 StringRedisTemplate redis 的模板类(springboot为我们生成)
//来操作redis,获取之前Lua 存的订单信息
Set<String> productIdList =
stringRedisTemplate.opsForSet().members(PURCHASE_SCHEDULE_SET);
List<PurchaseRecordPo> prpList = new ArrayList<>();
for (String productIdsStr : productIdList) {
Long productId = Long.parseLong(productIdsStr);
String purchaseKey = PURCHASE_PRODUCT_LIST + productId;
BoundListOperations<String, String> ops = stringRedisTemplate.boundListOps(purchaseKey);
//计算记录数
long size = stringRedisTemplate.opsForList().size(purchaseKey);
//计算下 循环的次数
Long times = size % ONE_TIME_SIZE == 0 ?
size / ONE_TIME_SIZE :
size / ONE_TIME_SIZE + 1;
for (int i = 0; i < times; i++) {
List<String> prList = null;
//每次取1000条数据进行持久化。这个可以跟进后期真正业务进行修改。
//可以采用线程池完成
if (i == 0) {
prList = ops.range(i * ONE_TIME_SIZE, (i + 1) * ONE_TIME_SIZE);
} else {
prList = ops.range(i * ONE_TIME_SIZE + 1, (i + 1) * ONE_TIME_SIZE);
}
for (String str : prList) {
//生成订单信息
PurchaseRecordPo prp = this.createPurchaseRecord(productId, str);
prpList.add(prp);
}
try {
//调用dao 层来进行持久化操作
dealRedisPurchase(prpList);
} catch (Exception e) {
e.printStackTrace();
}
//清空数据
prpList.clear();
}
//删除购买列表
stringRedisTemplate.delete(purchaseKey);
//从商品集合中删除商品
stringRedisTemplate.opsForSet().remove(PURCHASE_SCHEDULE_SET, productIdsStr);
}
System.out.println("结束...............");
return new Result(true, "结束了................");
}
//手动模拟订单信息
private PurchaseRecordPo createPurchaseRecord(Long productId, String str) {
//解析字符串的内容
String[] arr = str.split(",");
Long userId = Long.parseLong(arr[0]);
int quantity = Integer.parseInt(arr[1]);
double sum = Double.valueOf(arr[2]);
double price = Double.valueOf(arr[3]);
Long time = Long.parseLong(arr[4]);
Timestamp purchaseTime = new Timestamp(time);
PurchaseRecordPo purchaseRecordPo = new PurchaseRecordPo();
purchaseRecordPo.setProductId(productId);
purchaseRecordPo.setPurchaseTime(purchaseTime);
purchaseRecordPo.setPrice(price);
purchaseRecordPo.setQuantity(quantity);
purchaseRecordPo.setSum(sum);
purchaseRecordPo.setUserId(userId);
purchaseRecordPo.setNote("购买日志-redis,时间: " + purchaseTime.getTime());
return purchaseRecordPo;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean dealRedisPurchase(List<PurchaseRecordPo> purchaseRecordPos) {
for (PurchaseRecordPo prp : purchaseRecordPos) {
//直接调用dao层进行持久化
purchaseRecordDao.insertPurchaseRecord(prp);
productDao.decreaseProduct(prp.getProductId(), prp.getQuantity());
}
return true;
}
实体类
dao.xml
Jmeter 模拟测试
采用线程组2000个,直接调用请求。服务器响应没有问题。本地单机可以持续响应,不卡顿。如果是直接服务器请求mysql。100个并发都不能支持。
踩坑记录
1.lua脚本编写错误
脚本编写错误,请求的时候,在redis 里面执行lua 脚本报错。 这个要仔细对比了。比如属性名,引号之类的。
2.没有初始化成功
user_script:9: attempt to compare nil with number
也是初始化没有成功。需要进行hset 操作。
hset product ‘stock’ 30000。 设置库存30000.hset product ‘price’ 6.0
3.redis 里面的单价和库存没有初始化
提示 stock 属性不存在。
很好解决,直接链接redis 服务器手动 添加这个参数。hset product 'stock' 30000。 设置库存30000.
同理 也要设置单价 hset product 'price' 6.0
其他
本文是学习了 <<深入浅出 SpringBoot 2.x>>(杨开振大佬)的redis 模拟秒杀章节所写。如果文章有不清楚的。可以参考下 原著。
书籍下载地址:
https://download.csdn.net/download/echohuangshihuxue/86933031
如果不能获取,可以邮箱私我
m17748691474@163.com