一、背景介绍
公司内部福利社的同事牵头组织,将福利社的退货商品,低价售卖给公司内部员工,算是员工福利吧。 内卖举办过挺多次了,这里仅记录我参与的两次内卖。
二、鸣谢
特别感谢雪兔、小伊、白明、安迪,留白、云流,公生、阿力、路飞、莫一兮 感谢其他在内卖过程中给与各种各样支持的同事们
三、为什么会决定参与内卖?
套用经典名言:如今机会就在眼前,我不知道何时才能再有机会去参与一个真实的秒杀项目。 对于程序猿来说,秒杀是一个经典的高技术难度场景,绝佳的镀金项目。 在这里要感谢公司的ExtraMile计划,才能给与我这个机会,在公司内,去做一些非本职工作的项目,去做一些技术、业务上的挑战。纸上得来终觉浅,看再多的书籍、博客,如果没有在真实业务场景下去实现过,就只能叫纸上谈兵,吹牛都没人信的。
四、第一次内卖
1. 前言
第一次内卖,后端开发人员只有我一个。 因为仓库那边堆积压力很大,所以从我接到任务开始,开发时间只有一周多,还要尽量不影响工作,所以技术方案设计的时候,快速实现就很重要,一切不稳定因素都应当剔除。
2. 技术方案设计
-
缓存:不使用redis,采用单机内存做缓存 我当时刚跑路到小红书,在内部基础设施服务的使用上,接连踩了各种各样的坑,在时间如此紧张的情况下,我对于接入公司内部redis实在是没有信心。不仅是redis,内部基础设施服务都是能不用就不用。 -
采用java的Semaphore来做限购。 内卖限制每个人只能购买四件商品,那么就用Semaphore来做令牌发放,获取到令牌的请求才能进行购买,购买失败就返回令牌,购买成功则不返回。 -
限流:用guava的RateLimiter。 因为是单机器,怕撑不住,所以得加一个限流才行。 -
内卖整体业务流程 内卖原则上不支持退货,用户收到货物之后,根据实际到货情况和货物质量,扫描官方支付宝付款。 (流程图链接) -
秒杀流程 每人最多购买四件商品,每件商品限购一件 (流程图链接) -
核心秒杀代码:
public static volatile Integer userBuyLimit = 0;
public static final RateLimiter qpsLimter = RateLimiter.create(100);
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("该商品已被抢购一空");
}
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.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.set(userLock, String.valueOf(System.currentTimeMillis()), "NX", "PX", DEFAULT_USER_EXPIRE_TIME)) {
try {
userBuyCount = cacheService.getIntOrDefault0(userBuyCountKey);
if (userBuyCount >= currentBatch.getBuyLimit()) {
throw new RuntimeException("您已达到购买限额,无法再购买商品");
}
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);
}
} finally {
cacheService.delete(userLock);
}
}
} finally {
cacheService.delete(goodsLockKey);
}
}
if (!success) {
throw new RuntimeException("抢购失败,请重试");
}
}
3、回顾总结
- 主要指标:
- 峰值qps:3k
- 卖出商品sku数量:2313
- 生成订单数量:11238
- 售卖总金额:609,653
- 相比第一次,不仅秒杀期间,整个app没有崩溃,而且秒杀使用体验也比第一次好了很多,算是比较成功了。
- 数据监控
做任何活动,需要充分考虑到运维监控的需求,作为业务方,需要知道当前关键业务数据的趋势情况,作为技术方,需要知道当前关键技术指标情况(判断服务是否还能支撑的住)。在第二次内卖中,考虑到了数据监控的需求,这点不错,但是部分数据监控是人工每次sql查询的,可以改成程序自动查询并在相关业务群里报数会更好点。 - 异常处理预案
这次内卖提前准备了预案,比如当用户锁未正常释放时,手动为用户释放等等,这也是比较可喜的进步了。 - 技术评审
复杂、重要的业务,需要有技术评审环节,群策群力,独自一人制定方案,难免会有各种遗漏。 - 信息沟通
要有一个统一的群,用来活动各方及时沟通各种信息。所有相关信息,要有文档记录,并且文档目录要放在沟通群的公告中,方便随时查找。
|