记录些Spring+题集(22)

JVM内存: 新生代(Eden和Survivor),老年代,永久代

这样划分的目的:是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

1)共享内存区划分

  • 共享内存区 = 永久代 (/元空间) + 堆

  • 永久代  =  方法区 + 其他

  • Java堆 =  老年代 + 新生代

  • 新生代 = Eden + S0 + S1

2)一些参数的配置

  • 默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ,可以通过参数 –XX:NewRatio 配置。

  • 默认的,Eden : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定)

  • Survivor区中的对象被复制次数为15   (对应虚拟机参数 -XX:+MaxTenuringThreshold)

3)为什么要分为Eden和Survivor?  为什么要设置两个Survivor区?

  • 如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多,所以需要分为Eden和Survivor。

  • Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历最大16次Minor GC还能在新生代中存活的对象,才会被送到老年代。

  • 设置两个Survivor区最大的好处就是解决了碎片化,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;

    等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。

为什么新生代内存需要有两个Survivor区?  

首先,说明一下默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ,默认的,Eden : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定) 这个  堆内存的结构。

图片

避免 内存碎片

在新生代,因为对象的生命周期比较短,为了避免 内存碎片太多, 使用了 标记 复制算法。

在老生代,因为对象的生命周期比较长,为了提升性能,使用了 性能高的 标记清理 或者 标记整理算法。

标记 复制算法,就需要备用空间, 所以两个Survivor ,一个使用,一个备用。

管理内存大小

标记 复制算法 ,空间成本高,需要一块额外的内存空间 作为 复制的备用空间,默认情况下,新时代是30%,  用 30%的 10分之一, 也就是 3% 作为 复制的备用空间。

备用大部分时候空闲, 3%  的空间是空闲的, 所以内存块不能太大,CMS是物理分代,G1是逻辑分代。

而在没有做并发分治模式之前(G1),老生代本身对象生命周期长, 老生代的内存也比价大, 占到60%多,标记 复制算法速度慢, 这么大的内存,不适合使用 标记 复制算法。

不使用 标记 复制算法,也就不需要用 备用空间。

在 做并发分治模式之后(G1),整个内存管理的模式, 已经从大块治理,变成小块治理, G1使用的也是标记复制算法, 本质上,已经可以理解为很多的 轮替备用空间了。如果把那些备用空间理解为 surviver的话,可以理解为,实际上存在N多的surviver区了,只是大家没有用这个名词去命名而已。

总结

因为大部分的新建对象生命周期很短、对象存活率低,用复制算法在回收时的效率会更高,也不会产生内存碎片。

复制算法的代价,  就是需要备用空间,为了不节约过多的内存,就划分了两块雷同大小的内存区域survivor from和survivor to。在每次gc后就会把存活对象给复制到另一个survivor上,而后清空Eden和刚应用过的survivor。

只有在Eden空间快满的时候才会触发 Minor GC 。而 Eden 空间占新生代的绝大部分,所以 Minor GC 的频率得以降低。

当然,使用两个 Survivor 这种方式我们也付出了一定的代价,如 10% 的空间浪费、复制对象的开销等。

这个标记复制算法,必须付出的代价。

JVM中完整的GC流程,对象如何晋升到老年代

  • Java堆 = 老年代 + 新生代

  • 新生代 = Eden + S0 + S1

  • 当 Eden 区的空间满了, Java虚拟机会触发一次 Minor GC,以收集新生代的垃圾,存活下来的对象,则会转移到 Survivor区。

  • 大对象(需要大量连续内存空间的Java对象,如那种很长的字符串)直接进入老年态

  • 如果对象在Eden出生,并经过第一次Minor GC后仍然存活,并且被Survivor容纳的话,年龄设为1,每熬过一次Minor GC,年龄+1,若年龄超过一定限制(15),则被晋升到老年态。即长期存活的对象进入老年态

  • 老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行Full GC,Full GC 清理整个内存堆 – 包括年轻代和年老代

  • Major GC 发生在老年代的GC清理老年区,经常会伴随至少一次Minor GC,比Minor GC慢10倍以上

什么是分代,分代的必要性

Java 虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代和永久代(对 HotSpot 虚拟机而言),这就是JVM 的内存分代策略。

JDK 1.7 之前,Java 虚拟机将堆内存划分为新生代、老年代和永久代(或者元空间),永久代是 HotSpot 虚拟机特有的概念(JDK1.8 之后为 metaspace 元空间替代永久代),它采用永久代的方式来实现方法区,其他的虚拟机实现没有这一概念,而且 HotSpot 也有去永久代的趋势,在 JDK 1.7 中 HotSpot 已经开始了“去永久化”,把原本放在永久代的字符串常量池移出。永久代主要存放常量、类信息、静态变量等数据,与垃圾回收关系不大,新生代和老年代是垃圾回收的主要区域。

堆内存是虚拟机管理的内存中最大的一块,也是垃圾回收最频繁的一块区域,我们程序所有的对象实例都存放在堆内存中。

给堆内存分代 是为了提高对象内存分配和垃圾回收的效率

试想一下,如果堆内存没有区域划分,所有的新创建的对象和生命周期很长的对象放在一起,随着程序的执行,堆内存需要频繁进行垃圾收集,而每次回收都要遍历所有的对象,遍历这些对象所花费的时间代价是巨大的,会严重影响我们的 GC 效率。

有了内存分代,情况就不同了,新创建的对象会在新生代中分配内存,经过多次回收仍然存活下来的对象存放在老年代中,静态属性、类信息等存放在永久代(或者元空间)中,新生代中的对象存活时间短,只需要在新生代区域中频繁进行 GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,永久代(或者元空间)中回收效果太差,一般不进行垃圾回收,还可以根据不同年代的特点采用合适的垃圾收集算法。

分代收集大大提升了收集效率,这些都是内存分代带来的好处。

JVM中的永久代中会发生垃圾回收吗

垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。

聊聊:堆内存设置的主要参数?

-Xms:初始堆大小(默认:物理内存的1/64)

-Xmx:最大堆大小(默认:物理内存的1/4)

-Xmn:新生代大小

-XX:SurvivorRatio:设置eden/form/ro的比例 (默认:8:1:1)

-XX:NewRatio:设置老年代/新生代的比例(默认:2)

-XX:+PrintGC/-XX:+PrintGCDetails :打印   GC   的过程信息

-XX:MaxTenuringThreshold:设置年轻代超过多少要进入老年代(默认:15)

聊聊:JVM运行的常用管理工具

  1. jps:查看当前系统运行的Java进程(进程号+名字;-m进程参数;-l程序的全路径;-v:传递给Java的main函数的函数参数)

  2. jstat:查看堆信息

  3. jinfo:查看虚拟机参数(也可以修改某些参数)

  4. jstack:查看线程的堆栈信息(查看线程拥有的锁,分析死锁的原因)

  5. jstatd:查看远程的Java进程

  6. jcmd:jdk7新增;可以查看Java进程,导出进程信息,执行GC等操作。

聊聊:JVM可视化管理工具(jdk自带的)有哪些?

  1. jconsole(jconsole)

  2. jvisualvm(visual VM)

  3. jmc(Mission Control)

查看堆内存,线程,加载类,cpu,dump等信息,检查死锁,内存溢出的原因

聊聊:哪些对象会被存放到老年代?

  1. 新生代对象每次经历⼀次minor gc,年龄会加1,当达到年龄阈值(默认为15岁)会直接进⼊老年代;

  2. 大对象直接进⼊老年代;

  3. 新生代复制算法需要⼀个survivor区进行轮换备份,如果出现大量对象在minor gc后仍然存活的情况时,就需要老年代进行分配担保,让survivor⽆法容纳的对象直接进⼊老年代;

  4. 如果在Survivor空间中相同年龄所有对象大⼩的总和大于Survivor空间的⼀半,年龄大于或等于该年龄的对象就可以直接进⼊年⽼代。

聊聊:什么时候触发full gc?

  1. 调用System.gc时,系统建议执行Full GC,但是不必然执行

  2. 老年代空间不⾜

  3. 方法区空间不⾜

  4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存

  5. 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

聊聊:七个垃圾回收器之间如何搭配使用

  1. Serial New收集器是针对新生代的收集器,采用的是复制算法;

  2. Parallel New(并行)收集器,新生代采用复制算法,老年代采用标记整理;

  3. Parallel Scavenge(并行)收集器,针对新生代,采用复制收集算法;

  4. Serial Old(串行)收集器,新生代采用复制,老年代采用标记清理;

  5. Parallel Old(并行)收集器,针对老年代,标记整理;

  6. CMS收集器,基于标记清理;

  7. G1收集器(JDK):“标记-复制”和“标记-整理”。从整体上看是基于“标记-整理”,从局部看,两个region之间是“标记-复制”。;

    综上:新生代基本采用复制算法,老年代采用标记整理算法。

聊聊:有没有JVM调优经验?JVM调优方案有哪些?

  1. 调优时机:

    a. heap 内存(老年代)持续上涨,达到设置的最大内存值;

    b. Full GC 次数频繁;

    c. GC 停顿时间过长(超过1秒);

    d. 应用出现OutOfMemory 等内存异常;

    e. 应用中有使用本地缓存,且占用大量内存空间;

    f. 系统吞吐量与响应性能不高或下降。

  2. 调优原则:

    a. 多数的Java应用不需要在服务器上进行JVM优化;

    b. 多数导致GC问题的Java应用,都不是因为我们参数设置错误,而是代码问题;

    c. 在应用上线之前,先考虑将机器的JVM参数设置到最优(最适合);

    d. 减少创建对象的数量;

    e. 减少使用全局变量和大对象;

    f. JVM优化,是到最后不得已才采用的⼿段;

    g. 在实际使用中,分析GC情况优化代码比优化JVM参数更好;

  3. 调优目标:

    a. GC低停顿;

    b. GC低频率;

    c. 低内存占用;

    d. 高吞吐量;

  4. 调优步骤:

    a. 分析GC日志及dump⽂件,判断是否需要优化,确定瓶颈问题点;

    b. 确定jvm调优量化目标;

    c. 确定jvm调优参数(根据历史jvm参数来调整);

    d. 调优⼀台服务器,对比观察调优前后的差异;

    e. 不断的分析和调整,知道找到合适的jvm参数配置;

    f. 找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪。

聊聊:你们项目如何排查JVM问题的?

对于还在正常运行的系统:

  1. 可以使用jmap来查看JVM中各个区域的使用情况

  2. 可以通过jstack来查看线程的运行情况,比如哪些线程阻塞、 是否出现了死锁

  3. 可以通过jstat命令来查看垃圾回收的情况,特别是fullgc,如果发现fullgc比较频繁,那么就得进行调优了

  4. 通过各个命令的结果,或者jvisualvm等⼯具来进行分析

  5. 首先,初步猜测频繁发送fullgc的原因,如果频繁发⽣fullgc但是⼜⼀直没有出现内存溢出,那么表示 fullgc实际上是回收了很多对象了,所以这些对象最好能在younggc过程中就直接回收掉,避免这些对象进⼊到老年代,对于这种情况,就要考虑这些存活时间不⻓的对象是不是比较大,导致年轻代放不下,直接进⼊到了老年代,尝试加大年轻代的大⼩,如果改完之后,fullgc减少,则证明修改有效

  6. 同时,还可以找到占用CPU最多的线程,定位到具体的方法,优化这个方法的执行,看是否能避免某些对象的创建,从而节省内存

对于已经发⽣了OOM的系统:

  1. ⼀般⽣产系统中都会设置当系统发⽣了OOM时,⽣成当时的dump⽂件(- XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/base)

  2. 我们可以利用jsisualvm等⼯具来分析dump⽂件

  3. 根据dump⽂件找到异常的实例对象,和异常的线程(占用CPU⾼),定位到具体的代码

  4. 然后再进行详细的分析和调试

总之,调优不是⼀蹴而就的,需要分析、 推理、 实践、 总结、 再分析,最终定位到具体的问题

聊聊:如何查看线程死锁?

  1. 可以通过jstack命令来进行查看,jstack命令中会显示发⽣了死锁的线程

  2. 或者两个线程去操作数据库时,数据库发⽣了死锁,这是可以查询数据库的死锁情况

1、 查询是否锁表
show OPEN TABLES where In_use > 0;
2、 查询进程
show processlist;
3、 查看正在锁的事务
SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS;
4、 查看等待锁的事务
SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS;

10W QPS超高并发架构的10大思想

如何理解高并发系统

所谓设计高并发系统,首先保证它整体可用的同时,然后,能够承受很大的流量冲击

要设计高并发的系统,要识别系统的各种瓶颈,如内存不足、磁盘空间不足,连接数不够,网络宽带不够等等,以应对突发的流量洪峰。

1. 业务分治思想+微服务拆分

业务分治思想,就是从业务的维度,分而治之,横向扩展。

设计一个高并发系统,我们可以分而治之,横向扩展

不同的业务,流量的规模不一样,需要分开应对。

也就是说,高频业务,需要更多的部署多台服务器,把流量分流开,让每个服务器都承担一部分的并发和流量,提升整体系统的并发能力

业务分治思想之后,就可以进行业务的分开建模,对应在模块设计、模块架构的维度,可以做微服务拆分,这样就可以达到分摊请求流量的目的,提高了并发能力。

所谓的微服务拆分,其实就是把一个单体的应用,按功能单一性,拆分为多个服务模块。

比如一个电商系统,拆分为用户系统、订单系统、商品系统等等

图片

2. 数据分治思想+分库分表

我们知道数据库连接数是有限的。

当业务量暴增的话,MySQL单机磁盘容量会撑爆

一般情况下,MYSQL的吞吐量为 1000-1500qps。

另外,由于innodb的 B+树索引的深度限制,最好是单表在500w记录,性能较佳。

所以,在高并发的场景下,大量请求访问数据库,MySQL单机是扛不住的。

高并发场景下,会出现too many connections报错。

所以高并发的系统,需要考虑拆分为多个数据库,来抗住高并发的毒打

而假如你的单表数据量非常大,存储和查询的性能就会遇到瓶颈了,可以首先做sql调优。

但是,如果做了很多优化之后还是无法提升效率的时候,就需要考虑做分表了。

分库分表之后,每个表的数据量少一点,提升SQL查询性能。

当面试官问要求你设计一个高并发系统的时候,一般都要说到分库分表这个点。

3. 读写分离思想+主从分离

通常来说,一台单机的MySQL服务器,一般情况下,MYSQL的吞吐量为 1000-1500qps。另外,由于innodb的 B+树索引的深度限制,最好是单表在500w记录,性能较佳。

所以,单机支撑的请求访问是有限的。

因此你做了分布式部署,部署了多台机器,部署了主数据库、从数据库。

但是,如果双十一搞活动,流量肯定会猛增的。

如果所有的查询请求,都走主库的话,主库肯定扛不住,因为查询请求量是非常非常大的。

因此一般都要求做主从分离,然后实时性要求不高的读请求,都去读从库,写的请求或者实时性要求高的请求,才走主库

这样就很好保护了主库,也提高了系统的吞吐。

当然,如果回答了主从分离,面试官可能扩展开问你主从复制原理,问你主从延迟问题等等,这块大家需要全方位复习好哈。

4. 池化思想

在高并发的场景下,各种连接,都可能成为瓶颈。

  • 数据库连接

  • HTTP 连接

  • Redis 连接

  • 反向代理连接

因为连接数是有限的。如何各种连接瓶颈呢?

  • 方式一: 增大连接数量限制

  • 方式二:池化技术

通过池化技术,调用数据库时,可以会先获取数据库的连接,然后依靠这个连接来查询数据,搞完收工,最后关闭连接,释放资源。

反之,如果不用数据库连接池的话,每次执行SQL,都要创建连接和销毁连接,这就会导致每个查询请求都变得更慢了,相应的,系统处理用户请求的能力就降低了。

使用数据库连接池,可以避免每次查询都新建连接,减少不必要的资源开销,通过复用连接池,提高系统处理高并发请求的能力

和数据库连接池类似,咱们需要:

  • redis 连接池

  • HTTP 连接

  • 反向代理连接池

并且,需要使用http长连接,替代短连接。

5. 缓存思想

缓存思想是高并发的一大核心架构方案,无论是各种中间件内部,还是在cpu的内部,还是操作系统内部、在web应用、客户端浏览器的架构设计过程中,广泛的使用缓存思想和模式。

除了一级缓存,甚至有二级、三级缓存、多级缓存架构。

我们使用缓存,主要是提升系统接口的性能,这样高并发场景,你的系统就可以支持更多的用户同时访问。

web应用中,常用的缓存包括:Redis缓存,JVM本地缓存,nginx本地缓存,memcached、CDN静态资源加速等等。

就拿Redis来说,它单机就能轻轻松松应对几万的并发,你读场景的业务,可以用缓存来抗高并发。

缓存虽然用得爽,但是要注意缓存使用的一些问题

  • 缓存与数据库的一致性问题

  • 缓存雪崩

  • 缓存穿透

  • 缓存击穿

然后拿 CDN来说,也属于缓存思想的一种应用,将静态资源,缓存到离用于最近的CDN机房,加速静态资源访问。

商品图片,icon等等静态资源,可以对页面做静态化处理,减少访问服务端的请求

如果用户分布在全国各地,有的在上海,有的在深圳,地域相差很远,网速也各不相同。

为了让用户最快访问到页面,可以使用CDNCDN可以让用户就近获取所需内容。

什么是CDN?

Content Delivery Network/Content Distribution Network,翻译过来就是内容分发网络,它表示将静态资源分发到位于多个地理位置机房的服务器,可以做到数据就近访问,加速了静态资源的访问速度,因此让系统更好处理正常别的动态请求。

6. 异步思想+消息队列削锋

一般情况下,服务与服务之间的调用,是同步的rpc调用。同步调用的特点是:

  • 调用简单

  • 结果直观

  • 但是是阻塞执行,性能低

简单来说,同步rpc 它代表调用方要阻塞等待被调用方法中的逻辑执行完成

这种方式下,当被调用方法响应时间较长时,会造成调用方长久的阻塞,在高并发下会造成整体系统性能下降甚至发生雪崩。

那么,如何提升性能?如何抵御高并发?就是使用异步调用。

异步调用恰恰相反,调用方不需要等待方法逻辑执行完成就可以返回执行其他的逻辑,在被调用方法执行完毕后再通过回调、事件通知等方式将结果反馈给调用方。

因此,设计一个高并发的系统,需要在恰当的场景使用异步

如何使用异步呢?后端可以借用消息队列实现。

比如在海量秒杀请求过来时,先放到消息队列中,快速响应用户,告诉用户请求正在处理中,这样就可以释放资源来处理更多的请求。

秒杀请求处理完后,通知用户秒杀抢购成功或者失败。

我们搞一些双十一、双十二等运营活动时,需要避免流量暴涨,打垮应用系统的风险

因此一般会引入消息队列,来应对高并发的场景

图片

假设你的应用系统每秒最多可以处理2k个请求,每秒却有5k的请求过来,可以引入消息队列,应用系统每秒从消息队列拉2k请求处理得了。

有些伙伴担心这样可能会出现消息积压的问题:

  • 首先,搞一些运营活动,不会每时每刻都那么多请求过来你的系统(除非有人恶意攻击),高峰期过去后,积压的请求可以慢慢处理;

  • 其次,如果消息队列长度超过最大数量,可以直接抛弃用户请求或跳转到错误页面;

7. 服务保护思想:熔断降级

熔断降级是保护系统的一种手段。也可以理解为一种服务保护的思想。

当前互联网系统一般都是分布式部署的。

而分布式系统中偶尔会出现某个基础服务不可用,最终导致整个系统不可用的情况, 这种现象被称为服务雪崩效应

比如分布式调用链路A->B->C....,下图所示:

图片

如果服务C出现问题,比如是因为慢SQL导致调用缓慢,那将导致B也会延迟,从而A也会延迟。

堵住的A请求会消耗占用系统的线程、IO、CPU等资源。

当请求A的服务越来越多,占用计算机的资源也越来越多,最终会导致系统瓶颈出现,造成其他的请求同样不可用,最后导致业务系统崩溃。

为了应对服务雪崩, 常见的做法是熔断和降级

最简单是加开关控制,当下游系统出问题时,开关打开降级,不再调用下游系统。还可以选用开源组件Hystrix来支持。

你要保证设计的系统能应对高并发场景,那肯定要考虑熔断降级逻辑进来。

8. 限流思想

限流也是我们应对高并发的一种保护的思想。这是一种无奈的处理方式。也是一种兜底的处理方式。理论上,咱们是需要能处理所有的请求。

但是,有的时候,资源有限,系统的CPU、网络带宽、内存、线程等资源都是有限的。因此,我们要考虑限流。

只能服务其中的部分请求,只能进行无奈的限流,至少能保证部分用户得到正常的服务。

如果你的系统每秒扛住的请求是一千,如果一秒钟来了十万请求呢

换个角度就是说,高并发的时候,流量洪峰来了,超过系统的承载能力,怎么办呢?

这时候,我们可以采取限流方案。就是为了保护系统,多余的请求,直接丢弃。

什么是限流:限流,也称流量控制。

限流是指系统在面临高并发,或者大流量请求的情况下,限制新的请求对系统的访问,从而保证系统的稳定性。

具体怎么做限流呢?

可以使用GuavaRateLimiter单机版限流,也可以使用Redis分布式限流,还可以使用阿里开源组件sentinel限流。

9. 扩容思想+切流量

如果是突发的流量高峰,除了降级、限流保证系统不跨,我们可以采用这两种方案,保证系统尽可能服务用户请求:

  • 扩容:比如增加从库、提升配置的方式,提升系统/组件的流量承载能力。

    比如增加MySQL、Redis从库来处理查询请求。

  • 切流量:服务多机房部署,如果高并发流量来了,把流量从一个机房切换到另一个机房。

10. 海量数据处理思想 ElasticSearch+Hbase

Elasticsearch,大家都使用得比较多了吧,一般搜索功能都会用到它

它是一个分布式、高扩展、高实时的搜索与数据分析引擎,简称为ES

我们在聊高并发,为啥聊到ES呢?

因为ES可以扩容方便,天然支撑高并发。

当数据量大的时候,不用动不动就加机器扩容,分库等等,可以考虑用ES来支持简单的查询搜索、统计类的操作。

图片

图片

压力测试确定系统瓶颈

设计高并发系统,离不开最重要的一环,就是压力测试

就是在系统上线前,需要对系统进行压力测试,测清楚你的系统支撑的最大并发是多少,确定系统的瓶颈点,让自己心里有底,最好预防措施。

压测完要分析整个调用链路,性能可能出现问题是网络层(如带宽)、Nginx层、服务层、还是数据路缓存等中间件等等。

loadrunner是一款不错的压力测试工具,jmeter则是接口性能测试工具,都可以来做下压测。

接口的常规优化的18个方案

设计一个高并发的系统,需要设计接口的性能足够好,这样系统在相同时间,就可以处理更多的请求。

图片

HashMap的遍历方法

1.JDK 8 之前的遍历

JDK 8 之前主要使用 EntrySet 和 KeySet 进行遍历。EntrySet 和 KeySet 除了可以直接循环外,还可以使用它们的迭代器进行循环。

使用 KeySet 遍历,其性能是不如 EntrySet 的,因为 KeySet 其实循环了两遍集合,第一遍循环是循环 Key,而获取 Value 有需要使用 map.get(key),相当于有循环了一遍集合,所以 KeySet 循环不能建议使用,因为循环了两次,效率比较低

使用迭代器循环 EntrySet,并且在循环中动态删除元素,实现代码如下:

public static void main(String[] args) {
    // 循环遍历
    Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
    while (iterator.hasNext()) {
        Map.Entry<String, String> entry = iterator.next();
        if ("Java".equals(entry.getKey())) {
            // 删除此项
            iterator.remove();
            continue;
        }
        System.out.println(entry.getKey() + ":" + entry.getValue());
    }
}

使用迭代器的优点是可以在循环的时候,动态的删除集合中的元素。非迭代器的方式则不能在循环的过程中删除元素(程序会报错)。

2.JDK 8 之后的遍历

在 JDK 8 之后 HashMap 的遍历就变得方便很多了,JDK 8 中包含了以下 3 种遍历方法:

  • 使用 Lambda 遍历

  • 使用 Stream 单线程遍历

  • 使用 Stream 多线程遍历

2.1 Lambda 遍历

使用 Lambda 表达式的遍历方法实现代码如下:

public static void main(String[] args) {
    // 创建并赋值 hashmap
    HashMap<String, String> map = new HashMap() {{
        put("Java", " Java Value.");
        put("MySQL", " MySQL Value.");
        put("Redis", " Redis Value.");
    }};
    
    // 循环遍历
    map.forEach((key, value) -> {
        System.out.println(key + ":" + value);
    });
}

2.2 Stream 单线程遍历

Stream 遍历是先得到 map 集合的 EntrySet,然后再执行 forEach 循环,实现代码如下:

public static void main(String[] args) {
    // 创建并赋值 hashmap
    HashMap<String, String> map = new HashMap() {{
        put("Java", " Java Value.");
        put("MySQL", " MySQL Value.");
        put("Redis", " Redis Value.");
    }};
    
    // 循环遍历
    map.entrySet().stream().forEach((entry) -> {
        System.out.println(entry.getKey() + ":" + entry.getValue());
    });
}

2.3 Stream 多线程遍历

Stream 多线程的遍历方式和上一种遍历方式类似,只是多执行了一个 parallel 并发执行的方法,此方法会根据当前的硬件配置生成对应的线程数,然后再进行遍历操作,实现代码如下:

public static void main(String[] args) {
    // 创建并赋值 hashmap
    HashMap<String, String> map = new HashMap() {{
        put("Java", " Java Value.");
        put("MySQL", " MySQL Value.");
        put("Redis", " Redis Value.");
    }};
    // 循环遍历
    map.entrySet().stream().parallel().forEach((entry) -> {
        System.out.println(entry.getKey() + ":" + entry.getValue());
    });
}

当前执行结果和之前的所有遍历结果都不一样(打印元素的顺序不一样),因为程序是并发执行的,所以没有办法保证元素的执行顺序和打印顺序,这就是并发编程的特点。

推荐使用哪种遍历方式?

不同的场景推荐使用的遍历方式是不同的,例如,如果是 JDK 8 之后的开发环境,推荐使用 Stream 的遍历方式,因为它足够简洁;而如果在遍历的过程中需要动态的删除元素,那么推荐使用迭代器的遍历方式;如果在遍历的时候,比较在意程序的执行效率,那么推荐使用 Stream 多线程遍历的方式,因为它足够快。所以这个问题的答案是不固定的,我们需要知道每种遍历方法的优缺点,再根据不同的场景灵活变通。

HashMap的底层实现和插入流程

HashMap 底层实现

HashMap 在 JDK 1.7 和 JDK 1.8 的底层实现是不一样的,在 JDK 1.7 中,HashMap 使用的是数组 + 链表实现的,而 JDK 1.8 中使用的是数组 + 链表或红黑树实现的

JDK 1.8 中的 HashMap。HashMap 中每个元素称之为一个哈希桶(bucket),哈希桶包含的内容有 4 个:

  • hash 值

  • key

  • value

  • next(下一个节点)

HashMap 插入流程

简单来说 HashMap 的元素添加流程是,先将 key 值进行 hash 得到哈希值,根据哈希值得到元素位置,判断元素位置是否为空,如果为空直接插入,不为空判断是否为红黑树,如果是红黑树则直接插入,否则判断链表是否大于 8,且数组长度大于 64,如果满足这两个条件则把链表转成红黑树,然后插入元素,如果不满足这两个条件中的任意一个,则遍历链表进行插入,它的执行流程如下图所示:

图片

为什么要将链表转红黑树?

JDK 1.8 中引入了新的数据结构红黑树来实现 HashMap,主要是出于性能的考量。因为链表超过一定长度之后查询效率就会很低,它的时间复杂度是 O(n),而红黑树的时间复杂度是 O(logn),因此引入红黑树可以加快 HashMap 在数据量比较大情况下的查询效率。

哈希算法实现

HashMap 的哈希算法实现源码如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

其中,key.hashCode() 是 Java 中自带的 hashCode() 方法,返回一个 int 类型的散列值,后面 hashCode 再右移 16 位,正好是 32bit 的一半,与自己本身做异或操作(相同为 0,不同为 1),主要是为了混合哈希值的高位和地位,增加低位的随机性,这样就实现了 HashMap 的哈希算法。

总结

HashMap 在 JDK 1.7 时,使用的是数组 + 链表实现的,而在 JDK 1.8 时,使用的是数组 + 链表或红黑树的方式来实现的,JDK 1.8 之所以引入红黑树主要是出于性能方面的考虑。HashMap 在插入时,会判断当前链表的长度是否大于 8 且数组的长度大于 64,如果满足这两个条件就会把链表转成红黑树再进行插入,否则就是遍历链表插入。

100亿级 超大流量 红包 架构方案

百亿级 微信红包技术架构

架构

微信用户在国内有深圳、上海两个接入点,习惯性称之为南、北(即深圳为南,上海为北)。用户请求接入后,不同业务根据业务特性选择部署方式。微信红包在信息流上可以分为订单纬度与用户纬度。

其中订单是贯穿红包发、抢、拆、详情列表等业务的关键信息,属于交易类信息;而用户纬度指的是红包用户的收红包列表、发红包列表,属于展示类信息。红包系统在架构上,有以下几个方面:

南北分布

1、订单层南北独立体系,数据不同步

用户就近接入,请求发红包时分配订单南北,并在单号打上南北标识。抢红包、拆红包、查红包详情列表时,接入层根据红包单号上的南北标识将流量分别引到南北系统闭环。根据发红包用户和抢红包用户的所属地不同,有以下四种情况:

1)深圳用户发红包,深圳用户抢

订单落在深圳,深圳用户抢红包时不需要跨城,在深圳完成闭环。

2)深圳用户发红包,上海用户抢

订单落在深圳,上海用户抢红包,在上海接入后通过专线跨城到深圳,最后在深圳闭环完成抢红包。

3)上海用户发红包,上海用户抢

订单落在上海,上海用户抢红包时不需要跨城,在上海完成闭环。

4)上海用户发红包,深圳用户抢

订单落在上海,深圳用户抢红包,从深圳接入后通过专线跨城到上海,最后在上海闭环完成抢红包。

系统这样设计,好处是南北系统分摊流量,降低系统风险。

图片

2、用户数据写多读少,全量存深圳,异步队列写入,查时一边跨城

用户数据的查询入口,在微信钱包中,隐藏的很深。这决定了用户数据的访问量不会太大,而且也被视为可旁路的非关键信息,实时性要求不高。因此,只需要在发红包、拆红包时,从订单维度拆分出用户数据写入请求,由MQ异步写入深圳。后台将订单与用户进行定时对账保证数据完整性即可。

3、支持南北流量灵活调控

红包系统南北分布后,订单落地到深圳还是上海,是可以灵活分配的,只需要在接入层上做逻辑。例如,可以在接入层中,实现让所有红包请求,都落地到深圳(无论用户从上海接入,还是深圳接入),这样上海的红包业务系统将不会有请求量。提升了红包系统的容灾能力。同时,实现了接入层上的后台管理系统,实现了秒级容量调控能力。可根据南北请求量的实时监控,做出对应的调配。

4、DB故障时流量转移能力 基于南北流量的调控能力,当发现DB故障时,可将红包业务流量调到另外一边,实现DB故障的容灾。

预订单

支付前订单落cache,同时利用cache的原子incr操作顺序生成红包订单号。优点是cache的轻量操作,以及减少DB废单。在用户请求发红包与真正支付之间,存在一定的转化率,部分用户请求发红包后,并不会真正去付款。

拆红包入账异步化

信息流与资金流分离。拆红包时,DB中记下拆红包凭证,然后异步队列请求入账。入账失败通过补偿队列补偿,最终通过红包凭证与用户账户入账流水对账,保证最终一致性。如下图所示:

图片

这个架构设计,理论基础是快慢分离。红包的入账是一个分布事务,属于慢接口。而拆红包凭证落地则速度快。

实际应用场景中,用户抢完红包,只关心详情列表中谁是“最佳手气”,很少关心抢到的零是否已经到账。因为只需要展示用户的拆红包凭证即可。

发拆落地,其他操作双层cache

1、Cache住所有查询,两层cache

除了使用ckv做全量缓存,还在数据访问层dao中增加本机内存cache做二级缓存,cache住所有读请求。查询失败或者查询不存在时,降级内存cache;内存cache查询失败或记录不存在时降级DB。

DB本身不做读写分离。

2、DB写同步cache,容忍少量不一致, DB写操作完成后,dao中同步内存cache,业务服务层同步ckv,失败由异步队列补偿,定时的ckv与DB备机对账,保证最终数据一致。

高并发

微信红包的并发挑战,主要在于微信大群,多人同时抢同一个红包。以上这种情况,存在竞争MySQL行锁。为了控制这种并发,团队做了以下一些事情:

1、请求按红包订单路由,逻辑块垂直sticky,事务隔离

按红包订单划分逻辑单元,单元内业务闭环。服务rpc调用时,使用红包订单号的hash值为key寻找下一跳地址。对同一个红包的所有拆请求、查询请求,都路由到同一台逻辑机器、同一台DB中处理。

图片

2、Dao搭建本机Memcache内存cache,控制同一红包并发个数

在DB的接入机dao中,搭建本机内存cache。以红包订单号为key,对同一个红包的拆请求做原子计数,控制同一时刻能进DB中拆红包的并发请求数。

这个策略的实施,依赖于请求路由按红包订单hash值走,确保同一红包的所有请求路由到同一逻辑层机器。

3、多层级并发量控制

1)发红包控制

发红包是业务流程的入口,控制了这里的并发量,代表着控制了红包业务整体的并发量。在发红包的业务链路里,做了多层的流量控制,确保产生的有效红包量级在可控范围。

2)抢红包控制

微信红包领取时分为两个步骤,抢和拆。

抢红包这个动作本身就有控制拆并发的作用。因为抢红包时,只需要查cache中的数据,不需要请求DB。

对于红包已经领完、用户已经领过、红包已经过期等流量可以直接拦截。而对于有资格进入拆红包的请求量,也做流量控制。通过这些处理,最后可进入拆环节的流量大大减少,并且都是有效请求。

3)拆时内存cache控制

针对同一个红包并发拆的控制,上面的文章已介绍。

4、DB简化和拆分

DB的并发能力,有很多影响因素。红包系统结合红包使用情境,进行了一些优化。比较有借鉴意义的,主要有以下两点:

1)订单表只存关键字段,其他字段只在cache中存储,可柔性。

红包详情的展示中,除了订单关键信息(用户、单号、金额、时间、状态)外,还有用户头像、昵称、祝福语等字段。这些字段对交易来说不是关键信息,却占据大量的存储空间。

将这些非关键信息拆出来,只存在cache,用户查询展示,而订单中不落地。

这样可以维持订单的轻量高效,同时cache不命中时,又可从实时接口中查询补偿,达到优化订单DB容量的效果。

2)DB双重纬度分库表,冷热分离

使用订单hash、订单日期,两个纬度分库表,也即db_xxx.t_x_dd这样的格式。其中,x表示订单hash值,dd表示01-31循环日。

订单hash纬度,是为了将订单打散到不同的DB服务器中,均衡压力。订单日期循环日纬度,是为了避免单表数据无限扩张,使每天都是一张空表。

另外,红包的订单访问热度,是非常典型的冷热型。热数据集中在一两天内,且随时间急剧消减。线上热数据库只需要存几天的数据,其他数据可以定时移到成本低的冷数据库中。

循环日表也使得历史数据的迁移变得方便。

红包算法

首先,如果红包只有一个,本轮直接使用全部金额,确保红包发完。然后,计算出本轮红包最少要领取多少,才能保证红包领完,即本轮下水位;轮最多领取多少,才能保证每个人都领到,即本轮上水位。主要方式如下:

计算本轮红包金额下水位:假设本轮领到最小值1分,那接下来每次都领到200元红包能领完,那下水位为1分;如果不能领完,那按接下来每次都领200元,剩下的本轮应全部领走,是本轮的下水位。

计算本轮红包上水位:假设本轮领200元,剩下的钱还足够接下来每轮领1分钱,那本轮上水位为200元;如果已经不够领,那按接下来每轮领1分,计算本轮的上水位。

为了使红包金额不要太悬殊,使用红包均值调整上水位。如果上水位金额大于两倍红包均值,那么使用两倍红包均值作为上水位。换句话说,每一轮抢到的红包金额,最高为两倍剩下红包的均值。

最后,获取随机数并用上水位取余,如果结果比下水位还小,则直接使用下水位,否则使用随机金额为本轮拆到金额。

柔性降级方案

系统到处存在发生异常的可能,需要对所有的环节做好应对的预案。下面列举微信红包对系统异常的主要降级考虑。

1、 下单cache故障降级DB

下单cache有两个作用,生成红包订单与订单缓存。缓存故障情况下,降级为直接落地DB,并使用id生成器独立生成订单号。

2、 抢时cache故障降级DB

抢红包时,查询cache,拦截红包已经抢完、用户已经抢过、红包已经过期等无效请求。当cache故障时,降级DB查询,同时打开DB限流保护开关,防止DB压力过大导致服务不可用。

另外,cache故障降级DB时,DB不存储用户头像、用户昵称等(上文提到的优化),此时一并降级为实时接口查询。查询失败,继续降级为展示默认头像与昵称。

3、 拆时资金入账多级柔性

拆红包时,DB记录拆红包单据,然后执行资金转账。单据需要实时落地,而资金转账,这里做了多个层级的柔性降级方案:

大额红包实时转账,小额红包入队列异步转账 所有红包进队列异步转账 实时流程不执行转账,事后凭单据批量入账。

总之,单据落地后,真实入账可实时、可异步,最终保证一致即可。

4、 用户列表降级

用户列表数据在微信红包系统中,属于非关键路径信息,属于可被降级部分。

首先,写入时通过MQ异步写,通过定时对账保证一致性。

其次,cache中只缓存两屏,用户查询超过两屏则查用户列表DB。在系统压力大的情况下,可以限制用户只查两屏。

上文参考来源:架构说公众号,作者:方乐明

360w QPS  100亿级 字节红包 体系架构

1. 背景&挑战&目标

1.1 业务背景

(1)支持八端

2022 年字节系产品春节活动需要支持八端 APP 产品(包含抖音/抖音火山/抖音极速版/西瓜/头条/头条极速版/番茄小说/番茄畅听)的奖励互通。用户在上述任意一端都可以参与活动,得到的奖励在其他端都可以提现与使用。

(2)玩法多变

主要有集卡、朋友页红包雨、红包雨、集卡开奖与烟火大会等。

(3)多种奖励

奖励类型包含现金红包、补贴视频红包、商业化广告券、电商券、支付券、消费金融券、保险券、信用卡优惠券、喜茶券、电影票券、dou+券、抖音文创券、头像挂件等。

1.2 核心挑战

(1)超高吞吐,超大并发,最高预估 360w QPS 发奖。

(2)奖励类型多,共 10 余种奖励。多种发奖励的场景,玩法多变;

(3)从奖励系统稳定性、用户体验、资金安全与运营基础能力全方位保障,确保活动顺利进行 。

1.3 最终目标

(1)奖励入账:数据高可靠。提供统一的错误处理机制,入账幂等能力和奖励预算控制。

(2)奖励展示/使用:支持用户查看、提现(现金),使用卡券/挂件等能力。

(3)稳定性保障:在大流量的入账场景下,保证钱包核心路径稳定性与完善,通过常用稳定性保障手段如资源扩容、限流、熔断、降级、兜底、资源隔离等方式保证用户奖励方向的核心体验。

(4)资金安全:通过幂等、对账、监控与报警等机制,保证资金安全,保证用户资产应发尽发,不少发。

(5)活动隔离:实现内部测试、灰度放量和正式春节活动三个阶段的奖励入账与展示的数据隔离,不互相影响。

2. 产品需求介绍

用户可以在任意一端参与字节的春节活动获取奖励,以抖音红包雨现金红包入账场景为例,具体的业务流程如下:

登录抖音 → 参与活动 → 活动钱包页 → 点击提现按钮 → 进入提现页面 → 进行提现 → 提现结果页,另外从钱包页也可以进入活动钱包页。

图片

奖励发放核心场景:

  1. 集卡:集卡抽卡时发放各类卡券,集卡锦鲤还会发放大额现金红包,集卡开奖时发放瓜分奖金和优惠券;

  2. 红包雨:发红包、卡券以及视频补贴红包,其中红包和卡券最高分别 180w QPS;

图片

3. 钱包资产中台设计与实现

在 2022 年春节活动中,业务方分为:

UG、激励中台、视频红包、钱包方向、资产中台等

其中,UG 主要负责活动的玩法实现,包含集卡、红包雨以及烟火大会等具体的活动相关业务逻辑和稳定性保障。

而钱包方向定位是大流量场景下实现奖励入账、奖励展示、奖励使用与资金安全保障的相关任务。

其中资产中台负责奖励发放与奖励展示部分。

3.1 资产资产中台总体架构图如下:

资产中台负责奖励发放与奖励展示

图片

钱包资产中台核心系统划分如下:

  1. 资产订单层

    收敛八端奖励入账链路,提供统一的接口协议,对接上游活动业务方的奖励发放功能,同时,支持预算控制、补偿、订单号幂等。

  2. 活动钱包 api 层

    统一奖励展示链路,同时支持大流量场景。

3.2 资产订单中心设计

核心发放模型:

图片

说明:

活动 ID 唯一区分一个活动,本次春节分配了一个单独的母活动 ID。

场景 ID 和具体的一种奖励类型一一对应,定义该场景下发奖励的唯一配置,场景 ID 可以配置的能力有:

  • 发奖励账单文案;

  • 是否需要补偿;

  • 限流配置;

  • 是否进行库存控制;

  • 是否要进行对账。

  • 提供可插拔的能力,供业务可选接入。

订单号设计:

资产订单层支持订单号维度的发奖幂等,订单号设计逻辑为

${actID}_${scene_id}_${rain_id}_${award_type}_${statge}

从单号设计层面保证不超发,每个场景的奖励用户最多只领一次。

4.  核心难点问题解决

4.1 难点一:支持八端奖励数据互通

有八个产品端,需要统一对接,其中抖音系和头条系 APP 是不同的账号体系,所以不能通过用户 ID 打通奖励互通。

具体解决方案是:

  • 给每个用户生成唯一的 actID

  • 手机号优先级最高,如果不同端登录的手机号一样,在不同端的 actID 是一致的。

在唯一 actID 基础上,每个用户的奖励数据是绑定在 actID 上的,入账和查询是通过 actID 维度实现的,即可实现八端奖励互通。

示意图如下:

图片

4.2 难点二:高场景下的奖励入账实现

超高并发场景,发现金红包都是最关键的一环。有几个原因如下:

  1. 预估发现金红包最大流量有 180w TPS。

  2. 现金红包本身价值高,需要保证资金安全。

  3. 用户对现金的敏感度很高,在保证用户体验与功能完整性同时也要考虑成本问题。

终上所述,发现金红包面临比较大的技术挑战。

发红包其实是一种交易行为,资金流走向是从公司成本出然后进入个人账户。

(1)从技术方案上是要支持订单号维度的幂等,同一订单号多次请求只入账一次。订单号生成逻辑为

${actID}_${scene_id}_${rain_id}_${award_type}_${statge}

从单号设计层面保证不超发。

(2)支持高并发,有以下 2 个传统方案:

具体方案类型实现思路优点缺点
同步入账申请和预估流量相同的计算和存储资源

1.开发简单;

2.不容易出错;

浪费存储成本。 拿账户数据库举例,经实际压测结果:支持 30w 发红包需要 152 个数据库实例,如果支持 180w 发红包,至少需要 1152 个数据库实例,还没有算上 tce 和 redis 等其他计算和存储资源。
异步入账申请部分计算和存储资源资源,实际入账能力与预估有一定差值

1.开发简单;

2.不容易出错;

3.不浪费资源;

用户体验受到很大影响。 入账延迟较大,以今年活动举例会有十几分钟延迟。用户参与玩法得到奖励后在活动钱包页看不到奖励,也无法进行提现,会有大量客诉,影响抖音活动的效果。

以上两种传统意义上的技术方案都有明显的缺点,那么进行思考,既能相对节约资源又能保证用户体验的方案是什么?

最终采用的是红包雨 token 方案,具体方案是:使用异步入账加较少量分布式存储和较复杂方案来实现,下面具体介绍一下。

4.2.1 红包雨 token 方案:

根据预估发放红包估算,红包雨 token 方案,  计算实际入账最低要支持的 TPS 为 30w,所以实际发放中有压单的过程。

设计目标:

在活动预估给用户发放(180w)与实际入账(30w)有很大 gap 的情况下,保证用户的核心体验。用户在前端页面查看与使用过程当中不能感知压单的过程,即查看与使用体验不能受到影响,相关展示的数据包含余额,累计收入与红包流水,使用包含提现等。

具体设计方案:

我们在大流量场景下每次给用户发红包会生成一个加密 token(使用非对称加密,包含发红包的元信息:红包金额,actID,与发放时间等),分别存储在客户端和服务端(容灾互备),每个用户有个 token 列表。

每次发红包的时候会在 Redis 里记录该 token 的入账状态,然后用户在活动钱包页看到的现金红包流水、余额等数据,是合并已入账红包列表+token 列表-已入账/入账中 token 列表的结果。

同时为保证用户提现体验不感知红包压单流程,在进入提现页或者点击提现时,将未入账的 token 列表进行强制入账,保证用户提现时账户的余额为应入账总金额,不 block 用户提现流程。

示意图如下:

图片

token 数据结构:

token 使用的是 protobuf 格式,经单测验证存储消耗实际比使用 json 少了一倍,节约请求网络的带宽和存储成本;同时序列化与反序列化消耗 cpu 也有降低。

// 红包雨token结构
type RedPacketToken struct {
   AppID      int64  `protobuf: varint,1,opt  json: AppID,omitempty ` // 端ID
   ActID     int64  `protobuf: varint,2,opt  json: UserID,omitempty ` // ActID
   ActivityID string `protobuf: bytes,3,opt  json: ActivityID,omitempty ` // 活动ID
   SceneID    string `protobuf: bytes,4,opt  json: SceneID,omitempty ` // 场景ID
   Amount     int64  `protobuf: varint,5,opt  json: Amount,omitempty ` // 红包金额
   OutTradeNo string `protobuf: bytes,6,opt  json: OutTradeNo,omitempty ` // 订单号
   OpenTime   int64  `protobuf: varint,7,opt  json: OpenTime,omitempty ` // 开奖时间
   RainID     int32  `protobuf: varint,8,opt,name=rainID  json: rainID,omitempty ` // 红包雨ID
   Status     int64  `protobuf: varint,9,opt,name=status  json: status,omitempty ` //入账状态
}

token 安全性保障:

采用非对称加密算法,保障存储在的客户端尽可能不被破解。如果 token 加密算法被黑产破译,可监控报警发现,可降级。

4.3 难点三:发奖励链路依赖多的稳定性保障

发红包流程降级示意图如下:

图片

根据历史经验,实现的功能越复杂,依赖会变多,对应的稳定性风险就越高,那么如何保证高依赖的系统稳定性呢?

解决方案:

现金红包入账最基础要保障的功能,是将用户得到的红包进行入账,核心的功能,需要支持幂等与预算控制(避免超发),红包账户的幂等设计强依赖数据库保持事务一致性。

但是如果极端情况发生,中间的链路可能会出现问题,如果是弱依赖,需要支持降级掉,不影响发放主流程。

钱包方向发红包最短路径为依赖服务实例计算资源和 MySQL 存储资源实现现金红包入账。

发红包强弱依赖梳理图示:

psm依赖服务是否强依赖降级方案降级后影响
资产中台tcc降级读本地缓存
bytkekv主动降级开关,跳过 bytekv,依赖下游做幂等
资金交易层分布式锁  Redis被动降级,调用失败,直接跳过基本无
token  Redis主动降级开关,不调用 Redis用户能感知到入账有延迟,会有很多客诉
MySQL主有问题,联系 dba 切主故障期间发红包不可用
4.4 难点四:大流量发卡券预算控制

大流量集中发券的一个场景,钱包侧与算法策略配合进行卡券发放库存控制,防止超发。

具体实现:

(1)钱包资产中台维护每个卡券模板 ID 的消耗发放量。

(2)每次卡券发放前,读取该卡券模板 ID 的消耗量以及总库存数。同时会设置一个阈值,如果卡券剩余量小于 10%后不发这个券(使用兜底券或者祝福语进行兜底)。

(3) 发券流程 累计每个券模板 ID 的消耗量(使用 Redis incr 命令原子累加消耗量),然后与总活动库存进行比对,如果消耗量大于总库存数则拒绝掉,防止超发,也是一个兜底流程。

具体流程图:

图片

优化方向:

(1)大流量下使用 Redis 计数,单 key 会存在热 key 问题,需要拆分 key 来解决。

(2)大流量场景下,操作 Redis 会存在超时问题,返回上游处理中,上游继续重试发券,会多消耗库存少发,本次春节活动实际活动库存在预估库存基础上加了 5%的量级来缓解超时带来的少发问题。

4.5 难点五:高 QPS 场景下的热 key 的读取和写入稳定性保障

最大流量预估读取有 180wQPS,写入 30wQPS。这是典型的超大流量,热点 key、更新延迟不敏感,非数据强一致性场景(数字是一直累加),同时要做好容灾降级处理,最后实际活动展示的金额与产品预计发放数值误差小于 1%。

图片

4.5.1 方案一

高 QPS 下的读取和写入单 key,比较容易想到的是使用 Redis 分布式缓存来进行实现,但是单 key 读取和写入的会打到一个实例上,压测过单实例的瓶颈为 3w QPS。所以做的一个优化是拆分多个 key,然后用本地缓存兜底。

具体写入流程:

设计拆分 100 个 key,每次发红包根据请求的 actID%100 使用 incr 命令累加该数字,因为不能保证幂等性,所以超时不重试。

图片

读取流程:

与写入流程类似,优先读取本地缓存,如果本地缓存值为为 0,那么去读取各个 Redis 的 key 值累加到一起,进行返回。

图片

问题:

(1)拆分 100 个 key 会出现读扩散的问题,需要申请较多 Redis 资源,存储成本比较高。

而且可能存在读取超时问题,不能保证一次读取所有 key 都读取成功,故返回的结果可能会较上一次有减少。

(2)容灾方案方面,如果申请备份 Redis,也需要较多的存储资源,需要的额外存储成本。

4.5.2 方案二

设计思路:

在方案一实现的基础上进行优化,在写场景,通过本地缓存进行合并写请求,进行原子性累加,读场景返回本地缓存的值,减少额外的存储资源占用。

使用 Redis 实现中心化存储,最终大家读到的值都是一样的。

具体设计方案:

每个 docker 实例启动时都会执行定时任务,分为读 Redis 任务和写 Redis 任务。

读取流程:

  1. 本地的定时任务每秒执行一次,读取 Redis 单 key 的值,如果获取到的值大于本地缓存那么更新本地缓存的值。

  2. 对外暴露的 sdk 直接返回本地缓存的值即可。

  3. 有个问题需要注意下,每次实例启动第一秒内是没有数据的,所以会阻塞读,等有数据再返回。

写入流程:

  1. 因为读取都是读取本地缓存(本地缓存不过期),所以处理好并发情况下的写即可。

  2. 本地缓存写变量使用 go 的 atomic.AddInt64 支持原子性累加本地写缓存的值。

  3. 每次执行更新 Redis 的定时任务,先将本地写缓存复制到 amount 变量,最后将 amount 的值 incr 到 Redis 单 key 上,实现 Redis 的单 key 的值一直累加。

  4. 容灾方案是使用备份 Redis 集群,写入时进行双写,一旦主机群挂掉,设计了一个配置开关支持读取备份 Redis。两个 Redis 集群的数据一致性,通过定时任务兜底实现。

具体写入流程图如下:

图片

本方案调用 Redis 的流量是跟实例数成正比,经调研读取侧的服务为主会场实例数 2 万个,写入侧服务为资产中台实例数 8 千个,所以实际 Redis 要支持的 QPS 为 2.8 万/定时任务执行间隔(单位为 s),经压测验证 Redis 单实例可以支持单 key2 万 get,8k incr 的操作,所以设置定时任务的执行时间间隔是 1s,如果实例数更多可以考虑延长执行时间间隔。

4.5.3 方案对比

优点缺点
方案一1. 实现成本简单1. 浪费存储资源;2. 难以做容灾;3. 不能做到一直累加;
方案二

1. 节约资源;

2. 容灾方案比较简单,同时也节约资源成本;

1. 实现稍复杂,需要考虑好并发原子性累加问题

结论:

从实现效果,资源成本和容灾等方面考虑,最终选择了方案二上线。

4.6 难点六:大流量场景下资金安全保障

钱包方向在本次春节活动期间做了三件事情来保障大流量大预算的现金红包发放的资金安全:

  1. 现金红包发放整体预算控制的拦截

  2. 单笔现金红包发放金额上限的拦截

  3. 大流量发红包场景的资金对账

  • 小时级别对账:支持红包雨/集卡/烟火红包发放 h+1 小时级对账,并针对部分场景设置兜底 h+2 核对。

  • 准实时对账:红包雨已入账的红包数据反查钱包资产中台和活动侧做准实时对账

多维度核对示意图:

图片

准实时对账流程图:

图片

说明:

准实时对账监控和报警可以及时发现是否异常入账情况,如果报警发现会有紧急预案处理。

5. 通用模式抽象

在经历过春节超大流量活动后的设计与实现后,有一些总结和经验与大家一起分享一下。

5.1 容灾降级层面

大流量场景,为了保证活动最终上线效果,容灾是一定要做好的。

参考业界通用实现方案,如降级、限流、熔断、资源隔离,根据预估活动参与人数和效果进行使用存储预估等。

5.1.1 限流层面

(1)限流方面应用了 api 层 nginx 入流量限流,分布式入流量限流,分布式出流量限流。

这几个限流器都是字节跳动公司层面公共的中间件,经过大流量的验证。

(2)首先进行了实际单实例压测,根据单实例扛住的流量与本次春节活动预估流量打到该服务的流量进行扩容,并结合下游能抗住的情况,

在 tlb 入流量、入流量限流以及出流量限流分别做好了详细完整的配置并同。

限流目标:

保证自身服务稳定性,防止外部预期外流量把本身服务打垮,防止造成雪崩效应,保证核心业务和用户核心体验。

简单集群限流是实例维度的限流,每个实例限流的 QPS=总配置限流 QPS/实例数,对于多机器低 QPS 可能会有不准的情况,要经过实际压测并且及时调整配置值。

对于分布式入流量和出流量限流,两种使用方式如下,每种方式都支持高低 QPS,区别只是 SDK 使用方式和功能不同。

一般低 QPS 精度要求高,采用 redis 计数方式,使用方提供自己的 redis 集群。高 QPS 精度要求低,退化为总 QPS/tce 实例数的单实例限流。

5.1.2 降级层面

对于高流量场景,每个核心功能都要有对应的降级方案来保证突发情况核心链路的稳定性。

(1)本次春节奖励入账与活动活动钱包页方向做好了充分的操作预案,一共有 26 个降级开关,关键时刻弃车保帅,防止有单点问题影响核心链路。

(2)以发现金红包链路举例,钱包方向最后完全降级的方案是只依赖 docker 和 MySQL,其他依赖都是可以降级掉的,MySQL 主有问题可以紧急联系切主,虽说最后一个都没用上,但是前提要设计好保证活动的万无一失。

5.1.3 资源隔离层面

(1)提升开发效率不重复造轮子

因为钱包资产中台也日常支持抖音资产发放的需求,本次春节活动也复用了现有的接口和代码流程支持发奖。

(2)同时针对本次春节活动,服务层面做了集群隔离

创建专用活动集群,底层存储资源隔离,活动流量和常规流量互不影响。

5.1.4 存储预估

(1)不但要考虑和验证了 Redis 或者 MySQL 存储能抗住对应的流量,同时也要按照实际的获取参与和发放数据等预估存储资源是否足够。

(2)对于字节跳动公司的 Redis 组件来讲,

可以进行垂直扩容(每个实例增加存储,最大 10G),也可以进行水平扩容(单机房上限是 500 个实例),因为 Redis 是三机房同步的,所以计算存储时只考虑一个机房的存储上限即可。

要留足 buffer,因为水平扩容是很慢的一个过程,突发情况遇到存储资源不足只能通过配置开关提前下掉依赖存储,需要提前设计好。

5.1.5 压测层面

本次春节活动,钱包奖励入账和活动钱包页做了充分的全链路压测验证,下面是一些经验总结。

  1. 在压测前要建立好压测整条链路的监控大盘,在压测过程当中及时和方便的发现问题。

  2. 对于 MySQL 数据库,在红包雨等大流量正式活动开始前,进行小流量压测预热数据库,峰值流量前提前建链,减少正式活动时的大量建链耗时,保证发红包链路数据库层面的稳定性。

  3. 压测过程当中一定要传压测标,支持全链路识别压测流量做特殊逻辑处理,与线上正常业务互不干扰。

  4. 针对压测流量不做特殊处理,压测流量处理流程保持和线上流量一致。

  5. 压测中要验证计算资源与存储资源是否能抗住预估流量

  • 梳理好压测计划,基于历史经验,设置合理初始流量,渐进提升压测流量,实时观察各项压测指标。

  • 存储资源压测数据要与线上数据隔离,对于 MySQL 和 Bytekv 这种来讲是建压测表,对于 Redis 和 Abase 这种来讲是压测 key 在线上 key 基础加一下压测前缀标识 。

  • 压测数据要及时清理,Redis 和 Abase 这种加短时间的过期时间,过期机制处理比较方便,如果忘记设置过期时间,可以根据写脚本识别压测标前缀去删除。

  1. 压测后也要关注存储资源各项指标是否符合预期。

5.2 微服务思考

在日常技术设计中,大家都会遵守微服务设计原则和规范,根据系统职责和核心数据模型拆分不同模块,提升开发迭代效率并不互相影响。

但是微服务也有它的弊端,对于超大流量的场景功能也比较复杂,会经过多个链路,这样是极其消耗计算资源的。

本次春节活动资产中台提供了 sdk 包代替 rpc 进行微服务链路聚合对外提供基础能力,如查询余额、判断用户是否获取过奖励,强制入账等功能。访问流量最高上千万,与使用微服务架构对比节约了上万核 CPU 的计算资源。

6. 系统的未来演进方向

(1)梳理上下游需求和痛点,优化资产中台设计实现,完善基础能力,优化服务架构,提供一站式服务,让接入活动方可以更专注进行活动业务逻辑的研发工作。

(2)加强实时和离线数据看板能力建设,让奖励发放数据展示的更清晰更准确。

(3)加强配置化和文档建设,对内减少对接活动的对接成本,对外提升活动业务方接入效率。

上文参考来源:字节跳动技术团队:春节钱包大流量奖励系统入账及展示的设计与实现

  • 18
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值