当前位置: 首页 > 后端技术 > Java

干货-1分钟售出8万张门票!抢票背后的技术思考

时间:2023-04-01 23:36:00 Java

一、背景去年疫情过后,为加快启动旅游市场,湖北在全区开展“爱行·惠鄂”活动——全省A级旅游景区向全国游客开放,门票免费,张开双臂欢迎全国人民。本文将介绍在线预订抢票系统在本次活动中遇到的核心问题、系统的改造过程以及实施中的一些经验。这是在高并发、高可用场景下提升系统稳定性的实用优化。希望能为面临同样问题的同学提供一些参考思路。活动页面二、风险与挑战活动前期,系统面临四大风险:人流量大,入口流量瞬间暴增100倍,远超系统承载能力;高并发,服务稳定性下降;限购错误;热门门票、热门出行日期库存热点扣费;高并发下的系统挑战下面我们来看看每个问题的影响和解决策略。2.1入口流量增加100倍问题活动开始时入口流量增加100倍,当前系统无法通过横向扩展解决问题。请求量监控目标是提高入口应用的吞吐量,减少下游调用量。减少依赖的策略1)去除0元票场景下不必要的依赖。例如:打折、立即打折;2)合并重复IO(SOA/Redis/DB),减少一次请求中重复访问相同数据。上下文传输对象减少重复IO,提高缓存命中率。这里说的是接口级缓存,数据来源依赖于下游接口,如下图:服务层-接口级缓存-固定过期接口级缓存一般采用固定过期+懒加载的方式来缓存下游接口返回的对象或自定义DO对象。当请求进来时,首先从缓存中获取数据。如果缓存命中,则返回数据。如果没有命中缓存,则从下游获取数据重建缓存。由于是接口级缓存,所以过期时间一般设置的比较短。流程如下:固定过期+懒加载缓存的缓存方案存在击穿和穿透的风险。在高并发场景下,缓存击穿和缓存穿透的问题会被放大。下面就系统中常见的这几类问题进行介绍。如何解决它。1)缓存击穿说明:缓存击穿是指数据库中有,缓存中没有。比如:某个key访问量非常大,属于集中式高并发访问。此刻key失效,大量请求突破缓存,直接向下游(接口/数据库)请求,造成下游压力过大。解决方案:在缓存中加入被动刷新机制,在缓存实体对象中加入上次刷新时间,请求进来后从缓存中获取数据返回,然后判断缓存是否满了再刷新条件,如果满足,则异步获取数据并重建缓存,如果不满足,则本次不更新缓存。通过用户请求异步刷新,更新租约到期时间,避免缓存固定到期。例如:商品描述信息,之前的缓存过期时间是5分钟,现在缓存过期时间是24小时,被动刷新时间是1分钟。用户每次请求返回之前的缓存,但是缓存是异步每1分钟构建一次。2)缓存穿透说明:缓存穿透是指数据库或缓存中不存在的数据。当用户不断发起请求,比如获取ID不存在的数据时,无法命中缓存,导致下游压力过大。解决方案:当缓存未命中,下游没有取到数据时,缓存实体内容为空对象,缓存实体添加穿透状态标志。这类缓存的过期时间设置得比较短。默认为30秒过期和10秒刷新以防止失败。现有id重复访问下游,大部分场景有少量渗透,但部分场景恰恰相反。例如,某类规则配置仅适用于少数产品。在本例中,我们将穿透类型的缓存过期时间和刷新时间设置为与正常过期和刷新时间相同,防止下游频繁请求无数据。3)异常降级当下游出现异常时,缓存更新策略如下:缓存更新:下游为非核心:写入一个短期的清空缓存(例如:30s过期,10s刷新)防止下游超时并影响上游服务的稳定性。下游是核心:缓存不会在异常发生时更新,下次请求时会更新,防止写空缓存阻塞核心进程。4)缓存模块化管理根据数据来源对缓存键进行分类。每种类型的键对应一个缓存模块名称。每个缓存模块都可以动态设置版本号、过期时间、刷新时间,并统一埋藏和监控。经过模块化管理,缓存过期时间的粒度更加细化。通过分析缓存模块命中率监控,可以推断出过期和刷新时间是否合理。最后动态调整缓存过期时间和刷新时间,达到最佳命中率。缓存模块命中率可视化埋点我们将以上功能封装为一个缓存组件。在使用的时候,我们只需要关心数据访问的实现。这样既解决了缓存本身使用中的一些常见问题,又降低了业务代码与缓存读写的耦合度。.下图是优化前后缓存使用过程对比:缓存使用对比效果解决了缓存穿透击穿、异常降级、缓存模块化管理,最终缓存命中率提升到98%以上,接口性能(RT)提升50%以上,上下游调用比例从1:3.9降低到1:1.3,下游接口调用量减少70%。处理性能提高了50%。2.2高并发下服务稳定性低。每天早上8:00开始抢票活动时,DB连接池爆满,线程波动大,商品服务超时。数据库线程波动想想为什么DB连接池满了?为什么API会超时?是DB不稳定影响API,还是API流量过大影响DB?问题分析1)为什么DB连接池满了?分析三种类型的SQL日志。Insert语句过多——场景:限购记录提交,限购表分离隔离后,商品API仍然超时(排除)Update语句耗时过长——场景:扣库存热点(重点排查)选择高频查询——场景:商品信息查询2)为什么API会超时?从问题排查日志可以看出,8:00活动开始后,在DB中发现了大量热门商品信息查询,与Select的高频查询一致。3)是DB不稳定影响API,还是API流量过大影响DB?根据#2,初步判断是由于cachebreakdown导致大量流量渗透到DB。为什么缓存坏了?梳理系统架构后发现,由于8:00的预定可用性受offlinejob控制,8:00产品上线导致数据变化,数据变化导致缓存刷新(删除首先,然后添加)。在缓存过期的瞬间,服务器上的流量中断到DB,导致服务器上的数据库连接池爆满,也就是上面说的缓存崩溃现象。数据访问层-表级缓存-主动刷新如下图商品信息变更后,缓存主动过期,用户访问时重新加载缓存:数据访问层缓存刷新架构(旧)-messagechangedeletecacheKeytargettopreventactivity当缓存被删除时,缓存被分解,流量渗透到DB。采用了以下两种策略:1)为了避免活动期间数据更新导致缓存失效,我们将商品的可售状态拆分为可见状态和可售状态。状态可见:提前7:00对外可见,避开高峰时段;Availablestatus:定时销售的逻辑判断,既解决了定时在线修改数据后缓存被刷新的问题,又解决了Job上线后商品销售状态的Latency问题。逻辑判断定时可以卖掉,避免峰值缓存击穿2)调整缓存刷新策略原先的缓存刷新方案(先删后加)存在缓存击穿的风险,因此调整后续缓存刷新策略覆盖updateto避免缓存击穿磨损导致的缓存失效。新的缓存刷新架构通过Canal监听MySQLbinlog发送的MQ消息,在消费端聚合后重建缓存。数据访问层缓存刷新架构(新)——消息变更重建缓存效果服务(RT)正常,QPS提升至21w。以上两类问题与具体业务无关。下面介绍两个业务痛点:如何防止恶意采购(限购)和如何防止库存少买/多买(扣除库存)2.3限购什么是限购?限购就是限制购买,规定购买的数量,往往是一些特价、降价的产品。为防止恶意抢购而采用的一种商业手段。限购规则(最多几十种组合)例如:1)每张身份证同一出行日期同一景区只能预订一张身份证;2)7天内(预订日期)同一区域仅限预订3个景点,限购20份;3)活动期间,预订次数超过5次,noshow购买limit没玩过;库存扣减失败,限购取消成功(实际数据不一致),对第二次预约施加限购。原因是限购提交是Redis和DB的双写操作。Redis是同步写入,DB是线程池异步写入。当请求量过大时,线程队列会积压,最终导致Redis写入成功,DB写入延迟。限购记录提交成功但未扣除库存,需要取消限购记录。如下图所示:购买限额检查-提交购买限额-取消购买限额在高并发场景下,线程池队列中会积压提交的购买限额记录。Redis写入成功后,DB还没有写入。此时取消限购,删除Redis成功,DB删除未找到的记录,最后提交限购记录写入,再次预订时,再次进行限购。如下图:线程队列积压,先提交的“提交限购”请求晚于“取消限购”目标。服务稳定,限购准确。策略保证Redis/DB取消限购的操作是最终一致的。由于提交限购记录可能会出现积压,取消限购时提交的限购记录还没有写入,导致取消限购时无法删除对应的提交记录。我们通过延迟消息来补偿重试,以确保取消购买限制操作(Redis/DB)最终一致。取消限购时,当删除限购记录影响的行数为0时,发送MQ延迟消息,在Consumer端消费该消息,重试取消限购,查看核心指标是否异常通过埋点和监控。如下图:下单-提交限购和取消限购,限购效果准确,无误拦截投诉。2.4库存扣款问题商品后台显示1w已售罄,实际售出5000,导致库存未售出。MySQL存在行级热锁,影响推导性能。原因是库存扣减和库存明细SQL不在同一个事务中。大量扣款时容易出现局部故障,导致库存记录与明细不一致。热门景点的热门旅游日期被密集预订,导致MySQL中出现库存扣减热点。目标库存扣减精准,加工能力提升。策略1)将扣款库存记录和扣款明细放在一笔交易中,保证数据的一致性。DB事务抵扣库存效果优势:数据一致性。缺点:热点资源、热门日期、扣库存行级锁时间变长,接口RT变长,处理能力下降。2)使用分布式缓存,预先减少分布式缓存中的库存,减少数据库访问。秒杀产品异步扣减DBpeaks,非秒杀产品正常流程。产品上线时,库存写入Redis。activity扣减库存时,使用incrby原子扣减发送扣减消息MQ,在消费端执行库存的DB扣减。如果下单失败,执行库存退货操作也是先操作Redis,再发送MQ。在消费者端,执行DB返回库存。如果没有查到扣款记录(可能是库存MQ扣款有延时),延时重试,通过埋芯和监控检查指标是否异常。异步扣库存的效果是服务RT稳定,数据库IO稳定。Redis推演有热点迹象。3)库存的缓存热点和桶扣除。当单个key的流量达到单个Redis实例的承载能力时,需要对单个key进行拆分,以解决单实例热点问题。由于热点票热门日期导致的热点key问题,经过观察监控,并不是特别严重。临时拆分Redis集群是为了减少单个实例的流量,缓解热点问题。所以缓存热点的bucketing扣库存还没有实现。这里简单描述一下当时讨论的思路。如下图:缓存热点分桶,从桶和库存中扣除:在秒杀开始前提前锁定库存修改,并执行分桶策略,根据库存的型号分成N个桶ID。每个bucket对应的cachekey为Key[0~N-1],每个子bucket保存m个库存并初始化到Redis,秒杀时根据Hash(Uid)%N路由到不同的bucket进行扣分,所以以解决所有访问单个Key的流量对单个Redis实例造成压力的问题。桶缩水:正常情况下,热门活动每个桶中的存货会经过几轮扣减后降为0。特殊场景下,每个桶中可能只剩下个位数库存,预订时的份数大于剩余库存,导致扣款不成功。例如:桶数为100个,每个桶有1~2个存货,当用户下单3个时扣款失败。当存货不足十位时,减少桶数,防止用户看到有是存货,扣款总是失败。优化前后对比库存扣除方案对比三.回顾总结回顾“湖北爱心行”整个活动,我们整体是这样备战的:梳理风险点:包括系统架构、核心流程,识别后制定应对措施;流量预估:根据票量、历史PV、高峰节假日预估活动的峰值QPS;全链路压测:对系统进行全链路压测,对峰值QPS进行压测,发现问题,优化改进;限流配置:为系统配置满足业务需求的安全限流阈值;应急预案:收集各领域可能存在的风险点,制定应急预案;监控:观察活动中的各项监控指标,如有异常按计划处理;回顾:事后分析日志,监控指标,故障分析,持续改进;本文阐述了抢票活动中遇到的四个具有代表性的问题。在优化过程中,我们不断思考和落实技术细节,沉淀核心技术,最终达到让用户顺利预订入园,体验良好体验的目标。