方案设计

190 阅读12分钟

架构设计流程

结构设计目的

架构设计的目的是解决系统的复杂性。

系统的复杂度识别

技术层面的复杂性来源包括三方面,高性能,高可用,可扩展。

识别系统复杂度实践

场景

用户发布微博时,首先通知审核子系统对微博内容进行审核,其次再通知子系统进行统计,而后还需要通知广告子系统进行广告预测等。一条微博的成功发布需要通知下游十多个子系统。当前子系统的通知均通过系统间的接口调用。其每增加一个子系统就要进行接口设计,开发,测试,效率较低,问题定位艰难。

问题根源

系统间通过接口的方式调用。架构上各个子系统强耦合。

方案

使用消息中间件对于微博系统与各子系统进行解耦。即微博发布时主要主系统将对应的信息发布至指定的消息队列。后续的子系统对于队列进行订阅,按需获取消息。替换原有的系统之间的接口调用。

技术选型-排查法

  1. 消息队列是否需要高性能
    • 用户每天发送微博的数量为1000万条。即消息队列会产生1000万条消息每天。假定存在10个子系统。则每天消息的读取为1亿次。
      • 统计队列的TPS与QPS
      • TPS 为115
      • QPS 为1150 条
      • 消息的读写不是平均的,一般需要考量其峰值。一般取值为平均值的三倍。因此其TPS是345 QPS是3450 分析所得该数据量集并不要求高性能
      • 考虑后续系统的业务增长,系统设计需要考量性能余量。因此系统容量峰值的4倍。最终的性能要求TPS 1380,QPS为13800
  2. 消息队列是否需要高可用
    • 消息被丢失或非法博客未被审核导致触犯法律。因此消息队列需要确保其高可用。包括数据消息的写入,消息的存储,消息的读取都需要保证其高可用
  3. 消息队列是否高的可扩展性
    • 该系统的中消息中间件的业务功能已经很明确,因此可忽略其扩展性

分析结论

该系统使用的消息队列的复杂性主要体现在,高性能消息读取、高可用的消息的写入、高可用消息的存储。

中间件的基础处理能力

  1. nginx 负载均衡性能RPS 3万左右

  2. zookeeper写入读取2万左右

  3. kafka 百万级

架构设计-高性能的存储与访问

读写分离

分库分表

服务的注册与发现

业务场景--后台几十个服务进行高效管理

整个系统因业务扩展中后台服务数激增,且各个服务间存在相互调用。除此之外服务应用的语言也不尽相同。如java,next.js go 等。目前流行的微服务的框架Springbootcloud与Dubbo 均为针对java语言的。

  1. 现状 服务之间的调用关系通过本地的配置文件进行关联。配置过程为首先通过加载本地的配置文件获取服务的主机地址,而后在通过代码中加上URI组装成URL。而后所有的服务均是通过NGINX代理。

    • 存在问题 系统上线部署的场合,每次新增服务,或添加,减少机器时,NGINX都需要手动同步。且每个环境不一样。容易出错。因此每次服务器迁移或网络变更的场合,都需要将配置重新梳理,并进行多轮测试才能确保质量。系统的可移植性较差。

微服务中数据的一致性

数据的一致性的区分

最终一致性

  1. 定义: 最终一致性是指系统中的数据在一段时间内可能存在不一致的状态,但最终会达到一致的状态. 最终一致性模式下,系统允许在不同的服务之间存在一定的延迟和数据冲突。系统会通过异步消息、事件驱动等方式来处理数据的一致性。最终一致性可以提供较高的可用性和性能,并且可以容忍一定的数据不一致性。

实时一致性

分库分表

场景

亿万级订单数据快速的实现读写。用户表数据400万,订单表1200万。其按照新的业务增量,每天会新生成100万的订单。

场景分析

原有的表数据已经较大。其读写速率已经因数据量问题受到影响。而关键点为其后续表数据量每天会激增100万。因此如何确保系统的性能,数据存储的方案设计不容忽视。

方案,分库,分表 现将订单表数据进行拆分,而后进行分布式存储。

  1. 原有的订单表为单表且对应只有一个数据库sale.而后则首先会构建多个数据库。且每个数据库中存在多个订单表的子表
  2. 订单数据需要按照一定的规律尽可能均衡的分布到多个数据库中的多个订单表中

拆分存储常用的技术方案

  1. 分区
    • 分区主要实现将数据表不同的行存储在不同的存储层文件中。
    • 不足:仅仅实现了存储的分摊,对于请求无法进行分摊,负载
  2. NoSQL
    • NoSQL比较典型就是MongoDB。MongoDB的分片功能起性能足够。但其不是关系型数据库,其数据文档型存储。在存储重要的数据过程中,因为订单数据必须使用强制约束特性。如订单的金额字段。因此直接应用较为不便
  3. NewSQL
    • NewSQL 结合了NoSQL与关系型数据库一些优点特性。但其技术相关医用实践较少。
  4. 分库分表 分表:将一份大的数据表拆分后存放至多个结构一样的拆分表中;分库讲一个大数据库查分成类似于多个结构的小数据库。

方案选择:分库,分区

考量点

  1. 使用什么字段作为拆分字段
  2. 拆分策略
  3. 业务代码如何修改
  4. 历史数据如何迁移
  5. 未来扩容的方案

读缓存

场景

电商项目,其商品的详情页中,随着业务需求的增加,商品详情页加载的数据流很多。如商品本身的信息,图片,介绍,规格,评价。以及相关商品推荐列表,最近的成交状况,优化活动等。用户浏览该页面时需要再在后台查询几十个SQL后才能获取页面渲染时需要的数据。时长较长,用户体验较差。

场景分析。

商品详情页随着后续的应用其页面端的功能可能会继续追加。其必严重造成页面加载缓慢。因此其项目优化不可或缺。 想定的方案1.是否可对数据库进行重构。2.利用缓存,将页面中常用的且变更频度较小的数据预先进行缓存,少部分的必要数据通过异步加载的方式。如最近的成交量,采用异步加载。只有当前用户打开商品详情页以后,再在后台加载最近的成交数据。而后在线页面进行渲染。

方案:利用缓存的方式预先加载部分数据

读缓存

使用缓存的逻辑

  1. 常识从缓冲中获取数据
  2. 若缓存中没有数据或者数据过期,再从数据库中读取数据保存在缓存中
  3. 将缓冲中的数据返回给调用方

可能存在的问题

当某一时刻有大量请求时,其缓存中一旦不存在相关的数据,其压力会传递到数据库服务端。可能造成服务器宕机。

数据库崩溃的原因

  1. 单一的数据过期或不存在。--缓存击穿
    • 方案:第一个线程发现key不存在的场合,先给key添加锁机制,防止后续线程再次访问。而后有加锁后的线程将数据从数据库中读取到缓存中。而后在释放锁。
 如Java程序中可使用关键字snychronized关键字确保线程加锁。将数据从数据库加载到缓存的过程中屏蔽其他线程
  1. 数据大面积过期或者Redis宕机。--缓存雪崩

    • 设置缓存的过期时间为随机分布或永不过期
  2. 恶意请求,key在缓存数据库中不存在--缓存穿透

    • 方案1: 业务逻辑中对于key进行校验
    • 方案2: 设定空值,提前设定恶意的key在缓存中

缓存预热

上述逻辑都是在用户真正请求的时具体的处理。预热至的是在用户请求过来之前将数据缓存到Redis中 具体推荐做法:如深夜时间段,将预热的数据加载到缓存中

缓存更新:更新数据库与更新缓存

  1. 先删除缓存,更新数据库,再删除缓存 先删除缓存,避免其他线程在主线程更新数据库之中从缓存中获取原始数据。

写缓存

场景

临时,短期的促销活动。9:00 ~9:15 期间。用户在商品详情页面半价抢购一款热门商品。预计的流量为几十万的预约量

场景分析

目标 15分钟内的流量为1百万。数据库服务是否能支持如此的并发量。 按照一般的预购场景,其中百分之90的流量会集中在前期1分钟内。其不是平稳分布 即就是可能5分钟内完成80%的预约。

峰值估算:1分钟内完成90%的预约活动。即就是90万的预约量。则TPS(吞吐量)=90/60 即就是每秒1.5万次的写请求。

现状:数据库服务器压测阈值为TPS=2200times/s.

方案:通过使用缓存,或消息队列实现数据库的写降频

写缓存

写缓存思路后台收到用户请求时,如请求校验通过,数据不会直接落库。而是首先存储在缓存层中,缓存层中的写请达到一定数量时再进行批量的落库操作。实际意义:利用写缓存的高吞吐能力承受流量的洪峰,再将数据匀速的迁移到数据库。

架构示意图

graph TD
写请求 --> 缓存层
缓存层-->批量落库
触发批量操作-->批量落库
批量落库-->数据库

方案中存在要点问题

  1. 写请求与批量落库是同步还是异步

    • 同步处理的场合,写请求提交数据后,写操作的线程需等待批量落库完成后才开始启动

      • 优点:用户预约成功后,即可在预约详情页面试试查看其预约数据
      • 不足:用户提交请求后,需要等待时间才会返回结果,且等待的时间区间不定,若存在批量落库,若请求链接一直阻塞则等待时间无法确认。
    • 异步处理的场合,写提交请求后,会直接提示用户提交成功

      • 优点:用户快速得到回复
      • 不足:直接查看预约详情,可能会存在数据还未同步的场景
    • 异步的实现方案

      • 我的预约页面给用户一个提示:指明预约的详情可能会存在一定的延时
      • 用户预约成功后,直接进入预约完成的详情页面,此时页面会定时的发送请求查询后台批量落库的状态,一旦落库成功,则弹出成功提示,并跳转至下一个页面。
  2. 如何触发批量落库

    • 方案1.时间窗口的方式,每隔一定的时间落库一次
      • 优点:用户等待的时间不会太久
      • 部分时间窗口内可能会存在较大的请求量
    • 方案2.写请求满足一定的次数后,实现落库
      • 优点:访问数据库的次数减少
      • 不足:若预约数一直无法凑够指标,则预约已知等待
    • 实现方案: 同时采用上述两种方案,1.即就是每次收集一次写请求,就插入到缓存中,在判定缓存中的数据是否达到指定的总数,达到后触发落库操作。2.开一个定时器,每隔一定的时间段自动触发落库操作。
  3. 缓存数据存在哪里

    • 数据缓存常用的存储可分为两类:本地内存,中间件【Redis或者MQ】
    • 实现方案:最终采用redis 作为数据存储的缓存依赖。
graph TD
写请求 --> Redis
写请求-->判断缓存数量
判断缓存数量-->批量落库
批量落库-->数据库
定时器-->批量落库
  1. 批量数据落库失败
    • 批量数据落库的实现逻辑:1.当前线程从缓存中获取所有的数据【因每10条执行一次落库操作,不用考量数据量过大,分批操作】。2.当前线程批量保存数据到数据库。 3. 当前线程从缓存中删除对应的数据。【注意,不能直接清空缓存操作,因为新的预约数据可能在执行期间已经被插入到缓存中了。】

    • 批量落库失败: 可能执行批量落库操作失败的步骤

        1. 从缓存中获取数据失败:如果是获取失败,则不用特殊处理,后续的操作自然将之前未处理的数据进行入库
        1. 批量保存数据到数据库:事务的方式进行包裹,若失败则回滚
        1. 从缓存中删除数据,考虑数据操作的幂等性