架构设计相关思想详解笔记

系统设计原则

高并发原则

无状态

应用无状态、配置文件有状态,那么应用容易水平扩展

拆分&&服务化

如果开发人员少,资源有限,就没有必要对系统拆分(拆分商品和订单)

如果类似京东秒杀系统,访问量大且投入资源充足可按功能拆分系统

  • 系统维度

按照功能/业务拆分;比如商品系统、购物车、结算、订单系统等

  • 功能维度

对于功能模块可以按照不同的业务逻辑状态再进行划分;例如要优惠券拆分优惠券创建系统、领券系统、用券系统等

  • 读写维度

按照读写不同的压力进行拆分;读的量大于写,因此拆分商品写服务、商品读服务

  • AOP维度

根据访问特征;比如商品详情页可以分为CDN、页面渲染模块等

  • 纵深维度

按照纵深的维度,最底层的是基础服务,最上层的是业务服务;代码结构一般按照MVC三层架构来进行划分。


随系统拆分越来越多,需要考虑服务自动注册与发行

其次还要考虑服务的分组、隔离

随着调用量增加还考虑服务的限流、黑白名单

还要一些细节上的超时时间、重试机制、服务路由(能动态切换不同分组),故障补偿等


消息队列

功能:用消息队列进行服务解耦、异步处理、流量肖峰/缓冲等

如果订阅者太多,订阅单个消息队列会成为瓶颈,此时考虑对消息队列进行多个镜像复制集群

考虑的问题?

  • 处理生成消息失败
  • 消息重复接收的场景
    • 对消息重复,业务层面需要进行防重处理
  • 有些消息队列提供重试,重试完还未成功,对不容忍失败的业务场景要做好后续的数据处理工作
    • 如:增加日志、报警灯


数据闭环

数据闭环如商品详情页,数据来源太多,影响服务稳定性因素也多

最好的颁发就是使用到的数据进行异构存储,形成数据闭环

1
2
3
数据异构:MQ机制接收数据变更,存储到合适的存储引擎如Redis
数据聚合:数据异构目的就是把数据多个数据源拿过来做聚合,这样前端可以调用一个拿到所有数据
前端展示:前端通过一次货少量几次调用拿到所需要的数据


数据缓存

浏览器缓存

设置响应头Expires、Cache-control控制

适用于对实时性不敏感的数据,如商品详情页、广告词
不适用价格、库存等要求较高的数据

App客户端缓存

App所需要的如首屏数据缓存下来,如网络异常也有客户端降级的托底数据展示给用户

CDN缓存

有些页面、活动页、图片等服务考虑将推送到离用户最近的CDN节点

有推送机制(内容变更主动推CDN节点)和拉取机制(访问节点,当没有内容回源到服务器拿到内容缓存到节点)

Nginx缓存

  • proxy_cache:使用内存级/SSD级代理来缓存内容
  • proxy_cache_lock:使用lock机制,将多个回源合并一个,减少回源量,并设置lock超时时间

应用层缓存

应用部署一组Redis,先读本机的Redis获取数据,多机之间使用主从机制同步数据

  • 先进(nginx+lua)读取本地proxy cache/local cache
  • 如果不命中,则接入层会读取分布式Redis集群
  • 如果还不命中,则回源到Tomcat,然后读取Tomcat应用堆内的cache
  • 如果缓存还没命中,则调用依赖业务来获取数据,然后异步化写到Redis集群




高可用原则

降级

高可用服务,重要设计就是降级开关

  • 开关集中化管理:推送机制把开关推送到各个应用

  • 可降级多级读服务:服务调用降级为只读本地缓存、只读分布式缓存、只读默认降级数据

  • 开关前置化:如Nginx->Tomcat;可以将开关前置到Nginx接入层,降级后不回源Tomcat集群或只有一小部分流量访问


限流

防止恶意请求流量、恶意攻击或防止流量超出系统峰值

  • 恶意请求只访问到cache
  • 对穿透到后端应用的流量用Nginxlimit模块处理
  • 对恶意IP可以使用nginx deny进行屏蔽


切流量

多机房某个机房挂了或某个机架挂了或某台服务器挂了,都需要如下手段切流量

  • 1:DNS 切换机房入口

  • 2: HttpDns 客户端配置DNS,绕过运营商DNS

  • 3: LVS/HAProxy 切换故障的Nginx接入层

  • 4: Nginx: 切换故障的应用层


可回滚

版本华可审计可追溯

  • 事务回滚
  • 代码库回滚
  • 部署版本回滚
  • 数据版本回滚
  • 静态资源版本回滚
  • 应用配置回滚




业务设计原则

防重设计

如下单扣库存需要防止重复扣减库存,解决方案考虑防重Key、防重表

幂等设计

消息中间件基本不保证不发重复消息

因此重复消息等接口需要设计幂等处理

状态与状态机

交易系统设计中订单状态(待付款、待发货、已发货、完成)和逆向状态(取消、退款)

考虑要不要使用状态机来驱动状态的变更和后续流程节点操作,尤其是状态很多的时候使用状态机能够更好地控制状态迁移




隔离

线程隔离

线程隔离主要指线程池隔离,将请求分类交给不同的线程池处理,当一种业务的请求处理发送问题,不会将故障扩散带其他线程池,从而保证其他服务可用


进程隔离

刚开始都是从零到一做系统,而不是上来就系统拆分,这样开发出的大而全系统,系统中某个功能/模块出现问题,整个系统就不可用了

解决方案是部署多个实例,通过均衡负责进行转发,但无法避免某个Bug导致系统不可用的风险

因此较好的方案是拆分多个子系统实现物理隔离,通过进程隔离使得某个子系统出现问题时不会应影响到其他子系统


集群隔离

随系统发展需要服务化技术,通过部署多个服务形成的服务集群,来提升系统容量,多个服务集群进行隔离


机房隔离

当一个机房服务发送问题时,DNS/负载均衡将请求全部切换另一个机房


读写隔离

通过主从模式将读和写集群分离,读服务只从Redis集群获取数据,当主Redis集群出现问题,从Redis集群还是可用,从而不影响用户


动静隔离

JS/CSS静态资源放入CDN,


热点隔离

秒杀、抢购属于非常适合的热点例子

对于这类热点应用将秒杀和抢购做成独立系统或服务进行隔离,从而保证秒杀和抢购不影响主流程


资源隔离

常见的CPU、磁盘、内存资源都存在竞争问题

使用Docker容器虚拟化进行资源隔离




限流技术

限流算法

常见的限流算法有:令牌桶、漏铜、计数器(粗暴限流实现)

令牌桶

  • 假设限制2r/s,则按照500毫秒的固定速率往桶中添加令牌
  • 桶中最多存放b个令牌,当桶满时,新添加的令牌被丢弃或拒绝
  • 当一个n个字节大小的数据包到达,将从桶中删除n个令牌,接着数据包被发送到网络上
  • 如果桶中的令牌不足n个,则不会删除令牌,且该数据包被限流(要么丢弃,要么缓冲区等待)


漏桶算法

漏铜作为计量工具,可用于流量整形和流量控制

  • 一个固定容量的漏桶,按照常量固定速率流出水滴
  • 如果桶是空的,则不需要流出水滴
  • 可以以任意速率流入水滴到漏桶
  • 如果流入水滴超出了桶的容量,则流入的水滴溢出(被丢弃),而漏桶容量是不变的


区别

  • 令牌桶允许突发请求,只要有令牌就可以处理,支持一次拿3个令牌或4个令牌,并允许一定程度的突发流量
  • 漏桶限制的是常量流出速率,流入请求速率任意,当流入的请求数累积到漏桶容量,则新流入的请求被拒绝


应用级限流

限流总并发/总连接/总请求数

Tomcat的Connector其中一种配置如下几个参数

  • acceptCount:如果Tomcat线程都忙响应,新来的连接会进入队列排队,超出队列大小拒绝连接

  • macConnections:瞬时最大连接数,超出会排队等待

  • maxThreads:Tomcat能启动用来处理请求最大线程数,如果请求处理量一直远远大于最大线程数,会引起响应变慢甚至僵死


限流资源数

对数据库连接、线程采用池技术管理来限制总资源数,如连接池、线程池


限流某个接口的总并发数和请求书

采用Java的AtomicLongSemaphore进行限流

Hystrix在信号量模式也使用了Semaphore限制某个接口的总并发数


限制某个接口的时间窗请求数

即一个时间窗口内的请求数,如限制某个接口/服务每秒/每分钟/每天的请求数


平滑限流某个接口的请求数

之前的限流不能很好的应对突发请求,即瞬间请求可能都被允许,从而导致一些问题,即没有进行平均速率请求处理

使用Guava的RateLimiter提供的令牌桶算法可用于平滑突发限流平滑预热限流

1
2
3
4
5
6
7
RateLimiter limiter = Ratelimiter.create(5);

System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
  • 1:Ratelimiter.create(5);表示桶容量为5,每秒新增5个令牌,即每隔200毫秒新增一个令牌
  • 2:limiter.acquire(),表示消费一个令牌,如果当前桶有足够令牌,则成功(返回值为0),如果桶中没有令牌,则暂停一段时间(返回值为暂停的时间数量)


分布式限流

Nginx+Lua实现复杂的令牌桶或漏桶算法,从而进行分布式口子上的限流



接入层限流

接入层指请求流量入口,该层主要目的有:负载均衡、非法请求过滤、请求聚合、缓存、降级、A/B测试、服务质量监控

对Nginx接入层限流可以使用Nginx自带的两个模块:连接数限流ngx_http_limit_conn_module或漏桶算法实现的请求限流ngx_http_limit_req_module




降级技术

  • 降级按照是否自动化可分为:自动开关降级人工开关降级

  • 降级按照功能可分为:读服务降级写服务降级

  • 降级按照处于系统层次可分为:多级降级

自动开关降级

自动降级根据系统负载、资源使用情况、SLA等指标进行降级

超时降级

当访问数据/http响应慢, 且该服务不是核心服务可以在超时后自动降级

比如商品详情页上有推荐内容/评价


统计失败次数降级

依赖不稳定API,比如调用外部机票服务,当失败调用次数达到一定阈值自动降级(熔断器),然后异步线程去探测服务是否恢复了,恢复则取消降级


故障降级

如果远程服务(网络故障、DNS故障、HTTP服务返回错误的状态码、RPC服务抛出异常)直接降级

降级后处理方案:默认值、兜底数据(比如广告、提前准备好的静态页面)、缓存(之前访问暂存的一些缓存数据)、降级数据池


限流降级

因访问了过大而导致系统崩溃,开发者会使用限流限制访问量,后续请求就会被降级

降级页面处理方案: 排队页面,无货,错误页


人工开关降级

开关存放在配置文件、数据库、Redis/Zookeeper,定期同步开关数据,通过判断Key的值来决定是否降级


读服务降级

读降级一般采用的策略

  • 暂时切换读(降级到读缓存、降级到走静态化)
  • 暂时屏蔽读(屏蔽读入口、屏蔽某个读服务)

还有页面静态化

平时网站走动态化渲染商品详情页,到了大促来临之际将其切换为静态化来减少对核心资源的占用,从而提升性能
通过一个程序定期推送静态页到缓存或者生产到磁盘,出问题时直接切过去


写服务降级

写服务大多数场景是不可降级的,不过可以将同步操作转为异步操作,或者限制写的量/比例

正常情况下为同步扣除库存,在性能扛不住时,降级为异步操作

例如秒杀场景可以直接降级为异步,从而保护系统
如下单操作可在大促销时暂时降级,将下单数据写入Redis,等峰值过去再同步回DB


多级降级

缓存是离用户越近越高效,而降级是离用户越近对系统保护得越好

  • 页面JS降级开关:主要控制页面功能的降级,在JS脚本部署功能降级开关,在适当实际开关

  • 接入层降级开关:主要控制请求入口的降级,请求进入后会首先进入接入层,接入层可配种功能降级开关,根据实际情况进行人工/自动降级

  • 应用层降级开关:主要控制业务的降级,在应用中配置相应的功能开关,根据实际业务情况进行自动/人工降级




缓存技术

缓存是系统快速响应中的关键技术,是一组被保存起来以备将来使用的东西


缓存算法

缓存技术专用术语

  • 缓存命中:请求的数据在缓存中
  • 没有命中:cache miss如果缓存中有存储空间,没有命中的对象会被存储到缓存中
  • 存储成本:当数据存放缓存所需要的时间和空间就是成本
  • 缓存失效:当存储在缓存中的数据需要更新,意味着缓存中的数据失效了
  • 替代策略:当缓存没有命中,缓存容量也满了,就需要缓存移除旧数据再加新数据

LRU(Least-Recently-Used)

替换最近请求最少的对象, 在CPU缓存淘汰和虚拟内存系统中效果最好

LFU(Least-Frequently-Used)

替换访问次数最少的缓存,保留最常用、最流行的对象

LRU2(Least-Recently-Used2)

LRU的变种,两次访问过的对象放入缓存池,池满后把两次最少使用的缓存对象去除

2Q(Two Queues)

把访问数据放入LRU缓存中,如果对象再访问就转移第二个、更大的LRU缓存
去除缓存对象为保持第一个缓存池是第二个缓存池的1/3,当缓存的访问负载是固定时候,LRU换成LRU2

LRU-Threshold

不缓存超过某一Size的对象,其他和LRU相同

MRU(Most Recently Used)

MRU与LRU相对,移除最近使用最多的对象

FIFO

FIFO通过队列跟踪所有缓存对象,容量满的时候排在前面的缓存会被踢走

RandomCache

随机缓存,随意的替换缓存数据


缓存回收策略

  • 基于空间

基于空间指缓存设置了存储空间,如设置为10MB,当达到存储空间上限时,按照一定的策略移除数据

  • 基于容量

基于容量指缓存设置了最大大小,当缓存的条目超过最大大小时,按照一定的策略移除旧数据

  • 基于时间

    • TTL:存活期,即缓存数据从创建开始直到到期的一个时间段(缓存数据都将过期)
    • TTI:空闲期,即缓存数据多久没被访问后移除缓存的时间


浏览器缓存

HTTP 1.1引入实体标签E-TAG, E-TAG是文件或对象的唯一标识,这意味着可以请求一个资源以及提供所持有的文件

如果某一个文件的E-TAG是有效,那么服务器会生成304-Not Modified应答(浏览器直接从本地缓存取数据)
否则会发送200-OK应答(如果数据有变化,将整个数据重新发给浏览器)

Last-Modified/ETagCache-Control/Expires的作用是不一样的,如果检测到本地缓存还在有效的时间范围内,浏览器则直接使用本地缓存,不会发送任何请求

两者在一起使用时,Cache-Control/Expires的优先级要高于Last-modified/ETag (即当本地副本数据根据Cache-Control/Expires发现还在有效期内,则不会发送请求去询问服务器修改时间Last-Modified或标签实体E-Tag)


后端缓存

接入层缓存

Nginx+Lua配合Redis可以在网关进行缓存操作


数据库缓存

Mysql的查询缓存

Query Cache作用于整个Mysql实例,主要用于缓存Mysql中的ResultSet,也就是一条Sql语句执行的结果集,所以仅仅只能针对于Select语句

Query Cache流程

1
2
3
Mysql会根据直接预先设定好的Hash算法将接收到的Select语句以字符串方式进行Hash
然后到QueryCache中查找是否已经缓存
如果已经有结果在缓存中,该Select请求就会直接将数据返回,从而省略后面的步骤,从而极大提高性能

当然数据变化频繁使用Query Cache可能得不偿失

  • query_cache_size:缓存ResultSet的内存大小
  • query_cache_type:设置在任何场景下使用QueryCache

InnoDB缓存性能

innodb_buffer_pool_size是影响性能的关键参数

用来设置缓存InnoDB索引及数据块的内存区域大小

当操作一个InnoDB表的时候,返回的所有数据或查询过程中用到的任何一个索引块,都会在这个内存区域中查询一遍

table_cache是Mysql重要的性能参数

主要用于设置table高速缓存的数量

当某一连接访问表的时候,Mysql先检查当前已经缓存表的数量,如果已经在缓存中,则直接访问缓存中的表加快查询速度,如果未被缓存则将当前表添加进缓存并进行查询


应用级缓存

多级缓存的系统架构

Nginx应用服务器读取本地缓存,实现本地缓存方式Lua Shared Dict,或者面向磁盘或内存的Nginx Proxy Cache,以及本地的Redis实现等

如果本地缓存命中直接返回,如果本地缓存没有命中,就会进一步读取相应的分布式缓存Redis分布式缓存集群

如果分布式缓存命中则直接返回响应数据,并回写到Nginx应用服务器的本地缓存中

如果Redis分布式缓存集群也没有命中,就会回源到Tomcat集群,在回源到Tomcat集群也使用轮询和一致性哈希作为负载均衡算法

Tomcat集群应用中首先读取本都平台级缓存,如果平台级缓存命中则直接返回数据,并会同步写到主Redis集群,然后再同步到从Redis集群

如果所有缓存都没有命中,则只能查询数据库或者相关的服务获取相关数据并返回,当然数据库也是有缓存的