小红书-内卖秒杀项目总结

一、背景介绍

公司内部福利社的同事牵头组织,将福利社的退货商品,低价售卖给公司内部员工,算是员工福利吧。
内卖举办过挺多次了,这里仅记录我参与的两次内卖。

二、鸣谢

特别感谢雪兔、小伊、白明、安迪,留白、云流,公生、阿力、路飞、莫一兮
感谢其他在内卖过程中给与各种各样支持的同事们

三、为什么会决定参与内卖?

套用经典名言:如今机会就在眼前,我不知道何时才能再有机会去参与一个真实的秒杀项目。
对于程序猿来说,秒杀是一个经典的高技术难度场景,绝佳的镀金项目。
在这里要感谢公司的ExtraMile计划,才能给与我这个机会,在公司内,去做一些非本职工作的项目,去做一些技术、业务上的挑战。纸上得来终觉浅,看再多的书籍、博客,如果没有在真实业务场景下去实现过,就只能叫纸上谈兵,吹牛都没人信的。

四、第一次内卖

1. 前言

第一次内卖,后端开发人员只有我一个。
因为仓库那边堆积压力很大,所以从我接到任务开始,开发时间只有一周多,还要尽量不影响工作,所以技术方案设计的时候,快速实现就很重要,一切不稳定因素都应当剔除。

2. 技术方案设计

  1. 缓存:不使用redis,采用单机内存做缓存
    我当时刚跑路到小红书,在内部基础设施服务的使用上,接连踩了各种各样的坑,在时间如此紧张的情况下,我对于接入公司内部redis实在是没有信心。不仅是redis,内部基础设施服务都是能不用就不用。

  2. 采用java的Semaphore来做限购。
    内卖限制每个人只能购买四件商品,那么就用Semaphore来做令牌发放,获取到令牌的请求才能进行购买,购买失败就返回令牌,购买成功则不返回。

  3. 限流:用guava的RateLimiter。
    因为是单机器,怕撑不住,所以得加一个限流才行。

  4. 内卖整体业务流程
    内卖原则上不支持退货,用户收到货物之后,根据实际到货情况和货物质量,扫描官方支付宝付款。
    (流程图链接)
    在这里插入图片描述

  5. 秒杀流程
    每人最多购买四件商品,每件商品限购一件
    (流程图链接)
    在这里插入图片描述

  6. 核心秒杀代码:

/**
 * 用户购买商品的数量限制
 */
public static volatile Integer userBuyLimit = 0;
/**
 * QPS限流,限流100
 */
public static final RateLimiter qpsLimter = RateLimiter.create(100);
/**
 * 空的vector实例
 */
private static final Vector emptyVector = new Vector();
/**
 * 用户购买的商品
 */
private static volatile Map<Integer, Vector<Integer>> userGoodsMap = new ConcurrentHashMap<>();
/**
 * 商品的存量数据
 */
public static volatile Map<Integer, AtomicInteger> goodsRemainCountMap = new HashMap<>();
/**
 * 用户的购物令牌,需要获取到令牌之后,该用户才可以购物
 */
private static volatile Map<Integer, Semaphore> userTokenMap = new HashMap<>();
/**
 * 商品的可购买令牌,需要获取到商品的令牌之后,才可以购买该商品
 */
private static volatile Map<Integer, Semaphore> goodsTokenMap = new HashMap<>();
public void buy(User operator, Integer goodsId) {
    // 当前内卖秒杀是否开启
    if (!BatchStatusEnum.SEC_kILL.equals(Constant.CURRENT_BATCH_STATUS)) {
        throw new RuntimeException("内卖秒杀还未开始");
    }
    // 用户购买商品数量是否已到限额
    Vector vector = userGoodsMap.getOrDefault(operator.getId(), emptyVector);
    if (vector.size() >= userBuyLimit) {
        throw new RuntimeException(String.format("您已购买%s件商品,无法再购买", userBuyLimit));
    }
    // 用户是否已购买
    if (vector.contains(goodsId)) {
        throw new RuntimeException("每件商品限购一件,您已购买该商品,无法再购买");
    }
    // 商品是否还有存量
    if (goodsRemainCountMap.get(goodsId).get() < 1) {
        throw new RuntimeException("该商品已被抢购一空");
    }
    // QPS限流
    boolean pass = qpsLimter.tryAcquire();
    if (!pass) {
        throw new RuntimeException("竞争太激烈了,请重试");
    }
    // 获取令牌
    Semaphore userTokens = userTokenMap.get(operator.getId());
    try {
        userTokens.tryAcquire();
        Semaphore goodsTokens = goodsTokenMap.get(goodsId);
        try {
            goodsTokens.tryAcquire(10);
        } catch (Exception ex) {
            goodsTokens.release();
            throw ex;
        }
    } catch (Exception ex) {
        logger.warn("秒杀请购失败:" + ex.getMessage(), ex);
        userTokens.release();
        throw new RuntimeException("竞争太激烈了,请重试");
    }
    // 购买成功,记录相关信息
    userGoodsMap.get(operator.getId()).add(goodsId);
    goodsRemainCountMap.get(goodsId).decrementAndGet();
}

3、内卖过程中遇到的问题

  1. 内卖刚开始就崩掉了,原因是前端资源加载有瓶颈。
    从来没写过前端的我,从来没想过,前端加载竟然竟然会是个瓶颈,我一直以为只要我后端hold住就万事大吉了。然后能怎么办呢?大家就随缘进入购物页面了。

  2. 秒杀购物体验很差。
    商品列表没有展示剩余库存,也没有展示已购买订单,所以大家的购物体验就是:进入商品列表,然后点点点,买到没,不知道。

  3. 因为都使用机器内存做缓存,所以服务如果重启就会丢失数据。可是最后生成订单数据时,意外报错了。幸好排查之后发现是数据异常导致的,删除异常数据之后,就能正常生成订单了。如果是代码bug的话,那我给大家伙跪下求原谅了。

  4. 只关注了主要的秒杀流程,做了各种并发控制,但是用户注册、地址填写等没有做并发控制,导致一个人多个账户、一个账户多个收货地址等数据异常情况。

4、回顾总结

  1. 不可重复操作,一定要做好并发控制。
    不能认为在业务流程上不存在并发问题,就不需要做并发限制处理。

  2. 完善的测试与压测。
    问题无法完全避免,但是完善的测试与压测能帮助我们尽量去避免问题。
    测试与压测,需要尽可能的去模拟真实用户的使用场景,这样才能发现更多的问题,比如前端资源瓶颈。

  3. 迫不得已的情况下,选择机器内存做缓存,这个可以理解,但是没有做好缓存持久化,导致秒杀开始后,重启项目就会丢失数据,一切归零,这是整个方案的最大风险点。

  4. 没有做完善的数据监控,导致时候无法回顾整个秒杀过程中的各种性能指标,尤其是qps,幸好还有第二次内卖,不然装逼都没机会了。

四、第二次内卖

1、前言

这次时间充裕,后端还有三个人,人力是足够的。美中不足的是,直到秒杀开始前,也没找到前端小伙伴,导致只能在以前的后端接口基础上做修改,原本设想的所有设计前端的优化、新功能点都无法做。

2、技术方案设计

  1. 内卖整体业务流程(链接)
    在这里插入图片描述
  2. 秒杀下单流程(链接)
    MySQL订单入库限流,主要是为了避免MySQL被压垮
    实际下单落库,只需要在关系表中增加一个用户id与商品id的关联关系即可,MySQL语句是极其简单的,考虑到用户量与商品都不是特别大,而且限流之后qps也不会特别高,MySQL的处理速度足以满足下单需求,所以这里直接同步方式落库,而不采用异步落库方式。
    在这里插入图片描述
  3. 核心秒杀代码:
public void buy(User operator, Integer goodsId) {
    // 内卖是否开始
    checkBatchProcess();
    // 商品是否还有库存
    String goodsCountKey = String.format(GOODS_REMAIN_FORMAT, goodsId);
    Integer goodsRemain = cacheService.getIntOrDefault0(goodsCountKey);
    if (goodsRemain < 1) {
        throw new RuntimeException("该商品已无库存");
    }
    // 用户是否还有额度
    BatchConfig currentBatch = batchConfigService.getCurrentBatch();
    String userBuyCountKey = String.format(USER_BUY_COUNT, operator.getId(), currentBatch.getId());
    Integer userBuyCount = cacheService.getIntOrDefault0(userBuyCountKey);
    if (userBuyCount >= currentBatch.getBuyLimit()) {
        throw new RuntimeException("您已达到购买限额,无法再购买商品");
    }
    // 用户是否已购买该商品
    String boughtGoodsKey = String.format(USER_BOUGHT_GOODS, operator.getId());
    if (cacheService.isMember(boughtGoodsKey, goodsId)) {
        throw new RuntimeException("您已购买过该商品,每人每件商品限购一件");
    }
    // 获取商品锁
    String goodsLockKey = String.format(GOODS_COUNT_MODIFY_LOCK, goodsId);
    boolean success = false;
    //if (cacheService.setnx(goodsLockKey, System.currentTimeMillis())) {
    if (cacheService.set(goodsLockKey, String.valueOf(System.currentTimeMillis()), "NX", "PX", DEFAULT_GODOS_EXPIRE_TIME)) {
        try {
            goodsRemain = cacheService.getIntOrDefault0(goodsCountKey);
            if (goodsRemain < 1) {
                throw new RuntimeException("该商品已无库存");
            }
            // 获取用户锁
            String userLock = String.format(USER_BUY_LOCK, operator.getId(), currentBatch.getId());
            //if (cacheService.setnx(userLock, System.currentTimeMillis())) {
            if (cacheService.set(userLock, String.valueOf(System.currentTimeMillis()), "NX", "PX", DEFAULT_USER_EXPIRE_TIME)) {
                try {
                    userBuyCount = cacheService.getIntOrDefault0(userBuyCountKey);
                    if (userBuyCount >= currentBatch.getBuyLimit()) {
                        throw new RuntimeException("您已达到购买限额,无法再购买商品");
                    }
                    // MySQL限流
                    int mysqlQpsLimit = ConfigService.getAppConfig().getIntProperty("redersale.mysql.qps.limit", 500);
                    try {
                        if (cacheService.incrBy(MYSQL_QPS_LIMIT, 1) > mysqlQpsLimit) {
                            throw new RuntimeException("购买失败,请重试");
                        }
                        // 成功购买商品
                        cacheService.setByDefaultExpire(goodsCountKey, goodsRemain - 1);
                        cacheService.setByDefaultExpire(userBuyCountKey, userBuyCount + 1);
                        cacheService.addMemberToSet(boughtGoodsKey, goodsId);
                        goodsOrderMapper.insertOrder("秒杀下单", currentBatch.getId(), operator.getId(),
                                operator.getRedName(), goodsId);
                        success = true;
                    } finally {
                        cacheService.incrBy(MYSQL_QPS_LIMIT, -1);
                    }
                    // 获取mysql的qps
                } finally {
                    cacheService.delete(userLock);
                }
            }
        } finally {
            cacheService.delete(goodsLockKey);
        }
    }
    if (!success) {
        throw new RuntimeException("抢购失败,请重试");
    }
}

3、回顾总结

  1. 主要指标:
    • 峰值qps:3k
    • 卖出商品sku数量:2313
    • 生成订单数量:11238
    • 售卖总金额:609,653
  2. 相比第一次,不仅秒杀期间,整个app没有崩溃,而且秒杀使用体验也比第一次好了很多,算是比较成功了。
  3. 数据监控
    做任何活动,需要充分考虑到运维监控的需求,作为业务方,需要知道当前关键业务数据的趋势情况,作为技术方,需要知道当前关键技术指标情况(判断服务是否还能支撑的住)。在第二次内卖中,考虑到了数据监控的需求,这点不错,但是部分数据监控是人工每次sql查询的,可以改成程序自动查询并在相关业务群里报数会更好点。
  4. 异常处理预案
    这次内卖提前准备了预案,比如当用户锁未正常释放时,手动为用户释放等等,这也是比较可喜的进步了。
  5. 技术评审
    复杂、重要的业务,需要有技术评审环节,群策群力,独自一人制定方案,难免会有各种遗漏。
  6. 信息沟通
    要有一个统一的群,用来活动各方及时沟通各种信息。所有相关信息,要有文档记录,并且文档目录要放在沟通群的公告中,方便随时查找。
  • 7
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值