Adrian


  • 首页

  • 关于

  • 标签

  • 分类

  • 归档

高并发场景下的优化

发表于 2019-03-02 | 分类于 高并发

​ 当应用在面临高并发访问的情况下,一般的web项目很容易会出现请求处理不过来和性能瓶颈的问题,同时数据库的压力也会变得很大;尤其是单机处理的能力极其有限。那么在这种情况下就需要考虑相关的解决方案去提升应用的并发处理能力了。

数据库分库分表

​ 关系型数据库本身比较容易成为系统瓶颈,单机存储容量、连接数、处理能力都有限。当单表的数据量达到1000W或100G以后,由于查询维度较多,即使添加从库、优化索引,做很多操作时性能仍下降严重。此时就要考虑对其进行切分了,切分的目的就在于减少数据库的负担,缩短查询时间。

​ 数据库分布式核心内容无非就是数据切分(Sharding),以及切分后对数据的定位、整合。数据切分就是将数据分散存储到多个数据库中,使得单一数据库中的数据量变小,通过扩充主机的数量缓解单一数据库的性能问题,从而达到提升数据库操作性能的目的。

数据切分(分库分表)

​ 数据切分指通过某种特定的条件,将我们存放在同一个数据库中的数据分散存放到多个数据库(主机)上面,以达到分散单台设备负载的效果。数据的切分同时还可以提高系统的总体可用性,因为单台设备Crash之后,只有总体数据的某部分不可用,而不是所有的数据。

数据切分根据其切分类型,可以分为两种方式:

  • 按照不同的表(或者Schema)来切分到不同的数据库(主机)之上,这种切可以称之为数据的垂直(纵向)切分
  • 根据表中的数据的逻辑关系,将同一个表中的数据按照某种条件拆分到多台数据库(主机)上面,这种切分称之为数据的水平(横向)切分

垂直切分

​ 根据业务耦合性,将关联度低的不同表存储在不同的数据库。做法与大系统拆分为多个小系统类似,按业务分类进行独立划分。与”微服务治理”的做法相似,每个微服务使用单独的一个数据库(即根据功能模块来进行数据的切分,不同功能模块的数据存放于不同的数据库主机中)

单表

​ 对于单表的垂直切分是基于数据库中的“列”进行,某个表字段较多,可以新建一张扩展表,将不经常用或字段长度较大的字段拆分出去到扩展表中。

​ 在字段很多的情况下(例如一个大表有100多个字段),通过“大表拆小表”,更便于开发与维护,也能避免跨页问题,MySQL底层是通过数据页存储的,一条记录占用空间过大会导致跨页,造成额外的性能开销。

​ 另外数据库以行为单位将数据加载到内存中,这样表中字段长度较短且访问频率较高,内存能加载更多的数据,命中率更高,减少了磁盘IO,从而提升了数据库性能

垂直切分的优缺点

  • 优点
    1. 解决业务系统层面的耦合,业务清晰
    2. 与微服务的治理类似,也能对不同业务的数据进行分级管理、维护、监控、扩展等
    3. 高并发场景下,垂直切分一定程度的提升IO、数据库连接数、单机硬件资源的瓶颈
  • 缺点
    1. 部分表无法join,只能通过接口聚合方式解决,提升了开发的复杂度
    2. 分布式事务处理复杂
    3. 依然存在单表数据量过大的问题(需要水平切分)

水平切分

​ 水平切分是根据表内数据内在的逻辑关系,按照某种规则(如根据某个数字类型字段基于特定数目取模,某个时间类型字段的范围,或者是某个字符类型字段的hash值,或者用户表的地区等等)将同一个表按不同的条件分散到多个数据库或多个表中,每个表中只包含一部分数据,从而使得单个表的数据量变小,达到分布式的效果。

​ 当一个应用难以再细粒度的垂直切分,或切分后数据量行数巨大,存在单库读写、存储性能瓶颈,这时候就需要进行水平切分了。

水平切分优缺点

  • 优点
    1. 不存在单库数据量过大、高并发的性能瓶颈,提升系统稳定性和负载能力
    2. 应用端改造较小,不需要拆分业务模块
  • 缺点
    1. 跨分片的事务一致性难以保证
    2. 跨库的join关联查询性能较差
    3. 数据多次扩展难度和维护量极大

经典的水平切分规则

  • 根据数值范围:按照时间区间或ID区间来切分

    这样的优点在于:

    • 单表大小可控
    • 天然便于水平扩展,后期如果想对整个分片集群扩容时,只需要添加节点即可,无需对其他分片的数据进行迁移
    • 使用分片字段进行范围查找时,连续分片可快速定位分片进行快速查询,有效避免跨分片查询的问题。

    缺点:

    • 热点数据成为性能瓶颈。连续分片可能存在数据热点,例如按时间字段分片,有些分片存储最近时间段内的数据,可能会被频繁的读写,而有些分片存储的历史数据,很少被查询
  • 根据数值取模:一般采用hash取模mod的切分方式

    优点:

    • 数据分片相对比较均匀,不容易出现热点和并发访问的瓶颈

    缺点:

    • 后期分片集群扩容时,需要迁移旧的数据(使用一致性hash算法能较好的避免这个问题)
    • 容易面临跨分片查询的复杂问题(如果查询时无法定位表,那么需要同时对多个数据库表进行查询再合并数据,会拖累数据库)

分库分表带来的问题及其解决办法

​ 分库分表能有效的环节单机和单库带来的性能瓶颈和压力,突破网络IO、硬件资源、连接数的瓶颈,同时也带来了一些问题:

  1. 事务一致性问题

    • 分布式事务:当更新内容同时分布在不同库中,不可避免会带来跨库事务问题。跨分片事务也是分布式事务。分布式事务能最大限度保证了数据库操作的原子性。但在提交事务时需要协调多个节点,推后了提交事务的时间点,延长了事务的执行时间。导致事务在访问共享资源时发生冲突或死锁的概率增高。随着数据库节点的增多,这种趋势会越来越严重,从而成为系统在数据库层面上水平扩展的枷锁
    • 最终一致性:对于那些性能要求很高,但对一致性要求不高的系统,往往不苛求系统的实时一致性,只要在允许的时间段内达到最终一致性即可,可采用事务补偿的方式。与事务在执行中发生错误后立即回滚的方式不同,事务补偿是一种事后检查补救的措施,一些常见的实现方法有:对数据进行对账检查,基于日志进行对比,定期同标准数据来源进行同步等等
  2. 跨节点关联查询 join 问题

    切分之前,系统中很多列表和详情页所需的数据可以通过sql join来完成。而切分之后,数据可能分布在不同的节点上,此时join带来的问题就比较麻烦了,考虑到性能,尽量避免使用join查询

    对于该问题的解决方案:

    • 全局表,将所有模块依赖的一些表在每个数据库中都保存一份
    • 增加冗余字段,利用空间换时间,为了性能而避免join查询。例如:订单表保存userId时候,也将userName冗余保存一份,这样查询订单详情时就不需要再去查询User表了
    • 通过应用程序分多次查询,再将得到的数据组装合并
  3. 跨节点分页、排序、函数问题

    跨节点多库进行查询时,会出现limit分页、order by排序等问题。

    分页需要按照指定字段进行排序,当排序字段就是分片字段时,通过分片规则就比较容易定位到指定的分片;当排序字段非分片字段时,就变得比较复杂了。需要先在不同的分片节点中将数据进行排序并返回,然后将不同分片返回的结果集进行汇总和再次排序,最终返回给用户

  4. 全局主键避重问题

    在分库分表环境中,由于表中数据同时存在不同数据库中,主键值平时使用的自增长将无用武之地,某个分区数据库自生成的ID无法保证全局唯一。因此需要单独设计全局主键,以避免跨库主键重复问题。(可用UUID或者分布式的全局ID生成器(如推特的Snowflake))

  5. 数据迁移、扩容问题

    当业务高速发展,面临性能和存储的瓶颈时,才会考虑分片设计,此时就不可避免的需要考虑历史数据迁移的问题。一般做法是先读出历史数据,然后按指定的分片规则再将数据写入到各个分片节点中。此外还需要根据当前的数据量和QPS,以及业务发展的速度,进行容量规划,推算出大概需要多少分片(一般建议单个分片上的单表数据量不超过1000W)

    如果采用数值范围分片,只需要添加节点就可以进行扩容了,不需要对分片数据迁移。如果采用的是数值取模分片,则考虑后期的扩容问题就相对比较麻烦

什么时候需要分库分表

  • 能不切分尽量不要切分
  • 数据量过大,正常运维影响业务访问
  • 随着业务发展,需要对某些字段垂直拆分
  • 数据量快速增长
  • 安全性和可用性:在业务层面上垂直切分,将不相关的业务的数据库分隔,因为每个业务的数据量、访问量都不同,不能因为一个业务把数据库搞挂而牵连到其他业务。利用水平切分,当一个数据库出现问题时,不会影响到100%的用户,每个库只承担业务的一部分数据,这样整体的可用性就能提高

垂直切分和水平切分的联合使用

​ 一个应用系统的负载都是在慢慢的增长的,当系统开始遇到性能瓶颈的时候。大多数情况下会先选择对数据库进行垂直切分,因为这样的成本最先,最符合这个时期所追求的最大投入产出比。

​ 但是随着业务的不断扩张,系统负载的持续增长,在系统稳定一段时期之后,经过了垂直拆分之后的数据库集群可能会再一次不堪重负,遇到了性能瓶颈。如果再选择对系统数据库进行垂直切分的话,会随着时间又再次面临同样的问题。这个时候就需要通过水平切分的优势来解决这个问题了,而且完全可以在垂直切分后的基础上进行水平切分。

数据库扩容

​ 随着时间的推进和业务的发展,系统的数据量会不停的增长,无论是数据库的容量,还是单库单表的数据量也总会到达天花板,此时该如何扩展我们的数据库性能?

  • 水平扩容

    1. 增加服务器数量,就能线性扩充系统性能
    2. 但是增加过多的服务器会增加网络、数据库IO开销、管理多个服务器的难度
  • 垂直扩容:提升单机处理能力

    1. 增强单机硬件性能(升级CPU,SSD固态硬盘等)
    2. 提升单机架构性能(使用Cache来减少IO次数,使用异步来增加单服务吞吐量,使用无锁数据结构来减少响应时间)
    3. 但是这样会增大单个服务中其他软件设施的依赖与管理、服务内部复杂度

​ 对数据库的扩容不管是垂直还是水平扩容都会有优缺点,这就需要根据实际的业务需求来选择了更加合适的解决方案了:

  • 读操作扩展:假如网站是读操作比较多,比如博客网站。通过对数据库进行垂直扩容并且结合redis、CDN等构建一个健壮的缓存系统是个不错的选择。如果系统超负荷运行,将更多的数据放在缓存中来缓解系统的读压力。采用水平扩容没有太大的意义,因为性能的瓶颈不在写操作,所以不需要实时去完成,用更多的服务器来分担压力性价比太低。
  • 写操作扩展:假如写操作比较多,比如大型网站的交易系统,可考虑水平扩展的数据存储方式,比如Cassandra、Hbase等。和大多数的关系型数据库不同,这种数据存储会随着数据量的增长从而增加更多的节点。也可以考虑垂直扩容提升单个数据库的性能,但会发现资金与硬盘的IO能力是有限的,所以需要增加更多数据库来分担写的压力

缓存

​ 使用缓存可以减少数据库压力(I/O压力)与提高访问性能

​ 缓存通常适合读多写少的业务场景,实时性要求越低越适合缓存(即数据在缓存中更新的次数越少越适合)

  1. 缓存特征

    • 命中率:命中数 /(命中数 + 没有命中数),命中率越高产生收益也就越高,性能也就越好,相应的也就越短,吞吐量也就越高,抗并发的能力也就越强

    • 最大元素(空间):缓存中存放的最大元素的数量,当缓存的数量超过了缓存空间,则会触发缓存清空策略

    • 清空策略:

      一旦缓存中元素数量最大元素或者缓存数据所占空间超过其最大支持空间,那么将会触发缓存清空策略,根据不同的场景合理的设置最大元素值往往可以一定程度上提高缓存的命中率,从而更有效的时候缓存

      • FIFO先进先出策略,先进入的优先清除
      • LFU(Least Frequently Used,根据数据的历史访问频率来淘汰数据,对比命中数;其核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”)
      • LRU(Least recently used,最近最少使用策略;其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”)
      • 过期时间(根据元素设置的过期时间来清除缓存)
      • 随机清除
  2. 缓存分类

    • 本地缓存:Java中的本地缓存是存在当前应用进程内部的,没有过多的网络开销。在集群节点之间不需要互相通知的情况下使用较为合适
    • 分布式缓存:应用分离的缓存服务,其自身就是一个独立的应用,与本地应用是隔离的,多个应用之间共享缓存
  3. 高并发场景下缓存常见问题

    • 缓存一致性

      当数据时效性要求很高时,需要保证缓存中的数据与数据库中的保持一致,而且需要保证缓存节点和副本中的数据也保持一致,不能出现差异现象。这就比较依赖缓存的过期和更新策略。一般会在数据发生更改的时,主动更新缓存中的数据或者移除对应的缓存

    • 缓存并发

      在高并发场景下,多个请求并发的去从数据库获取数据,会对后端数据库造成极大的冲击,甚至导致 “雪崩”现象。当某个缓存key在被更新时,同时也可能被大量请求在获取,这也会导致缓存一致性的问题。

      那如何解决类似问题呢?在缓存更新或者过期的情况下,先尝试获取到锁(分布式锁),当更新或者从数据库获取完成后再释放锁,其他的请求只需要牺牲一定的等待时间,即可直接从缓存中继续获取数据

    • 缓存穿透

      在高并发场景下,如果某一个key被高并发访问,没有被命中,出于对容错性考虑,会尝试去从后端数据库中获取,从而导致了大量请求达到数据库,而当该key对应的数据本身就是空的情况下,这就导致数据库中并发的去执行了很多不必要的查询操作,从而导致数据库的压力增大。

      那如何解决类似问题呢?

      • 缓存空对象;对查询结果为空的对象也进行缓存,如果是集合,可以缓存一个空的集合(非null),如果是缓存单个对象,可以通过字段标识来区分。这样避免请求穿透到后端数据库。同时,也需要保证缓存数据的时效性。这种方式实现起来成本较低,比较适合命中不高,但可能被频繁更新的数据
      • 单独过滤处理;对所有可能对应数据为空的key进行统一的存放,并在请求前做拦截,这样避免请求穿透到后端数据库。这种方式实现起来相对复杂,比较适合命中不高,但是更新不频繁的数据
    • 缓存雪崩

      缓存雪崩就是指由于缓存没有命中的原因,导致大量请求到达后端数据库,从而导致数据库崩溃,整个系统崩溃,发生灾难。“缓存并发”,“缓存穿透”等问题,都可能会导致缓存雪崩现象发生,这些问题可能会被恶意攻击者所利用。

      还有一种情况,例如某个时间点内,系统预加载的缓存周期性集中失效了,也可能会导致雪崩。为了避免这种周期性失效,可以通过设置不同的过期时间,来错开缓存过期,从而避免缓存集中失效

      从应用架构角度,我们可以通过限流、降级、熔断等手段来降低影响,也可以通过多级缓存来避免这种灾难

  4. 缓存介质

    • 内存:将缓存存储于内存中是最快的选择,无需额外的I/O开销,但是内存的缺点是存储的数据没有持久化,一旦应用异常或者宕机,数据很难或者无法复原
    • 硬盘:很多缓存框架会结合使用内存和硬盘,在内存分配空间满了或是在异常的情况下,可以被动或主动的将内存空间数据持久化到硬盘中,达到释放空间或备份数据的目的

消息队列

​ 消息队列已经逐渐成为系统内部通信的核心手段和异步RPC的主要手段。

​ 它具有低耦合、可靠投递、广播、流量控制、最终一致性等一系列功能

  1. 消息队列的特性

    • 业务无关:只做消息的分发
    • FIFO:先进入队列的先到达
    • 容灾:节点的动态增删和消息的持久化
    • 性能:吞吐量提升,系统内部通信效率提高
  2. 使用消息队列的好处

    • 异步解耦

      使用消息队列,可以异步处理请求,从而缓解系统的压力

      例如:短信发送时只要保证放到消息队列中就可以接着做后面的事情。一个事务只关心本质的流程,需要依赖其他事情但是不那么重要的时候,有通知即可,无需等待结果

    • 保证最终一致性

      最终一致性指的是两个系统的状态保持一致,要么都成功,要么都失败。当然有个时间限制,理论上越快越好,但实际上在各种异常的情况下,可能会有一定延迟达到最终一致状态,但最后两个系统的状态是一样的

    • 错峰与流控

      上下游对于事情的处理是不同的,比如WEB前端每秒承受上千万的请求都是可以的但是数据库的处理却非常有限;迫于成本的压力我们不能要求数据库的机器数量与前端资源一样;这样的问题同样存在于系统与系统之间,比如短信系统的速度卡在网关上边它与前端的并发量不是一个数量级的,用户玩几秒种收到短信也是可以的;针对于这样的场景如果没有消息队列也能实现但是系统的复杂度非常的高

    • 广播

      如果没有消息队列每一个新的业务方介入都需要联调一次接口,使用消息队列只需要关心消息是否送达到消息队列,新接入的接口订阅相关的消息自己做处理就可以了

应用拆分

  1. 拆分的原则

    • 业务优先:每个系统都会有多个模块,每个模块又有多个业务功能;按照业务边界进行切割,再对模块进行拆分
    • 循序渐进:边拆分边测试,保证系统的正常运行
    • 兼顾技术:重构、分层(不能为了分布式而分布式,拆分过程不仅是业务梳理也是代码重构的过程,根据技术进行分层来分配工作)
    • 可靠测试:测试完毕后,才可进行下一步,每一步都要有足够的测试才可进行下一步,避免小错误引起蝴蝶效应
  2. 应用拆分时设计和选择

    • 应用之间通信:RPC 、消息队列、API(基于RESTFul风格的接口原则)

      消息队列通常用于传输数据包小但是数据量大,对实时性要求低的场景。而采用RPC要求实时性高

    • 应用之间数据库设计:每个应用都有独立的数据库

      通常情况下,每个应用都有自己独立的数据库,如果共同使用的信息,可以考虑放在common中使用

    • 避免事务操作跨应用;分布式事务是一个很消耗资源的问题,应用之间服务分开开发,能够保持相互独立

应用限流

​ 每个API接口都是有访问上限的,当访问频率或者并发量超过其承受范围时候,我们就必须考虑限流来保证接口的可用性或者降级可用性。即接口也需要安装上保险丝,以防止非预期的请求对系统压力过大而引起的系统瘫痪。首先先了解一下一些衡量服务器指标的概念:

  • QPS:对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准(每秒查询率)
    • QPS = 并发量 / 平均响应时间
    • 通常QPS用来表达和衡量当前系统的负载
    • 对应fetches/sec,即每秒的响应请求数,也即是最大吞吐能力
  • 响应时间(RT):响应时间是指系统对请求作出响应的时间
    • 它完整地记录了整个计算机系统处理请求的时间
    • 响应时间的绝对值并不能直接反映软件的性能的高低,软件性能的高低实际上取决于用户对该响应时间的接受程度
  • 吞吐量(TPS):系统在单位时间内处理请求的数量
    • 对于没有并发的系统而言,吞吐量就是响应时间的倒数
    • 通常用吞吐量作为并发系统的性能指标
    • 对于一个有并发的系统,如果只有一个用户使用时系统的平均响应时间是t,当有你n个用户使用时,每个用户看到的响应时间通常并不是n×t,而往往比n×t小很多(当然,在某些特殊情况下也可能比n×t大,甚至大很多)。这是因为在处理单个请求时,在每个时间点都可能有许多资源被闲置,当处理多个请求时,如果资源配置合理,每个用户看到的平均响应时间并不随用户数的增加而线性增加。实际上,不同系统的平均响应时间随用户数增加而增长的速度也不大相同,这也是采用吞吐量来度量并发系统的性能的主要原因。一般而言,吞吐量是一个比较通用的指标,两个具有不同用户数和用户使用模式的系统,如果其最大吞吐量基本一致,则可以判断两个系统的处理能力基本一致
  • 并发量:系统可以同时承载的正常使用系统功能的用户的数量
    • 对于网站系统一般会有三个关于用户数的统计数字:注册用户数、在线用户数和同时发请求用户数

​ 限流就是通过对并发访问/请求进行限速或一个时间窗口内的请求进行限速,从而达到保护系统的目的。一般系统可以通过压测来预估能处理的峰值,一旦达到设定的峰值阀值,则可以:

  • 拒绝服务(定向错误页或告知资源没有了)
  • 排队或等待(例如:秒杀、评论、下单)
  • 降级(返回默认数据)

限流常用算法:

  1. 计数器法

    ​ 该算法主要用来限制一定时间内的总并发数,比如数据库连接池、线程池、秒杀的并发数;计数器限流只要一定时间内的总请求数超过设定的阀值则进行限流,是一种简单粗暴的总数量限流,而不是平均速率限流

    ​ 这个方法有一个致命问题:临界问题——当遇到恶意请求,比如设定的阈值是1分钟内100次请求,在59秒时,瞬间请求100次,并且在60秒时请求100次,那么这个用户在1秒内请求了200次,用户可以在重置节点(就是重置计数器的值)时突发请求,而瞬间超过我们设置的速率限制,用户可能通过算法漏洞击垮我们的应用

  2. 滑动窗口算法

    ​ 滑动窗口算法类似将上面计数器法的1s时间拆分成若干个小窗口(此例拆分成4个窗口),每个窗口对应250ms;假设用户利用上一秒最后一刻和下一秒第一刻发起瞬间的高并发请求;此时会统计前一秒中的最后750ms和下一秒的前250ms,这样能够判断出用户的访问依旧超过了1s的访问数量,因此依然会阻拦用户的访问

  3. 漏桶算法

    ​ 水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水(请求)流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求(丢弃溢出的数据包);可以看出漏桶算法能强行限制数据的传输速率。

    ​ 因为漏桶的漏出速率是固定的,所以即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使流突发(burst)到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率

    漏桶算法示意图

  4. 令牌桶算法

    ​ 随着时间流逝,系统会按恒定1/QPS时间间隔(单位是ms)往桶里加入Token(想象和漏洞漏水相反,有个水龙头在不断的加水);如果桶已经满了就不再加了,新请求来临时会拿走一个Token,如果没有Token可拿了就阻塞(可以加入等待队列)或者拒绝服务。

    ​ 令牌桶的好处是可以方便的改变速度:一旦需要提高速率(应对突发传输),则按需提高放入桶中的令牌的速率.。一般会定时(比如100毫秒)往桶中增加一定数量的令牌,,有些变种算法则实时的计算应该增加的令牌的数量

服务降级与服务熔断

服务降级

​ 服务压力剧增的时候根据当前的业务情况及流量对一些服务和页面有策略的降级,以此缓解服务器的压力,以保证核心任务的进行。同时保证大部分请求,客户能得到正确的响应。也就是当前的请求处理不了了或者出错了,给一个默认的返回

降级分类

  1. 降级按照是否自动化可分为:
  • 自动降级
    • 超时降级:主要配置好超时时间和超时重试次数和机制,并使用异步机制探测回复情况
    • 失败次数降级:主要是一些不稳定的API,当失败调用次数达到一定阀值自动降级,同样要使用异步机制探测回复情况
    • 故障降级:比如要调用的远程服务挂掉了(网络故障、DNS故障、http服务返回错误的状态码、RPC服务抛出异常),则可以直接降级。降级后的处理方案有:默认值(比如库存服务挂了,返回默认现货)、兜底数据(比如广告挂了,返回提前准备好的一些静态页面)、缓存(之前暂存的一些缓存数据)
    • 限流降级:当我们去秒杀或者抢购一些限购商品时,此时可能会因为访问量太大而导致系统崩溃,此时开发者会使用限流来进行限制访问量,当达到限流阀值,后续请求会被降级;降级后的处理方案可以是:排队页面(将用户导流到排队页面等一会重试)、无货(直接告知用户没货了)、错误页(如活动太火爆了,稍后重试)
  • 人工降级

    在大促期间通过监控发现线上的一些服务存在问题,这个时候需要暂时将这些服务摘掉;还有有时候通过任务系统调用一些服务,但是服务依赖的数据库可能存在:服务器挂掉了或者很多慢查询,此时需要暂停下任务系统让服务方进行处理;还有发现突然调用量太大,可能需要改变处理方式(比如同步转换为异步);此时就可以使用开关来完成降级

  1. 降级按照功能可分为:

    • 读服务降级:对于读服务降级一般采用的策略有:暂时切换读(降级到读缓存、降级到走静态化)、暂时屏蔽读(屏蔽读入口、屏蔽某个读服务)

      比如多级缓存模式,如果后端服务有问题,可以降级为只读缓存,这种方式适用于对读一致性要求不高的场景

    • 写服务降级:比如秒杀抢购,我们可以只进行Cache的更新,然后异步同步扣减库存到DB,保证最终一致性即可,此时可以将DB降级为Cache

  2. 降级按照处于的系统层次可分为:

    • 多级降级:缓存是离用户最近越高效;而降级是离用户越近越能对系统保护的好。因为业务的复杂性导致越到后端QPS/TPS越低

服务熔断

​ 服务熔断一般是指软件系统中,由于某些原因使得服务出现了过载现象,为防止造成整个系统故障,从而采用的一种保护措施,所以很多地方把熔断亦称为过载保护

服务熔断和服务降级比较

  • 相似点:
    1. 目的很一致,都是从可用性可靠性着想,为防止系统的整体缓慢甚至崩溃,采用的技术手段
    2. 最终表现类似,对于两者来说,最终让用户体验到的是某些功能暂时不可达或不可用
    3. 粒度一般都是服务级别,当然,业界也有不少更细粒度的做法,比如做到数据持久层(允许查询,不允许增删改)
    4. 自治性要求很高,熔断模式一般都是服务基于策略的自动触发,降级虽说可人工干预,但在微服务架构下,完全靠人显然不可能,开关预置、配置中心都是必要手段
  • 不同点:
    1. 触发原因不太一样,服务熔断一般是某个服务(下游服务)故障引起,而服务降级一般是从整体负荷考虑
    2. 管理目标的层次不太一样,熔断其实是一个框架级的处理,每个微服务都需要(无层级之分),而降级一般需要对业务有层级之分(比如降级一般是从最外围服务开始)
    3. 实现方式不太一样

Hystrix

​ Hystrix旨在通过控制那些访问远程系统、服务和第三方库的节点,从而对延迟和故障提供更强大的容错能力。Hystrix具备拥有回退机制和断路器功能的线程和信号隔离,请求缓存和请求打包(request collapsing,即自动批处理),以及监控和配置等功能

Hystrix能做什么

  • 在通过第三方客户端访问依赖服务出现高延迟或者失败时,为系统提供保护和控制
  • 在分布式系统中防止级联失败
  • 快速失败(Fail fast)同时能快速恢复
  • 提供失败回退(Fallback)和优雅的服务降级机制
  • 提供近实时的监控、报警和运维控制手段

Hystrix设计原则

  • 防止单个依赖耗尽容器(例如 Tomcat)内所有用户线程
  • 降低系统负载,对无法及时处理的请求快速失败(Fail fast)而不是排队
  • 提供失败回退(Fallback),以在必要时让失效对用户透明化
  • 使用隔离机制(例如熔断器模式等)降低依赖服务对整个系统的影响
  • 针对系统服务的度量、监控和报警,提供优化以满足近实时性的要求
  • 在绝大部分需要动态调整配置并快速部署到所有应用方面,提供优化以满足快速恢复的要求
  • 能保护应用不受依赖服务的整个执行过程中失败的影响,而不仅仅是网络请求

JVM内存模型

发表于 2019-01-19 | 分类于 JVM

线程共享内存区域

Java堆

​ 此内存区域在虚拟机启动的时候创建,几乎所有对象的实例和数组都在堆中分配内存空间。

​ Java堆是垃圾回收主要的管理区域,而且现在的垃圾收集器基本都采用分代收集算法(这是针对不同分代的特点采用适当的收集算法,提高垃圾收集的效率),所以一般为了在内存方面出发,堆可以分为老年代和新生代。

新生代中,也可以细分为Eden空间,To Survivor空间和From Survivor空间等。(垃圾收集和对象内存分配在垃圾收集再详细分析)

  • 这里分为这样的空间是因为采用了标记复制的垃圾收集算法,但是基本的标记复制算法是采用1:1的方式划分空间,这样的话会浪费掉很多内存空间的使用,所以分成了两个Survivor区和一个Eden区(默认1:1:8)
  • 当Eden区被占满或者Eden区没有办法为新对象分配足够的内存空间时,会触发垃圾回收;垃圾收集后存活的对象会被复制到From区,当From区满的时候会将存活的对象复制到To区域(此时包括来自Eden区存活的对象),然后这两个区域会交换角色。注意:Survivor区总是有一个是空的,Survivor-From 区的对象分两类,一类是年轻的对象(分代年龄小),也是复制到 Survivor-To 区,还有一类是老东西,晋升到老年代中。(参考下面两张图片,来源:《Memory Management in the Java HotSpot Virtual Machine》)
  • 上面说的会将存活对象复制到To区域说得不全面,因为有可能To区域的内存会被占满,然后还有其他存活的对象就没有办法放入到To区中,那么这里就涉及分配担保机制了,也就是Survivor区域的空间不够用的时候,这些存活对象会直接通过这个机制进入到老年代中
  • 虚拟机会给每个对象定义一个对象年龄的计数器,当年轻代的对象经过MinorGC开始复制到Survivor区的时候,对象的年龄会增加1,当对象年龄达到一定程度或者触发分配担保机制的时候会晋升到老年代当中

年轻代垃圾收集前

年轻代垃圾收集后

方法区

​ 方法区主要用于存储被虚拟机加载的类信息(Class相关信息:类名,访问修饰符,字段描述,方法描述等),常量,静态变量,即时编译器编译后的代码等数据。

​ 方法区通常被我们称作:永久代。但是这只是HotSpot VM对方法区的实现而已;在其他的VM(JRockit等)并没有永久代。

​ 注意的是,在JDK1.8中,永久代已经被移除(JDK1.7中,永久代中的运行时常量池被移到堆中),此时HotSpot VM对方法区的实现变成了元空间Metaspace ,元空间和永久代的本质是类似的,不同的地方是元空间并不在虚拟机中,而是使用本地内存(Native Memory)。

在方法区中,对于很多动态生成类的情况容易出现OutOfMemoryError的异常。

常见的场景就是JSP页面较多的情况(JSP第一次运行需要进行编译)、CGLib增强和动态语言(Groovy)

运行时常量池

​ 运行时常量池是属于方法区的一部分(JDK7后被移到堆中)。其用于存储编译期生成的字面量和符号引用。

程序运行期间可以通过String.intern() 将变量放入运行时常量池中

线程私有的内存区域

程序计数器

程序计数器是当前线程执行的代码的行号指示器

  • 当执行Native方法的时候,该计数器值是空的(undefined)
  • 该内存区域是唯一一个没有规定OutOfMemoryError 情况的区域

Java虚拟机栈

​ Java虚拟机栈的生命周期与当前线程相同;虚拟机栈是Java方法执行时的内存模型,在方法执行时会创建栈帧存放局部变量表、操作数栈、动态链接和方法出口等信息。

​ 在虚拟机栈中,我们平常主要关注的是局部变量表:

  1. 局部变量表的内存空间在编译期间完成分配

  2. 局部变量表包括基本数据类型、指向字节码指令的地址和对象的引用(即reference)

    • 对象的引用主要有句柄和直接指针的两种方式:

      • 句柄:使用句柄的方式时,JVM会在Java堆中开辟一部分内存作为句柄池,虚拟机栈中的reference中储存的就是句柄的内存地址,句柄则包含了该对象的实例数据和类型数据的内存地址。

        类型数据:该类型数据是在方法区中的Class类信息和静态变量等数据

      • 直接指针:顾名思义就是虚拟机栈中的reference中储存的是该对象的实例数据和类型数据的内存地址。

    • 两种方式各有各的优劣势:

      • 句柄:其优势之处就是稳定,当垃圾收集器回收内存中的对象时,该对象要么存活在年轻代要么晋升到老年代,这个时候该对象的内存地址就会发生改变,此时虚拟机栈中reference 的指针就不需要发生改变,只需要改变句柄中对象的内存地址即可。
      • 直接指针:直接指针访问对象的速度会比句柄快,因为它少了一次指针定位的开销。在大量的对象访问的时候,直接指针的效率是相当的客观。

线程请求的栈深度大于虚拟机所允许的最大深度,会抛出StackOverflowError异常

这里的意思是:Java方法的从执行到结束就相当于将一个栈帧在虚拟机栈中的入栈和出栈的过程;当一个死循环的递归方法调用时,就会抛出StackOverflowError;因为死循环的递归,会不断的往虚拟机栈中压入栈帧,当达到最大深度时,则抛出该异常。

虚拟机在动态扩展时,无法为栈分配足够的内存空间时会抛出OutOfMemoryError异常

这里有容易会和上面那个混淆,唯一需要分清的是当栈空间不够分配的时候:这种情况下是因为内存不足还是栈的深度不够的问题就能分清楚这两种情况了。


本地方法栈

​ 本地方法栈跟Java虚拟机栈的作用基本上是相同的,不同之处是Java虚拟机栈服务的是Java方法,而本地方法栈服务的对象是Native方法

商品秒杀系统技术总结

发表于 2019-01-14 | 分类于 商品秒杀

这篇总结基于M课网的秒杀API的课程基础上来写的

这个课程我个人觉得有很多值得学习的地方,包括接口实现的思路,编码规范等等。

登录模块

​ 在登录这块,注册实现比较简单;主要是对password经过两层的盐值加密,前端加密然后后端也对其进行一次加密后再存放到数据库中。

登录验证

​ 这里主要对登录的验证沿用了拦截器来实现,主要的实现流程如下:

  1. 用户登录验证通过后(用户名和密码验证通过),生成随机码,对随机码进行加密操作

  2. 将加密后的随机码作为key,value存放user的json串存放到redis中(当然,可以不必要存放所有的字段,存放一些后续操作所需要的字段即可,这边我直接将user存放到redis中),并对其设置过期时间

  3. 放入redis后将生成的加密随机码放入cookie中,下次用户请求过来会带上cookie过来进行身份验证

  4. 我这边身份验证放到的拦截器中,就是简单的根据cookie的value来获取redis中的缓存,能够获取到user的即为验证通过

    这里有个小注意:

    Spring Boot 2.0后用配置类继承WebMvcConfigurerAdapter时,会提示这个类已经过时了

    此时可以通过实现WebMvcConfigurer接口解决该问题


JSR-303数据效验

(详细介绍:https://www.ibm.com/developerworks/cn/java/j-lo-jsr303/)

  • 使用JSR-303可以实现自定义的数据校验,这里我使用这个规范来对手机号码参数进行校验(推荐前后端一起校验)
  • 实现流程参考:https://blog.csdn.net/Adrian_Dai/article/details/83304599

自定义参数解析器

获取User对象可以实现了自定义参数解析器来为方法上的User参数进行解析,从cookie中获取到随机码,然后从redis中获取到User对象装填到方法的参数上。

  • Springmvc的自定义参数解析器是当接口参数中有某个类的时候触发,此时可以从该参数解析器中返回我们所需要的内容

  • 这里以User参数为例,自定义参数解析器实现HandlerMethodArgumentResolver接口,该接口下有两个方法

    • supportsParameter:当进入方法的参数解析时会调用这个方法,当这个方法返回true 的时候会执行resolveArgument方法对参数进行解析
  • 注意,要记得将其加入到List中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Configuration
    public class WebConfig implements WebMvcConfigurer {
    @Autowired
    UserArgumentResolver userArgumentResolver;
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
    argumentResolvers.add(userArgumentResolver);
    }
    }

商品和订单模块

​ 这里主要提及一下页面缓存、url缓存和对象缓存,如字面意思。来看下面的这段代码(注意阅读注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/**
* thymeleaf的页面渲染
* 注意点:因为在thymeleaf.spring5的API中把大部分的功能移到了IWebContext下面,用来区分边界。
* 剔除了ApplicationContext 过多的依赖,现在thymeleaf渲染不再过多依赖spring容器。
*
* 在spring4中的使用:
* SpringWebContext ctx = new SpringWebContext(request,response,
* request.getServletContext(),request.getLocale(), model.asMap(), applicationContext );
* String html = thymeleafViewResolver.getTemplateEngine().process("goods_list", ctx);
*
* RequestMapping中produces属性设置返回数据的类型以及编码;必须与@ResponseBody注解使用
*/
@RequestMapping(value="/to_list", produces="text/html")
@ResponseBody
public String list(HttpServletRequest request, HttpServletResponse response, Model model,MiaoshaUser user) {
model.addAttribute("user", user);
//取缓存
String html = redisService.get(GoodsKey.getGoodsList, "", String.class);
if(!StringUtils.isEmpty(html)) {
return html;
}
List<GoodsVo> goodsList = goodsService.listGoodsVo();
model.addAttribute("goodsList", goodsList);
// model 就是将参数存入 ,其中的所有参数 都是为了将页面渲染出来 放入其中,在返回一个静态的html源码
IWebContext webContext = new WebContext(request,response,
request.getServletContext(),request.getLocale(), model.asMap());
//手动渲染
String html = thymeleafViewResolver.getTemplateEngine().process("goods_list", webContext);
if(!StringUtils.isEmpty(html)) {
//页面缓存
redisService.set(GoodsKey.getGoodsList, "", html);
}
return html;
}

@RequestMapping(value="/to_detail/{goodsId}",produces="text/html")
@ResponseBody
public String detail(HttpServletRequest request, HttpServletResponse response, Model model,MiaoshaUser user,
@PathVariable("goodsId")long goodsId) {
model.addAttribute("user", user);

//取缓存
String html = redisService.get(GoodsKey.getGoodsDetail, ""+goodsId, String.class);
if(!StringUtils.isEmpty(html)) {
return html;
}
//手动渲染
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
model.addAttribute("goods", goods);

long startAt = goods.getStartDate().getTime();
long endAt = goods.getEndDate().getTime();
long now = System.currentTimeMillis();

int miaoshaStatus = 0;
int remainSeconds = 0;
if(now < startAt ) {//秒杀还没开始,倒计时
miaoshaStatus = 0;
remainSeconds = (int)((startAt - now )/1000);
}else if(now > endAt){//秒杀已经结束
miaoshaStatus = 2;
remainSeconds = -1;
}else {//秒杀进行中
miaoshaStatus = 1;
remainSeconds = 0;
}
model.addAttribute("miaoshaStatus", miaoshaStatus);
model.addAttribute("remainSeconds", remainSeconds);

IWebContext webContext = new WebContext(request,response,
request.getServletContext(),request.getLocale(), model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goods_detail", webContext);
if(!StringUtils.isEmpty(html)) {
//url缓存
redisService.set(GoodsKey.getGoodsDetail, ""+goodsId, html);
}
return html;
}
//对象缓存即:将对象化成json存放到reids中;在更新对象缓存的时候要注意先更新db数据库的再更新缓存中的数据来保持一致性

​ 这里对于页面缓存的实现,可以使用thymeleaf来实现;也可以使用对象缓存,然后用ajax获取数据并且利用vue或者angular来渲染页面,就不用将整个html页面存放到redis中去了。


秒杀模块

优化商品的秒杀API的主要内容:

(秒杀订单中商品ID和用户的ID做UNIQUE 约束)

  1. 隐藏秒杀地址

    • 当你可以对商品进行秒杀的时候,先去根据用户获取秒杀的url(如秒杀url:/{path}/do_seckill),然后将这个path根据用户放入到redis中
    • 获取到路径返回后再去请求刚才获取到的url进行商品的秒杀,此时会对商品的url上的path进行验证,是相同的path才能进行秒杀操作
    • 这里需要对获取path的接口进行防刷(限流)操作
  2. 接口防刷

    • 使用自定义注解加拦截器的方式实现接口的防刷

    • 自定义注解:

      1
      2
      3
      4
      5
      6
      @Retention(RUNTIME)
      @Target(METHOD)
      public @interface AccessLimit {
      int seconds();//n秒内可以请求maxCount次这个接口
      int maxCount();//最大次数
      }
    • 使用拦截器拦截秒杀API的请求(利用HandlerMethod hm.getMethodAnnotation(AccessLimit.class) 来获取自定义的AccessLimit注解)

    • 当满足在n秒内请求数量在maxCount以内的即可放行当前的请求

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
      throws Exception {
      if(handler instanceof HandlerMethod) {
      HandlerMethod hm = (HandlerMethod)handler;
      //方法上无该注解则直接放行
      AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
      if(accessLimit == null) {
      return true;
      }
      int seconds = accessLimit.seconds();
      int maxCount = accessLimit.maxCount();
      String key = request.getRequestURI();
      //获取现在的次数
      String count = jedisUtils.get(key, RedisDBEnum.ACCESS_BD.getDb());
      if(count == null) {
      jedisUtils.setex("access" + key, String.valueOf(1), seconds, RedisDBEnum.ACCESS_BD.getDb());
      }else if(Integer.valueOf(count) < maxCount) {
      jedisUtils.incr("access" + key, RedisDBEnum.ACCESS_BD.getDb());
      }else {
      render(response, CodeMsg.ACCESS_LIMIT_REACHED);
      return false;
      }
      }
      return true;
      }
  3. 图形验证码方式防刷

    • 在用户进入秒杀商品页面(跳转该页面时,服务端生成与用户对应的验证码,并且将该验证码与用户对应的方式放入到redis中存放,设置过期时间)的时候增加验证码输入框

    • 注意需要为验证码增加一个刷新验证码的接口,用户刷新验证码的时候生成一个新的验证码并且删除旧的验证码,再将新的验证码放入到redis中同时设置过期时间

      • 记得一定要对刷新验证码的接口进行防刷的操作(因为刷新验证码接口需要对redis进行数据操作,对redis 操作是有网络开销的,不做防刷操作的话,被别人恶意请求的话是会对服务器产生负担的)
    • 验证码的类型:可以生成算术题目的验证码,也可以是简单的字母+数字组合的验证码;可自行选择,只有验证码验证通过后再进行商品的秒杀

    • 下面引用一个别人写好的例子(使用swing生成一个算术类型的验证码):

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      public Results<String> getSeckillVerifyCod(HttpServletResponse response,User user,@RequestParam("goodsId")long goodsId) {
      try {
      BufferedImage image = orderService.createVerifyCode(user, goodsId);
      OutputStream out = response.getOutputStream();
      ImageIO.write(image, "JPEG", out);
      out.flush();
      out.close();
      return null;
      }catch(Exception e) {
      e.printStackTrace();
      return Results.faild(CodeMsg.SECKILL_FAIL);
      }
      }

      public BufferedImage createVerifyCode(User user, long goodsId) {
      if(user == null || goodsId <=0) {
      return null;
      }
      int width = 80;
      int height = 32;
      //create the image
      BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
      Graphics g = image.getGraphics();
      // set the background color
      g.setColor(new Color(0xDCDCDC));
      g.fillRect(0, 0, width, height);
      // draw the border
      g.setColor(Color.black);
      g.drawRect(0, 0, width - 1, height - 1);
      // create a random instance to generate the codes
      Random rdm = new Random();
      // make some confusion
      for (int i = 0; i < 50; i++) {
      int x = rdm.nextInt(width);
      int y = rdm.nextInt(height);
      g.drawOval(x, y, 0, 0);
      }
      // generate a random code
      String verifyCode = generateVerifyCode(rdm);
      g.setColor(new Color(0, 100, 0));
      g.setFont(new Font("Candara", Font.BOLD, 24));
      g.drawString(verifyCode, 8, 24);
      g.dispose();
      //把验证码存到redis中,设置过期时间
      int rnd = calc(verifyCode);
      redisService.set("seckill_vc_" + user.getId()+"_"+goodsId, rnd,60 ,RedisDBEnum.ACCESS_BD.getDb());
      //输出图片
      return image;
      }

      private static int calc(String exp) {
      try {
      ScriptEngineManager manager = new ScriptEngineManager();
      ScriptEngine engine = manager.getEngineByName("JavaScript");
      return (Integer)engine.eval(exp);
      }catch(Exception e) {
      e.printStackTrace();
      return 0;
      }
      }

      private static char[] ops = new char[] {'+', '-', '*'};

      private String generateVerifyCode(Random rdm) {
      int num1 = rdm.nextInt(10);
      int num2 = rdm.nextInt(10);
      int num3 = rdm.nextInt(10);
      char op1 = ops[rdm.nextInt(3)];
      char op2 = ops[rdm.nextInt(3)];
      String exp = ""+ num1 + op1 + num2 + op2 + num3;
      return exp;
      }
  4. MQ实现异步商品秒杀

    为什么要异步下单请往下看。。

    • 首先需要将秒杀商品的sku放入到redis中
    • 当到了秒杀的时间段开始秒杀的时候
    • 先判断redis中的sku是否还有(大于0),没有则直接返回秒杀失败;还有sku的时候,对redis中sku 的值进行减一操作
    • 此时需要查看是否该用户已经秒杀过该商品了(视业务而定,在这里一个用户只能秒杀一次)
      • 如果已经秒杀过该商品的用户,直接返回(可以对sku进行恢复)
    • 如果不是秒杀过该商品的用户,那么将用户和商品信息放入到消息队列中(可以使用Direct Exchange模式)
    • 加入消息队列后即可返回给用户(排队或其他消息),然后轮询订单的接口查看订单是否已经准备好
      • 下单的系统监听该消息队列,获取到消息时先判断用户是否已经秒杀过。如果没有则进行减库存、下订单的操作

为什么要异步下单?

(个人理解,大神们有更好的理解可以发表一下,非常感谢!)

  • 在电商平台中,需要考虑到技术方面的各个环节,在这个项目中实现的商品秒杀并不是基于服务化的环境去搭建的系统,在这里只是给出了一些实现的思路而已。

  • 下单操作就需要业务,网络和并发量等方面的问题了,一般服务分得细的话,订单的操作应该交由特定的服务去处理,这里就可以交由消息队列去实现异步的下单操作。

  • 秒杀的API可以先返回排队或者请稍等的文案,然后又前台去轮询订单是否已经生成。这样做还能降低系统的复杂度,并且如果是同步的操作那么在很短时间处理很大量的并发请求的话难度是很高的,异步方式去处理的话能够保障(注意:不是保证!)系统的可用性了。

12
Adrian Dai

Adrian Dai

看板娘赛高!

13 日志
7 分类
32 标签
GitHub E-Mail CSDN
© 2019 Adrian Dai