学堂 学堂 学堂公众号手机端

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

lewis 1年前 (2024-04-12) 阅读数 9 #技术


文章目录​​一、背景介绍​​​​二、鸣谢​​​​三、为什么会决定参与内卖?​​​​四、第一次内卖​​​​1. 前言​​​​2. 技术方案设计​​​​3、内卖过程中遇到的问题​​​​4、回顾总结​​​​四、第二次内卖​​​​1、前言​​​​2、技术方案设计​​​​3、回顾总结​​一、背景介绍

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

二、鸣谢

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


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

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

四、第一次内卖1. 前言

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

2. 技术方案设计缓存:不使用redis,采用单机内存做缓存
我当时刚跑路到小红书,在内部基础设施服务的使用上,接连踩了各种各样的坑,在时间如此紧张的情况下,我对于接入公司内部redis实在是没有信心。不仅是redis,内部基础设施服务都是能不用就不用。采用java的Semaphore来做限购。
内卖限制每个人只能购买四件商品,那么就用Semaphore来做令牌发放,获取到令牌的请求才能进行购买,购买失败就返回令牌,购买成功则不返回。限流:用guava的RateLimiter。
因为是单机器,怕撑不住,所以得加一个限流才行。内卖整体业务流程
内卖原则上不支持退货,用户收到货物之后,根据实际到货情况和货物质量,扫描官方支付宝付款。
(​​流程图链接​​)

秒杀流程
每人最多购买四件商品,每件商品限购一件
(​​流程图链接​​)

核心秒杀代码:
/**
* 用户购买商品的数量限制
*/
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、内卖过程中遇到的问题内卖刚开始就崩掉了,原因是前端资源加载有瓶颈。
从来没写过前端的我,从来没想过,前端加载竟然竟然会是个瓶颈,我一直以为只要我后端hold住就万事大吉了。然后能怎么办呢?大家就随缘进入购物页面了。秒杀购物体验很差。
商品列表没有展示剩余库存,也没有展示已购买订单,所以大家的购物体验就是:进入商品列表,然后点点点,买到没,不知道。因为都使用机器内存做缓存,所以服务如果重启就会丢失数据。可是最后生成订单数据时,意外报错了。幸好排查之后发现是数据异常导致的,删除异常数据之后,就能正常生成订单了。如果是代码bug的话,那我给大家伙跪下求原谅了。只关注了主要的秒杀流程,做了各种并发控制,但是用户注册、地址填写等没有做并发控制,导致一个人多个账户、一个账户多个收货地址等数据异常情况。4、回顾总结不可重复操作,一定要做好并发控制。
不能认为在业务流程上不存在并发问题,就不需要做并发限制处理。完善的测试与压测。
问题无法完全避免,但是完善的测试与压测能帮助我们尽量去避免问题。
测试与压测,需要尽可能的去模拟真实用户的使用场景,这样才能发现更多的问题,比如前端资源瓶颈。迫不得已的情况下,选择机器内存做缓存,这个可以理解,但是没有做好缓存持久化,导致秒杀开始后,重启项目就会丢失数据,一切归零,这是整个方案的最大风险点。没有做完善的数据监控,导致时候无法回顾整个秒杀过程中的各种性能指标,尤其是qps,幸好还有第二次内卖,不然装逼都没机会了。四、第二次内卖1、前言

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

2、技术方案设计内卖整体业务流程(​​链接​​)

秒杀下单流程(​​链接​​​)
MySQL订单入库限流,主要是为了避免MySQL被压垮
实际下单落库,只需要在关系表中增加一个用户id与商品id的关联关系即可,MySQL语句是极其简单的,考虑到用户量与商品都不是特别大,而且限流之后qps也不会特别高,MySQL的处理速度足以满足下单需求,所以这里直接同步方式落库,而不采用异步落库方式。

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


版权声明

本文仅代表作者观点,不代表博信信息网立场。

热门