前面学习过 Redis 的一些基础知识,为了达到学以致用的目的,加深和巩固对 Redis 的理解,这篇文章模拟微信抢红包,设计一个简单的抢红包系统。
1.需求描述
- 应用场景描述:
- 假设某微信群有:10000人
- 某土豪发红包: 1000 元
- 红包个数: 10 个
- 分配规则:发 1000 元,随机分成 10 个红包
- 红包过期时间 24 小时
- 新建红包规则:
自定义红包个数范围 1~100 个 ,金额范围 0.01~1000 元,红包留言备注字符自定义长度 0~25
2.新建红包
- 确定新建红包表字段:
字段名称 | 类型和长度 | 含义 |
id | int(11) | 自增主键 |
uuid | varchar(100) | UUID |
total_amount | int(11) | 红包总金额,单位分 |
total_packet | smallint(5) | 红包总个数 |
residue_amount | int(11) | 剩余红包总金额,单位分 |
residue_packet | smallint(5) | 剩余红包个数 |
user_id | bigint(20) | 用户ID |
remark | varchar(25) | 红包留言 |
created_at | int(11) | 创建时间(时间戳) |
updated_at | int(11) | 更新时间(时间戳) |
- 建表语句:
CREATE TABLE `red_packet_record` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(100) CHARACTER SET utf8mb4 NOT NULL DEFAULT '' COMMENT 'UUID', `total_amount` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '红包总金额,单位分', `total_packet` smallint(5) unsigned NOT NULL DEFAULT '0' COMMENT '红包总个数', `residue_amount` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '剩余红包金额,单位分', `residue_packet` smallint(5) unsigned NOT NULL DEFAULT '0' COMMENT '剩余红包个数', `user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '新建红包用户ID', `remark` varchar(25) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '红包留言', `created_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '红包创建时间', `updated_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '红包更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8
- 接口路由定义
$api->group(['middleware' => 'auth:user'], function ($api) { $api->post('test', 'TestController@addRedPacket'); }
- 控制器方法定义
/** * 新建红包 * @param Request $request * @return mixed */ public function addRedPacket(Request $request) { //数据校验 $this->sceneValidate((new RedPacketValidate())->scene('add-red-packet')); $redPacketService = new RedPacketService(); $redPacketService->setUser($this->user()) ->addRedPacket($request) ->allot() ->inform(); return $this->success("创建成功"); }
Tips: 其中 addRedPacket() 表示新建红包入库逻辑,allot() 表示红包分配并丢进 Redis list 中,inform() 表示通知客户端可以开始抢红包了。
- 接口数据校验
/** * 定义数据验证规则 * @var array */ protected $rule = [ 'total_amount' => 'required|numeric|between:0.01,1000', 'total_packet' => 'required|integer|between:1,100', 'remark' => 'max:25', ]; /** * 定义数据验证错误提示 * @var array */ protected $message = [ 'total_amount.required' => '红包金额不能为空', 'total_amount.numeric' => '红包金额必须是数字', 'total_amount.between' => '红包总金额范围必须是0.01~1000元之间', 'total_packet.required' => '红包数量不能为空', 'total_packet.integer' => '红包数量必须是整数', 'total_packet.between' => '红包数量范围必须是1~100之间', 'remark.max' => '红包留言字符长度不能超过25', ]; /** * 定义数据验证场景 * @var array */ protected $scene = [ 'add-red-packet' => ['total_amount', 'total_packet', 'remark'] ];
- 新建红包入库逻辑
public function addRedPacket(Request $request){ $total_amount = (float) $request->input("total_amount"); $total_packet = (int) $request->input("total_packet"); try{ $redPacket = new RedPacketRecord(); $redPacket->uuid = uuidCode(); $redPacket->total_amount = $total_amount * 100; $redPacket->total_packet = $total_packet; $redPacket->residue_amount = $total_amount * 100; $redPacket->residue_packet = $total_packet; $redPacket->user_id = 88;//这里需要用户登录才有信息,为了验证方便直接写死 $redPacket->remark = (string) $request->input("remark"); $redPacket->created_at = YouDate::now()->getTimestamp(); $redPacket->save(); }catch(\Exception $exception){ throw new ValidatorException("创建红包失败"); } }
- 红包分配逻辑
/** * 分配红包数量和金额丢进 redis * @return $this */ public function allot(){ $data = []; $n = $this->redPacket->total_packet; for ($i = 0;$i < $n;$i++){ //平均值 $ave = $this->redPacket->total_amount/$this->redPacket->total_packet; $randAmount = mt_rand(1,$ave * 2); $data[] = $randAmount; $this->redPacket->total_amount = $this->redPacket->total_amount - $randAmount; $this->redPacket->total_packet--; } //把分配好的红包金额丢进 Redis list $redList = Redis::lpush("ADD_RED_".$this->redPacket->user_id."_".$this->redPacket->uuid,$data);//将红包丢进 list 里面,准备后面的用户抢 if(!$redList){ //若操作 redis 失败,实施重试机制,处理相关退款逻辑 //若重试多次 throw new ValidatorException("红包发起失败"); } return $this; }
- 通知客户端
/** * 通知客户端开始抢红包 */ public function inform(){ //通知用户可以开始抢红包了,下面是伪代码 //send::clien("ADD_RED_".$this->redPacket->user_id."_".$this->redPacket->uuid); return $this; }
3.抢红包
需要判断红包是否已经抢完,然后限制同一个用户最多只能抢到一个红包:
- 确定用户抢红包表字段:
字段名称 | 类型和长度 | 含义 |
id | int(11) | 自增主键 |
uuid | varchar(100) | 红包的UUID |
amount | int(11) | 红包金额,单位分 |
user_id | bigint(20) | 用户ID |
created_at | int(11) | 创建时间(时间戳) |
- 建表语句:
CREATE TABLE `red_packet_user` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(100) CHARACTER SET utf8mb4 NOT NULL DEFAULT '' COMMENT '红包的UUID', `amount` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '红包金额', `user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '抢到红包的用户ID', `remark` varchar(25) COLLATE utf8_unicode_ci NOT NULL DEFAULT '' COMMENT '红包留言', `created_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '抢到红包时间', PRIMARY KEY (`id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
- 接口路由定义
$api->get('test', 'TestController@getRedPacket');
- 控制器方法定义
/** * 抢红包接口 * @param Request $request */ public function getRedPacket(Request $request){ $redPacketToken = $request->input("red_packet_token");//红包标识,这个会在发起红包时通知客用户 if(!$redPacketToken){ throw new ValidatorException("验证参数不能为空"); } $redPacketService = new RedPacketService(); $redPacketUser = $redPacketService->setUser($this->user()) ->setRedPacketToken($redPacketToken) ->isStart() ->statNum() ->getRedPacket(); return $this->success($redPacketUser->amount); }
Tips: 其中 isStart() 表示判断红包是否被抢空或该用户是否已经抢过,statNum() 表示统计参与抢红包人数,getRedPacket() 表示抢红包入库逻辑。
- 判断红包是否被抢空或该用户是否已经抢过
/** * 判断红包是否被抢空&该用户是否已经抢过 */ public function isStart(){ //判断红包是否还有 if(!Redis::exists($this->redPacketToken) || !Redis::llen($this->redPacketToken)){ throw new ValidationException("红包已抢光"); } //判断该用户是否已经抢过,需要登录 if(Redis::getbit($this->redPacketToken."_BIT",$this->user->id) == 1){ throw new ValidationException("该用户已经抢过,不能再抢"); } return $this; }
Tips: 其中已经抢过红包的用户
ID
会在bitmap
中记录。
- 统计参与用户数
/** * 统计累计参与人数 */ public function statNum(){ Redis::pfadd($this->redPacketToken."_COUNT",$this->user->id);//需要用户登录 return $this; }
- 抢红包逻辑
/** * 用户从 Redis list 取出红包,并操作 db * @return $this */ public function getRedPacket(){ //从红包列队里边取出红包金额,并备份至hash里面 $amount = Redis::rpop($this->redPacketToken); //备份至 hash 里面 if(!Redis::hset($this->redPacketToken."_BAK",$this->user->id,$amount)){ //若操作 redis 失败,实施重试逻辑 } DB::beginTransaction(); try{ $redPacket = RedPacketRecord::where('uuid',$this->redPacketToken)->lockForUpdate()->first();//这里必须要加一个锁,若果不加锁可能当前读造成影响 $redPacketUser = new RedPacketUser(); $redPacketUser->uuid = $redPacket->uuid; $redPacketUser->amount = $redPacket->uuid; $redPacketUser->user_id = $this->user->id; $redPacketUser->created_at = YouDate::now()->getTimestamp(); $redPacketUser->save(); $redPacket->residue_packet--; $redPacket->residue_amount = $redPacket->residue_amount - $amount; $redPacket->updated_at = YouDate::now()->getTimestamp(); $redPacket->save(); DB::commit(); }catch(\Exception $exception){ DB::rollBack(); Redis::lpush($this->redPacketToken,$amount);//丢回到list让别人抢 if(!Redis::hdel($this->redPacketToken."_BAK",$this->user->id)){ //若操作 redis 失败,实施重试逻辑 } throw new ValidationException("抢红包失败"); } //成功之后将用户标记为已抢 Redis::setbit($this->redPacketToken."_BIT", $this->user->id,1); return $redPacketUser; }