这几年工作中做过不少营销活动,这里以抽奖活动为例,讨论一下如何设计出一个高可用、高并发的营销系统。
高可用、高并发架构的核心是分流和限流。系统架构时,应根据每一种营销活动的场景与特性,制定不同的分流、限流方案。
一 业务
在开始进行架构讨论前,我们的简单描述一下业务,以方便我们有针对性的进行讨论。
1. 业务需求
公司希望拉更多的新用户来注册我们的app,所以想通过一个抽奖活动,用一些奖励和刺激的手段来促使用户使用我们的app,并通过奖励手段让用户帮我们一起推广宣传。业务主要诉求如下:
用户需要在app中或者活动页面(H5)中进行某些操作,以获取抽奖机会;例如分享给好友以后奖励一次抽奖机会。
每天每人最多拥有N次抽奖机会。
活动结束后给中奖用户发奖品。
每个人只能中一次奖。
……
2. 抽奖主流程
这是系统的核心流程,也是后面进行性能优化的核心内容。
3. 分析
对活动而言,最重要的是曝光量,只有更多的分享与传播,才能让更多的人参与进来,活动效果才越好,对技术上的要求就是能够提供尽可能好的用户体验。
抽奖活动的页面中,大部分内容都是静态的,只有抽奖按钮需要与服务端交互;对此可以通过客户端、CDN缓存等提升用户体验。
营销活动是拿真金白银来推广,所以需要有效的机制能防止被人恶意攻击,将全部奖品刷走。
如果活动中奖品价值都较高,此种情况下,通常奖品数量有限,中奖概率不高,所以即便是抽奖的并发量较大,实际写DB的压力也并不大,即在扣减奖品库存、向DB中插入中奖记录等环节的写压力并不会大。
如果活动发放类似于优惠券的虚拟奖品,数量可能很大,中奖概率非常高,那么当抽奖并发量大时,写DB的压力会很大。
二 核心指标
1. 高并发
营销活动对系统性能的要求,一般来说比常规业务高出很多;如果推广的效果比较好,流量可能会远超初始预期。基于此,系统应该能够支持较高的并发,并且在流量快速上升并超过容量规划时,能够及时、方便扩容。
2. 高可用
核心是提供尽可能好的性能支持,让更多人能够使用我们的产品。具体见高性能优化部分内容
3. 隔离性
营销活动不能影响常规业务;因为营销活动变动频繁,修改时应尽可能的避免影响到其他业务;前一次活动应该能够沉淀出一些内容,降低下一次类似的活动的实现成本。
4. 低耦合
根据上面的需求我们可以看到,营销活动旺旺与主业务有交集。营销活动的特点是变化频繁,定制化需求多,应尽量降低营销活动与常规业务的耦合性。
可以考虑通过旁支流程来解决此问题。例如,用户在app上操作以后奖励抽奖机会这个业务,将用户的行为作为事件,通过MQ消息向外广播,营销活动监听相关的MQ消息,根据MQ消息给用户奖励抽奖机会。
5. 防刷
在营销活动利益的驱动下,常常会有人找系统漏洞,钻系统空子,通过一些手段来刷系统奖品,影响营销效果。一般而言,有以下常规问题需要防范:
防恶意注册用户
防恶意调用接口
防奖品被少部分人获取
防用户误操作
三 架构
高并发的核心思路是逐级分流,逐步分散流量。
高可用的核心思路是通过备份、限流、降级、熔断等手段提升可用性。
1. 整体思路
充分分流,通过缓存、队列、消息等手段将大流量逐步分散。
以空间换时间,通过缓存来提升每个请求的处理速度,提升系统并发量。
异步处理,分析并识别出可以异步处理的逻辑,将他们异步化。
分层逐级拦截非法请求,仅让有效的请求到达底层系统;尽可能让每一次写操作都能成功。
根据每个系统的服务能力,设定流量极限,流量超过极限值后进行限流。
2. 分阶段优化模型
为了达到最高的并发性能,需要对整个环节的各个环节进行优化。现实中往往通过如下的倒三角形模型来逐层优化。
注意:这里不过多讲述概念性的内容,而是根据抽奖的需求讲述一些常规的方案;更多内容见博客《高并发技术》
客户端优化方案有:客户端(app、浏览器)缓存。
网络优化方案有:动静分离、静态化
服务端优化包括:缓存(硬盘、堆、分布式)、通信模型优化、异步化技术、线程池、DB存储优化。
响应优化包括:返回结果压缩。
3. 缓存策略
通过分层缓存策略,对每一个环节进行有优化,提升性能。
客户端(或浏览器)缓存:减少用户请求数量,降低系统压力,提升用户体验。
CDN缓存:合理地利用CDN,内容缓存放置在离用户最近的地方,加快响应的速度。
Web服务器缓存:减小后端应用服务器压力,抵挡瞬间峰值和/或针对少量定点内容的攻击。
应用层缓存:减小后端应用服务器压力,加快响应速度。
4. 分流模型
从图中可以看到,经过前面的逐层分流,最终只有少部分请求会进行DB操作。当绝大部分的流量都被通过种种手段分担,系统自然就可以轻松处理剩余的少量流量,这也就意味着系统可以具有非常高的性能。
四 高并发优化
1. 逐级分流
1) 客户端缓存
我们的营销活动都是通过H5来承接的,app通过webview来加载或者直接通过浏览器加载数据时,可以利用浏览器缓存或者手机SD卡,将数据缓存在客户端,例如图片、CSS、JS文件等。
越多的内容从本地缓存读取,响应速度会越快;不过需要注意缓存数据的更新问题,以及内存占用问题。
2) 动静分离
一个抽奖活动页面,绝大多数树内容都是静态的图片、css、js等,极端情况下可能只有一个抽奖按钮需要与服务端交互。通过将图片、css、js等内容存储在CDN节点中,用户浏览活动页面时,直接从距离用户最近的CDN节点获取静态资源,不用和服务端交互。优化模型如下:
在营销活动的场景中,使用此种方案时,至少可以让90%-95%的流量不用与服务端交互,可以节省大量带宽,对性能提升非常明显。
3) 防误操作
用户点击抽奖按钮,调用服务端接口时,当请求的响应速度比较慢时,如果客户端不做处理,用户会感觉到卡顿;此种情况下用户和可能会反复点击抽奖按钮,这无疑会进一步加大服务端的访问量,服务端的响应速度可能进一步降低;另外因为每点击一次都需要消耗一次抽奖机会,因一次卡顿导致用户耗费多次抽奖机会也是不合适的。
这种问题的处理方式比较多,例如:可以通过在H5端通过js控制,点击抽奖按钮以后,在请求返回之前将按钮置灰,禁止用户再次点击或者弹出一个loading动画来解决此问题。
这种方案在服务器压力大,响应慢时效果比较明显;另外,可以明显的提升用户体验。
4) 防代码请求
如果活动的奖励足够吸引人,就可能有第三方通过代码直接调用抽奖接口。因为代码的执行速度远快于手的点击动作,这可能给营销活动带来非常大的危害,同时也会给系统带来非常大的并发压力。因为风控的性能损耗较大,所以在营销活动中一般不能通过风控系统来解决此问题。
一般而言,每一家公司都会有自己的安全策略,保证请求的安全可靠,应尽可能让第三方伪造此流程的难度加大;除此之外,运营活动中要能够识别出异常流量,做好应对措施,尽可能的降低危害。
a) 伪造一个用户
多个线程中都使用相同的用户进行请求。这种场景比较好解决,通过userId加一个分布式锁即可解决。
b) 伪造多个用户
每个线程使用不同的用户进行请求。这种比较麻烦,常见处理手段有:
在用户注册时加限制,尽可能杜绝通过代码自动注册的行为,让每一个注册的用户尽量可靠。
和业务沟通,限制每个用户每天获取抽奖机会的次数,限制每人中奖次数。
抽奖机会通过用户的行为产生。例如:到社区对帖子评论以后才给一次机会,这将增大用户伪造请求的难度。
如果发现一定时间内某个ip下的流量明显异常,可以对此ip进行限流。例如:一个ip的请求量超过1000/min时,拒绝此ip的请求。注意,此方案可能有误判,因为当多人使用同一个局域网时,此时服务端收到的ip可能是相同的。
添加小黑屋功能,发现恶意用户、ip,将他们拉入小黑屋,一段时间内禁止他们后续的访问。
5) 业务规则拦截
根据前文中的需求我们可以得出:同一个用户只允许中一次奖;每天最多抽奖三次。当收到抽奖请求时,我们可以判断是否符合这这些规则:
可以将中奖用户放在缓存中,收到抽奖请求或抽中时,通过缓存校验此用户之前是否已中奖。
可以将抽奖次数放在缓存中,通过计数器自减的方式扣减抽奖机会。
2. 缓存
大量的运算将消耗大量服务器资源,频繁的DB操作也会给DB带来较大的压力,在高并发场景中,常常通过缓存运算结果或者DB记录,以降低资源消耗,提升请求的响应速度。
1) 缓存不常变动的数据
缓存非常适合存储不经常变动的数据,通过缓存他们可以极大的提升性能。
如,活动中奖品一般不会频繁改变,页面中需要展示奖品信息时,我们可以将奖品信息加载到缓存中,每次请求时直接读取缓存。
2) 缓存实时性要求不高的数据
业务上如果对某些数据的实时性要求并不高,那么也可以通过缓存降低系统的性能损耗,提升请求速度。
如:活动中需要播报中奖信息,业务上并不要求实时展示最新中奖的几人,所以可以通过启动一个线程,将最近一段时间内需要播报的名单加载到缓存中,播报时从缓存中直接返回数据。
3) 频繁变动的内容走缓存
频繁变动且持久化意义不大的数据非常适合走缓存。
如,用户完成一些任务以后,奖励一些抽奖机会,奖励的抽奖机会就很适合存储在缓存。使用redis或者tair的原子性加、减操作,每奖励一次抽奖机会,自增1,每抽奖一次,自减1。
4) 从缓存减库存
抽中奖品时,需要将此奖品总数减1,这其实就是一种减库存操作。并发情况下从DB中减库存,一来性能比较差,二来为了避免脏写可能导致DB更新失败概率大增。此时改为从缓存中减库存。
a) 减库存
活动开始前,将奖品库存加载到缓存。
抽奖时直接从缓存中扣除;如果扣除库存以后,剩余奖品数量>=0,表示扣减库存成功,然后发送MQ消息;如果库存扣除后,剩余奖品数量<0,表示扣减库存失败。
收到MQ消息后扣减DB中库存,插入中奖记录。(扣减商品库存不是一个必须的操作)。
此种方案的缺点是:活动期间如果需要修改库存信息,则需要特别注意数据一致性,避免出现奖品超发问题。
b) 缓存更新模型
使用此方案时,如果活动期间运营希望增加/减少奖品库存,需要先暂停活动,更新库存并刷新到缓存,然后取消暂停,让用户继续抽奖。暂停操作是为了防止因为异步扣减奖品库存而导致奖品超发。模型如下:
5) 选择合适的数据结构
缓存中间件,例如Redis提供了多种数据结构,分别适合不同场景的需求。使用时根据具体场景,选择最合适的数据结构,尽量选择时间复杂度底的数据结构,尽可能减少与缓存的交互次数。如:抽奖时需要扣减抽奖次数,有两种方式从缓存中扣减用户剩余抽奖次数:
方式一:先从查询当前剩余缓存;内存中扣减次数,最后将结果剩余次数重新放回缓存。
方式二:直接通过自减指令进行扣减操作,如果结果大于0,那么允许抽奖。
两种方式虽然都能满足需求(这里暂不考虑并发情况下更新缓存时的失败问题),但是第二种方式可以减少一次与缓存的交互。服务器与缓存服务器交换一次数据的时间约为1ms-3ms,即便一次请求只节省一次交互,当请求量达到1000w次,可为服务器累计节省2.7-8.3h;当并发量非常高时,这种性能损耗也是不能忽略的。
6) 使用堆缓存
使用分布式缓存时,因为每次与缓存交互都要消耗1ms-3ms时间(如果返回数据量较大时,由于IO瓶颈的存在,耗时可能进一步增加)。一个抽奖请求需要与缓存进行多次交互,当高并发时,累计出来的性能的损耗会非常明显,此时可以考虑将数据缓存在堆内存中。
例如:抽奖活动的各个页面中需要查看奖品信息,抽奖过程也需要根据奖品概率进行计算是否中奖,等等。如果每次都从缓存中取,性能损耗不可接受,那么可以将奖品信息放到内存中,彻底省略掉这部分与缓存交互的时间。
此方案的缺点是:服务器重启时内存中数据会丢失;DB数据变更以后,需要确保每台服务器都更新到最新的数据。
7) 缓存失效
使用缓存时,需要小心应对缓存击穿问题。在高并发场景中一般采用异步将数据刷新到缓存;请求过程中即便发现缓存中数据不存,也不会从DB中读取。有几种种常见的做法应该尽量避免:
缓存没有数据时,从db中获取,然后放到缓存中。此方式无法防止缓存击穿时,将流量全部倾泻到底层服务或DB上,而引起的雪崩。
通过锁让一个线程从DB中获取数据,然后存到缓存中,其他线程等待。这种做法一来编程麻烦,二来等待的线程较多时占用的内存也会比较大,服务器相应速度明显降低。
3. 异步发放奖品
一般而言,在高并发场景中,对性能影响最大的就是DB性能,如果通过同步方式写DB,那么DB的瓶颈将直接决定并发量(DB也存在多种优化策略)。
1) 异步方案分析
因为使用异步方式常常会带来优秀的性能,所以只要业务没有明确要求奖品需实时发放,都可和业务方沟通,考虑是否可以通过异步发放奖品。在我们的场景中,我们和业务方沟通,如果用户中奖,我们保证3分钟内会让用户在自己的账户中看到中奖纪录。而实际上99%以上的场景中,我们的异步化方案会在毫秒级别就可以将中奖纪录插入DB中。
一般来说每个奖品都是有数量的,每被人抽中一个,那么库存应该减一。如果多人同时抽中一个奖品,那么就存在并发写的问题。并发写是很容易失败的,失败以后重试也会损耗性能;另外应该保证奖品不会多发放。
a) MQ消息方案
中奖时发送一条MQ消息,收到MQ消息以后,将中奖信息入库。我们使用的是RocketMQ作为消息服务器,因为RocketMQ优秀的性能表现,只要我们消费过程不出现阻塞,消息可以在毫秒级别到达消费端;而当消费端阻塞时,可以通过增加服务器来水平扩展。目前有多中成熟的MQ服务器可供选择,可以根据公司的实际情况进行选择。
b) 日志方案
将中奖纪录写在服务器所在的磁盘文件中,另外启动线程从磁盘文件中读取中奖纪录然后入库。使用此种方案时需要注意,读文件和写文件的并发性问题。
c) 分布式队列方案
使用分布式队列,如Redis的队列,中奖时通过LPUSH命令向队列中加入中奖记录数据,每个服务器启动一个线程通过RPOP命令从队列中获取中奖记录数据,然后入库。因为Redis的性能很好,所以往Redis队列中写记录的速度非常快,抽奖过程的性能损耗非常小,性能自然也就提上来了。
以上是常见的几种处理手段,其实所有分布式队列、消息队列都可以作为设计时的考虑对象,我们在选用的是使用RocketMQ来异步化
2) 抽奖流程改进
通过缓存控制库存扣减,减库存成功,则通过MQ消息将中奖事件发送出去;返回用户中奖信息;异步收MQ消息,然后对DB中的记录进行减库存操作。
因为进行了异步化操作,所以不会因为写DB的性能影响抽奖流程的并发量。
4. 流程优化
1) 流程顺序调整
现实中,为了追求更高的性能,可能会对流程进行定制优化。例如:如果整个活动中奖概率比较低,那么可以考虑将部分验证逻辑向后移,以减少每个请求的耗时,如下图所示:
和前面的流程图相比,唯一的差别是将活动信息检查放在了概率计算之后。
服务器与缓存交互一次的时间大概是1ms-3ms,假设活动信息放在缓存中,中奖概率为5%。先检查活动信息和后检查活动信息两种方案,当后检查活动信息在请求量达到1000w时,可以总共节省约8.3h-25h,节省出来的时间是不是很惊人!!!
2) 放弃重试
失败重试会影响系统性能,重试次数越多,对系统性能的影响越大。
抽奖过程中,从抽奖信息验证到扣库存、中奖信息入库的整个过程中,任何一个环节异常或失败,我们都不会进行重试,全部当做未中奖处理,这是由抽奖的业务场景决定的,即:抽奖本身是随机的,不需要保证100%中奖。如果业务上要求100%中奖,可以在流程最后添加一个“补偿奖品环节”,即:返回失败信息之前,在给用户发一个奖品,不修改前面的整个流程。
面对不同业务场景是,要仔细分析每种业务场景的特性,分别采用不同的策略。
5. 部署优化方案
为了支撑更高的并发,追求更好的性能,可以对服务器的部署模型进行优化,这里给出一种方案。
如果走完整的流程,性能损耗无法接受时,可以通过增加A集群的机器,当负载均衡到达A服务器的请求直接扣减抽奖机会,整个流程得到极大简化,性能成倍提高。
此种方式的缺点是:部署复杂,开发时需要维护两套逻辑。
五 高可用优化
高可用涉及的技术比较多,具体可以参考博客:《高可用技术》,这里从活动中使用到的几个手段进行讨论。
1. 多实例部署
多实例部署;营销系统内部无状态,并可以通过水平扩展,提升系统性能。
2. 隔离技术
隔离技术的核心是防止压力、异常等相互传导,导致服务不稳定甚至不可用。
在我们的抽奖活动中,我们使用到的隔离技术有:系统隔离、数据隔离、动静分离、故障隔离。如:营销活动的系统独立于负责常规业务的系统,以避免相互干扰;DB、RocketMQ使用独立的集群,防止影响其他核心业务;通过动静分离技术,降低带宽占用;隔离各个系统的异常。
3. 限流技术
业务上主要通过计数器的方式进行防刷性限流,例如:单个ip的流量超过300/min,一段时间内不再接收此ip的其他请求;通过用户id限制抽奖次数、中奖次数;等等。
通过设置tomcat Connector、db连接池、缓存连接池等限制上游流量和对下游的压力。
六 重要问题
除了需要支持高并发,保证高可用外,还有几个重要的问题需要注意,他们在项目中中也非常重要。
1. 防止奖品超发
防止奖品超发,其实就是在并发场景中,对同一条记录修改时,如何防止不会出现脏写问题。一般而言有以下几种解决方案:
1) 锁
a) 乐观锁
乐观锁通过版本号实现,写数据之前从DB中读出记录当前信息,更新时where条件中通过版本号对比。典型的sql为:
update prize set stock = stock -1, version = version +1 where id = #{prizeId} and version = #{version}
优点:性能损耗较低;缺点:并发情况下写操作容易失败。
b) 悲观锁
悲观锁是从db中查询记录时通过锁来限制其他线程访问。典型的sql为:
select * from prize where id = #{prizeId} for update
优点:可以解决脏写问题;缺点:性能差。
c) 分布式锁
写DB之前,通过分布式锁保证同时只有一个线程对同一条记录进行修改。
优点:解决脏写问题,性能损耗低;缺点:同一时间只有一个线程可以写成功,其他线程全部失败。
2) 同步队列
同步队列的解决方案是将所有对同一条记录的修改操作收集起来,放在一个队列中串行执行,以降低规避并发写时的脏写问题,以及使用乐观锁时的写失败问题。常见的方式有两种:
请求到来以后,将写操作放到分布式队列中,另外启动线程从队列中不断读取数据,然后进行写操作。因为写操作放在一个线程中,对于单条记录来说不会出现并发问题。缺点是:编码复杂;性能优化较复杂;有多线程方案,但是容易出现资源分配不均的问题,难以充分利用系统的资源。
通过数据库中间件,所有服务器的写操作汇聚到数据库中间件,由中间件维护队列,这样同样可以实现顺序写操作。缺点是:中间件实现难度大;编码复杂。
3) 缓存+异步
见“异步化发放奖品”流程。现实中我们选用的是缓存+异步的方案。
2. 资源回收问题
现实的业务场景中,我们曾遇到这种问题:活动开始前,计划活动只进行1个月;活动中发现效果较好,希望延长活动时间。
在上面讨论流程已经多次提到,我们将很多信息都放在缓存中,并且了有效期,如果我们设置有效期为1个月,那运营需要延长活动时间时,我们就需要手动延长缓存中的这些数据的有效期。
实际项目中,缓存数据有效期一般不会和业务需求中完全匹配,例如业务提需求的时候要求保存一个月即可,我们会按照约2-3倍的时间进行设置,以防止出现类似的需求变更。
3. 域名问题
活动如果做得好,分享一定少不了。
在现实中,朋友圈最常见的导流渠道,但遗憾的是一旦微信发现H5页面有诱导分享的风险,可能直接将域名封掉。
为了不影响其他业务在朋友圈中的可用性,最好为每个活动申请一个子域名,即便是被封了,也不影响其他业务的使用。
七 量化的性能指标
面对高并发、高可用场景,上面讨论了多种处理对策,每种方案都有其优缺点,实现的难度与成本不同,现实设计、编码中应该根据活动的并发量预估,进行适当优化。以下是我们评估流量的一些常规做法。
1. 系统服务能力评估
根据运营投入,提前预估活动流量;根据业务特点,分析流量高峰和低谷的规律,通常晚上0点到早上8点的流量为低谷期,剩下的16个小时承担绝大多数流量。例如:预计活动每天的抽奖次数为1kw左右,可以计算出系统的平均QPS= 1000w/(16*60*60) = 173次/s
根据运营投入与业务特点,评估高峰时流量是平均流量的几倍,假设是10倍(一般5-8倍就足以满足需要),那么系统应该保证在1800次/s的并发量下没有压力。
考虑到流量预估可能不准,活动效果好时流量会远超超过1kw,所以系统设计应该要有足够的buffer以应此问题。我们一般会预留1-2倍的流量,那么开发时按照2kw-3kw/天的流量进行系统设计;所以系统应该支持的并发量在3600-5400次/s。
按照这个值进行性能测试,线上机器数量按照性能测试时需要的服务器进行部署。
2. 资源申请
根据服务能力的预估数据,再加上每次抽奖中与缓存、DB、MQ服务器等交互的次数,预估需要申请的缓存空间、DB、MQ服务器资源。
1) 服务器
根据上面流量评估以及性能测试的结果,预估需要的服务器数目,线上按照此数目进行部署。
2) 缓存空间评估
根据缓存key-value的大小,计算每个key-value占用的缓存空间,通过每个缓存的空间*预计数量得出缓存占用量。使用相同的方式计算所有缓存占用量;然后汇总得出缓存的总需求量;最后预留1-2倍的缓存空间作为buffer。
3) MQ服务器资源
根据并发流量计算出发送MQ流量。假设活动中有50%的流量需要发MQ消息,根据上面计算的最大QPS(3600-5400次/s),可以得出写MQ的流量为1800-2700次/s。
对于RocketMQ这种服务器来说,这个流量其实不大。但一般而言一个RocketMQ集群会同时为多个业务提供服务,所以应该和运维确保不会对其他业务产生影响。
使用类似于RabbitMQ服务器的时候,需要注意消息堆积问题,RabbitMQ消息堆积时,性能会大幅度降低。
4) DB资源
对于Mysql数据库而言,如果预计单表记录将超过1000w,应该考虑分表;如果预计单库数据会达到5-10GB(这个数字是之前DBA建议的数值),应该考虑分库;预计DB读写非常频繁,单库压力大可以考虑读写分离。
3. 线程池配置
DB连接池配置、Java线程池配置内容见博客《ThreadPoolExecutor》,地址:https://yq.aliyun.com/articles/592272