Golang|抽奖相关
文章目录
- 抽奖核心算法
- 生成抽奖大转盘
- 抽奖接口实现
抽奖核心算法
- 我们可以根据
单商品库存量/总商品库存量
得到每个商品被抽中的概率,可以想象这样一条0-1
的数轴,数轴上的每一段相当于一种商品,概率之和为1
。
- 抽奖时,我们会生成
U(0,1)
上的一个随机数,这个数位于哪个线段上就对应着抽中了对应的商品。 - 构建线段,时间复杂度
O(n)
。 - 用二分查找算法查找随机数位于哪一段,时间复杂度
O(logn)
,采集k
个样本需要再乘以k
。
- 接下来介绍二分查找区间算法:
N
个点把实数域分割成N+1
段,target
是随机生成的实数target
应该落在哪一段上?- 定义
array[i-1] < target < array[i]
为落在第i
条线段上,代表第i
个奖品被抽中
// BinarySearch 查找 >= target 的最小元素下标,arr单调递增(不能存在重复元素)
// 如果target比arr的最后一个元素还大,返回最后一个元素下标
func BinarySearch(arr []float64, target float64) int {
if len(arr) == 0 {
return -1
}
left := 0
right := len(arr)
for left < right {
// 通用条件
if target <= arr[left] {
return left
}
if target > arr[right-1] {
return right
}
// len(arr) == 2, mid在left和right之间, 选择left的概率值
if left == right-1 {
return right
}
// len(arr) >= 3
mid := (left + right) / 2
if target < arr[mid] {
right = mid
} else if target == arr[mid] {
return mid
} else {
left = mid // NOTE: 这里不是找直接数值,而是区间
}
}
return -1
}
生成抽奖大转盘
- 首先看看我们对于抽奖大转盘所设计的 mysql 数据库表结构
-- ----------------------------
-- DataBase
-- ----------------------------
CREATE DATABASE lottery;
use lottery;
-- ----------------------------
-- Table structure for inventory
-- ----------------------------
DROP TABLE IF EXISTS `inventory`;
CREATE TABLE `inventory` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT "奖品id, 自增",
`created_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP COMMENT "创建时间",
`updated_at` DATETIME(3) NULL DEFAULT NULL COMMENT "更新时间",
`deleted_at` DATETIME(3) NULL DEFAULT NULL COMMENT "删除时间",
`name` varchar(20) NOT NULL COMMENT "奖品名称",
`description` varchar(100) NOT NULL DEFAULT "" COMMENT "奖品描述",
`picture` varchar(200) NOT NULL DEFAULT "0" COMMENT "奖品图片",
`price` int(11) NOT NULL DEFAULT "0" COMMENT "价值",
`count` int(11) NOT NULL DEFAULT "0" COMMENT "库存量",
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8 COMMENT="奖品库存表";
insert into `inventory` (id,name,picture,price,count) values (1,'谢谢参与','img/face.png',0,0);
insert into `inventory` (name,picture,price,count) values ('篮球','img/ball.jpeg',100,1000),('水杯','img/cup.jpeg',80,1000),('电脑','img/laptop.jpeg',6000,200),('平板','img/pad.jpg',4000,300),('手机','img/phone.jpeg',5000,400),('锅','img/pot.jpeg',120,1000),('茶叶','img/tea.jpeg',90,1000),('无人机','img/uav.jpeg',400,100),('酒','img/wine.jpeg',160,500);
-- ----------------------------
-- Table structure for order
-- ----------------------------
DROP TABLE IF EXISTS `order`;
CREATE TABLE `order` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT "订单id, 自增",
`created_at` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP COMMENT "创建时间",
`updated_at` DATETIME(3) NULL DEFAULT NULL COMMENT "更新时间",
`deleted_at` DATETIME(3) NULL DEFAULT NULL COMMENT "删除时间",
`gift_id` int(11) NOT NULL COMMENT "商品id",
`user_id` int(11) NOT NULL COMMENT "用户id",
`count` int(11) NOT NULL DEFAULT "1" COMMENT "购买数量",
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=189549 DEFAULT CHARSET=utf8mb4 COMMENT="订单表";
- 上面这一段,在数据量不大的时候还是可以的,但是数据量一大,在千万级以上大表的场景下就不行啦,会导致长时间的阻塞,而且读出来内存也不够
-
V2是对其优化,主要是一个分页查询思路,每次把一页的数据放入一个channel中,然后用一个go协程每次从channel中读出数据写入redis
-
对于每个商品的 count 字段,每次被抽中都应该 count 对应的值减1,但是在高并发的情况下mysql可能扛不住这么大并发量下的频繁写入,考虑先记录在redis里面,真正的减1操作是在redis里面实现的。
-
在前端页面初始化的时候我们需要把整个大转盘的页面通过后端服务器返回的数据去渲染出整个大转盘出来,所以需要一开始通过
InitInventory
函数获得所有奖品的初始库存,存入redis。
- 看看前端的代码:
<body>
<div class="center" id="my-lucky"></div>
<script>
var giftMap = new Map(); //维护奖品ID和转盘里奖品index的对应关系
$(document).ready(function () {
$.ajax({
type: "GET",
url: "api/v1/gifts",
success: function (data) {
console.log(data)
let gifts = data["data"]
var prizes=new Array();
$.each(gifts,function(index,gift){
giftMap[gift.Id]=index;
prizes[index]= { background: '#e9e8fe', fonts: [{ text: gift.Name }], imgs:[{src:gift.Picture,top:30,width:80,height:80}] };
})
// 直接使用luch-canvas抽奖插件 https://100px.net/usage/js.html
const myLucky = new LuckyCanvas.LuckyWheel('#my-lucky', {
width: '600px',
height: '600px',
blocks: [{ padding: '10px', background: '#869cfa' }],
prizes: prizes,
buttons: [
{ radius: '40%', background: '#617df2' },
{ radius: '35%', background: '#afc8ff' },
{
radius: '30%', background: '#869cfa',
pointer: true,
fonts: [{ text: '抽奖', top: '-10px' }]
},
],
start: function() {
$.ajax({
type: "GET",
url: "api/v1/lucky",
success: function (giftId) {
if(giftId=="0"){
alert("抽奖结束")
}else{
myLucky.play();
idx=giftMap[giftId];
myLucky.stop(idx);
}
}
}).fail(function (result, result1, result2) {
alert("出错了");
});
},
end: function(prize) { // 游戏停止时触发
alert('恭喜中奖: ' + prize.fonts[0].text)
}
})
}
}).fail(function (result, result1, result2) {
$('#my-lucky').html("数据加载失败");
});
});
</script>
</body>
- 我们可以看到前端的代码逻辑是先放一个空的div,然后页面加载好之后通过js代码发起一个请求去请求"/gifts"这个接口获得数据渲染生成大转盘。
- 这里有个小细节,我们后端返回给前端gifts数据的时候要记得抹掉敏感信息,也就是说我们的抽奖概率是通过商品的库存量来决定的,我们不希望前端拿到json字符串后看到库存量,所以我们的gifts返回给前端的时候记得把所有的库存量都置为0。
type Inventory struct {
ID uint `gorm:"column:id"`
Name string `gorm:"column:name"`
Description string `gorm:"column:description"`
Picture string `gorm:"column:picture"`
Price int `gorm:"column:price"`
Count int `gorm:"column:count"`
}
- 上面这段代码就是从redis上获取所有奖品的库存量,其中商品id作为key的时候会统一加一个前缀prefix
抽奖接口实现
- 当我们按下抽奖这个按钮后,前端会用js代码请求"/lucky"接口,由后端返回本次抽奖抽中了哪个商品id
- ids和probs两个是一一对应的,一个存的是奖品id一个存的是奖品的库存量
- 如果奖品已经count为0了,说明已经抽没了,不应该再参与抽奖
- 为什么要给前端传一个0,因为这个0有特殊的意思,前端收到0后会告诉用户抽奖已经结束
- 有可能同时对一个库存量为1的商品去执行减1操作,会导致库存量为负数,这个时候我们会执行新一轮的抽奖,重新再抽一遍,如果执行指定次数后还是失败,则会返回最后一行代码,谢谢参与
- redis Decr 是支持原子性支持并发的