抢红包的关键
我认为在抢红包业务里面,主要有以下几个关键问题:
1、多个人同时抢一个红包存在的数据竞争问题(并发问题)
2、判断一个人是否已抢过该红包 (可见性问题)
3、拼手气红包的分配算法
4、红包抢到后钱怎么到账?
数据竞争问题
当多个人同时抢同一个红包时,会存在数据竞争,这个好理解。那么什么发生了竞争?首先红包个数会有竞争,当两个人同时抢最后一个红包时,只有一个人能抢到红包,类似于秒杀系统中的库存,如果没有解决好竞争问题,就会出现诸如超卖或少卖的问题。
解决竞争问题的主要思路:1、单线程执行(如加互斥锁、redis+lua脚本保证原子性)2、CAS
数据可见性问题
当我们抢完红包时,下次点进去,就不会显示可抢的状态了,即使红包还没有抢完。那如果我们很快地点开同一个红包呢?会不会都显示可抢状态。如果你抢红包通过更新数据库来完成,判断有没有抢过红包是有时间差的,那怎么解决呢?
解决数据可见性问题:1、还是单线程执行(如加互斥锁、redis+lua脚本保证原子性) 2、在单机环境下采用volitile修饰变量
手气红包分配算法
红包分配算法有很多种,满足随机,加上一些基本的限制:比如每个红包金额必须>=0.01。
常见的红包分配算法有:1、二倍均值法:适用于实时计算抢到的金额 (据说是之前微信用的 微信红包算法) 2、线段法:适用于发红包时预先算好每个红包的金额
可以参考一下:红包算法 当然你也可以根据业务条件设计一套自己的分配算法。
抢到的红包如何到账
红包金额的到账与抢红包逻辑两者不是一个耦合的逻辑,可以进行解耦,可以采用抢到红包后将打款消息写入到消息队列
,由后续业务处理,这样既解决了业务解耦的问题,同时起到了削峰填谷的作用,提供抢红包核心业务的性能。(如果业务量不大,或没有消息队列的时候,采用线程池异步执行也是一个弥补的方案)
解决方案
那么我是如何解决以上问题的呢?
表结构:
CREATE TABLE `red_envelope_info`
(
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`uid` int(10) unsigned NOT NULL COMMENT '红包所属用户',
`title` varchar(255) DEFAULT '' COMMENT '红包主题',
`amount` mediumint unsigned NOT NULL COMMENT '红包金额总金额,单位:分',
`detail_num` smallint unsigned DEFAULT 0 COMMENT '子红包数量',
`receive_num` smallint unsigned NOT NULL COMMENT '多少人已领',
`receive_rule` varchar(255) NOT NULL DEFAULT '' COMMENT '领取规则',
`type` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '分享类型 1:普通红包 2:拼手气红包',
`status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '状态 0待支付 1正常(支付成功) 2过期 3支付失败',
`pay_no` varchar(60) NOT NULL DEFAULT '' COMMENT '交易识别编号,业务id',
`gmt_pay` int(10) unsigned NOT NULL COMMENT '支付时间',
`gmt_begin` int(10) unsigned NOT NULL COMMENT '开始时间',
`gmt_end` int(10) unsigned NOT NULL COMMENT '结束时间',
`gmt_create` int(10) unsigned NOT NULL COMMENT '创建时间',
`gmt_modified` int(10) unsigned NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='红包发放表';
CREATE TABLE `red_envelope_detail`
(
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`gift_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'red_envelope_info.id',
`uid` int(10) unsigned NOT NULL COMMENT '领取的用户UID',
`amount` mediumint unsigned NOT NULL COMMENT '红包金额,单位:分',
`status` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '状态 1已领取',
`gmt_receive` int(10) unsigned NOT NULL COMMENT '领取时间',
`gmt_create` int(10) unsigned NOT NULL COMMENT '创建时间',
`gmt_modified` int(10) unsigned NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_uid` (`uid`),
KEY `idx_gift_id` (`gift_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='红包领取记录表';
lua脚本:
// KEYS[1]为红包库存的key
// 获取红包剩余个数
local stock = tonumber(redis.call('hget', KEYS[1], 'remain_num'))
// 记录不存在
if stock == nil then
return 3
end
// 已抢完
if stock <= 0 then
return 0
end
// 是否已抢过(KEYS[2]为红包已抢用户id集合的key,用set存储,ARGV[1]为用户id)
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) ~= 0 then
return 4
end
// 成功抢到红包,库存减1
stock = stock - 1
redis.call('hset', KEYS[1], 'remain_num', tostring(stock))
// 设置已抢用户id
redis.call('SADD', KEYS[2], ARGV[1])
if stock == 0 then
// 标记最后一个红包
return 2
end
return 1
这里通过redis + lua脚本保证了抢红包动作的原子性,但要注意几个问题:
1、在redis集群中,一个lua脚本中的多个key必须在同一个节点上
,否则会报错。可采用的方案为hashtag
。如
red_envelope_{%s}
red_envelope_uid_list_{%s}
%s传红包id,保证同一个红包数据在一个节点上,同时可以是数据分布更加均匀。
2、即使这里解决了抢红包时的竞争问题,但在后续红包数据更新时,仍然存在竞争。在这种情况下,可选方案:
- 更新的时候再加锁。(可以解决问题,但不是一个好办法)
- 如果是提前分配好的红包算法,可以在发红包时就将red_envelope_detail表中的id存在redis中,抢到红包时返回表id,然后带着表id去更新数据,消除了竞争条件。
- 一开始压根就没有表存储,那么在抢到红包后直接更新redis就可以了,消除了竞争。
解决这个问题,我们也可以看到,其实解决竞争还有一个办法,那就是消除竞争。毕竟这才是更好的方案
贴出部分业务代码:
public GrabResTO grab(GrabReqTO reqTO) {
log.info("抢红包请求:{}", reqTO);
// 解析红包链接
RedEnvelopeShareInfoTO shareInfoTO = defaultShareIdCodec.decodeShareId(reqTO.getShareId());
if (Validator.isNull(shareInfoTO)) {
throw new BizException(String.format("解析shareId失败,shareId:%s", reqTO.getShareId()));
}
// 执行lua脚本
Long scriptRes = redisClient.executeScript(LUA_SCRIPT,
Lists.newArrayList(CommonUtil.stringFormat(RedEnvelopesConstant.ENVELOPE_KEY_PREFFIX,
shareInfoTO.getId()),
CommonUtil.stringFormat(RedEnvelopesConstant.UID_LIST_OF_ENVELOPE_KEY_PREFFIX,
shareInfoTO.getId())),
Lists.newArrayList(reqTO.getUid().toString()));
log.info("红包id:{},uid:{},lua执行结果:{}", shareInfoTO.getId(), reqTO.getUid(),
scriptRes);
if (Validator.isNull(scriptRes)) {
return new GrabResTO(Boolean.FALSE, RedEnvelopesConstant.TIPS_FOR_EXCEPTION);
}
GrabResTO result = new GrabResTO();
// 根据执行结果进行后续处理
switch (scriptRes.intValue()) {
case RedEnvelopesConstant.STATUS_REPEAT:
result = new GrabResTO(Boolean.FALSE, RedEnvelopesConstant.TIPS_FOR_REPEAT_GRAB);
break;
// redis数据不存在,异常情况 || 红包已领完
case RedEnvelopesConstant.STATUS_NOT_EXSITS:
case RedEnvelopesConstant.STATUS_END:
result = new GrabResTO(Boolean.FALSE, RedEnvelopesConstant.TIPS_FOR_END);
break;
// 成功
case RedEnvelopesConstant.STATUS_LAST_SUCCESS:
case RedEnvelopesConstant.STATUS_SUCCESS:
// 如果采用文中的方案,后续还要加锁
result = this.getRedEnvelopeSuccessful(shareInfoTO, result, reqTO);
break;
default:
log.error("lua脚本未知结果,shareInfoTO:{},scriptRes:{}", shareInfoTO, scriptRes);
break;
}
return result;
}
其他问题应该还好,如果错误请指出