抢红包的关键

我认为在抢红包业务里面,主要有以下几个关键问题:

1、多个人同时抢一个红包存在的数据竞争问题(并发问题)

2、判断一个人是否已抢过该红包 (可见性问题)

3、拼手气红包的分配算法

4、红包抢到后钱怎么到账?

数据竞争问题

当多个人同时抢同一个红包时,会存在数据竞争,这个好理解。那么什么发生了竞争?首先红包个数会有竞争,当两个人同时抢最后一个红包时,只有一个人能抢到红包,类似于秒杀系统中的库存,如果没有解决好竞争问题,就会出现诸如超卖或少卖的问题。

解决竞争问题的主要思路:1、单线程执行(如加互斥锁、redis+lua脚本保证原子性)2、CAS

数据可见性问题

当我们抢完红包时,下次点进去,就不会显示可抢的状态了,即使红包还没有抢完。那如果我们很快地点开同一个红包呢?会不会都显示可抢状态。如果你抢红包通过更新数据库来完成,判断有没有抢过红包是有时间差的,那怎么解决呢?

解决数据可见性问题:1、还是单线程执行(如加互斥锁、redis+lua脚本保证原子性) 2、在单机环境下采用volitile修饰变量

手气红包分配算法

红包分配算法有很多种,满足随机,加上一些基本的限制:比如每个红包金额必须>=0.01。
常见的红包分配算法有:1、二倍均值法:适用于实时计算抢到的金额 (据说是之前微信用的 微信红包算法) 2、线段法:适用于发红包时预先算好每个红包的金额
可以参考一下:红包算法 当然你也可以根据业务条件设计一套自己的分配算法。

抢到的红包如何到账

红包金额的到账与抢红包逻辑两者不是一个耦合的逻辑,可以进行解耦,可以采用抢到红包后将打款消息写入到消息队列,由后续业务处理,这样既解决了业务解耦的问题,同时起到了削峰填谷的作用,提供抢红包核心业务的性能。(如果业务量不大,或没有消息队列的时候,采用线程池异步执行也是一个弥补的方案)

解决方案

那么我是如何解决以上问题的呢?

java高并发 PDF JAVA高并发抢红包_lua

表结构

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;
    }

其他问题应该还好,如果错误请指出