Alibaba interviewJava学习之路Java面试

如何构建高并发高可用的网站应用,以及我的思考

2017-03-07  本文已影响600人  Raynor_Chan

前言

本文版权属于©天职信息,你可以用它做任何事情,但是转载请保留链接。
http://www.tztech.net

我们构建的大多数系统都不用考虑并发性能。然而,系统的“速度”是用户体验的第一要素。所以当系统的反应速度变慢的时候,我们不得不面对一系列的并发问题。客户会经常这么说:

  • 这个网站太慢了!

这些问题,归根到底都(可能)是因为网站的并发没有做好导致的低可用性。此时,用户的流失或客户的失望将会是企业最大的损失。

本文将会讨论如何优化网站的性能。

本文中提到的所有方案都是可以通过技术手段实现的,并且实现速度是较快的。本人并不认为这种“得过且过”的方式是正确的,必要的时候请考虑!!!重构你的系统或提升你的算法和数据库优化技能!!!

本文提出的解决方案可能会需要争取客户的同意才能实施

异步操作

用户的时间是宝贵的,公司员工的时间也是宝贵的。所以对于一些费时的操作,我们可以推荐用户将操作异步化:比如,在导入数据时,让用户先将 excel 文件上传,然后新建一个后台的任务,在后台进行轮询和插入。

这样做不是没有道理的。在生活中,我们可以让同事取一个快递,取回来了之后给我打电话;点击“编译”之后,系统会在完成编译操作之后自动发送通知;CPU有一个“中断”的概念,用于提示外设发生了某个事件;在淘宝上卖完东西之后,过两天会有快递给我打来电话,叫你下楼去取快递——以上所述都是异步操作的例子。我们应该引导用户,容忍“异步操作”的存在

异步操作——实现方法

异步操作有很多实现方式。这里介绍两种——多线程和消息队列。

首先,你需要找到最费时的那几行代码,然后想办法将其提取为方法,然后将其滞后执行。

多线程

我们可以用多线程来处理进程内的异步操作。比如:

场景描述:
我们需要导入某一个 Excel 中的 10000 条数据,每次插入数据的时候都要花费0.5秒

解决方案:
文件上传后,立即新建一个线程来导入数据,当前线程立即返回,告知用户“文件提交成功,正在导入数据”。

消息队列Message Queue, MQ

我们可以使用“发布/订阅(pub/sub)”模式来处理进程间的异步操作。比如:

场景描述
在用户发送一条微博时,需要将这条微博推送到关注者的时间线中

解决方案
用户发送一条微博,用户端收到请求,将微博推送到“发送微博”队列(publish),负责计算时间线的应用(另一个线程)会监听该队列,并处理时间线相关的计算(subscribe)

关于消息队列的应用有很多。

  • 在青矩项目中,使用消息队列来发送系统通知和发送数据更新通知
  • 在互联网公司中,经常使用 Kafka 队列处理日志(GB/s 级别)

优秀的消息队列有:MSMQKafkaRabbitMQRedis也支持简单的pub/sub应用,值得一试。

⚠️☠️对于 Windows版本的 Redis,请使用 MSOpenTech/redis,请不要再使用 rgl/redis

数据一致性

在高并发应用中,我们需要保证系统数据的一致性,包括进程内数据的一致性和进程间数据的一致性。
我们需要使用事务Transaction来保证数据的一致性。

使用双重检查锁(Double Check Lock)保证进程内数据的一致性

首先,需要摘取出系统的边界资源,然后在边界资源更新时,对其应用双重检查锁。

场景描述
现在系统中有一个AccessToken,该 Token 需要2个小时刷新一次。在刷新的过程中,由于并发问题,导致该变量中的值瞬间被刷新了两次,进而导致系统中出现异常。

解决方案
在AccessToken更新时,为了防止有多个线程更新该变量,可考虑在更新时使用“双重检查锁”。

⚠️注意事项
此技巧仅适用于更新操作较少的情况。不恰当的使用可能会导致性能问题。

使用二阶段提交(Two-phase Commit, 2PC)处理进程间数据的一致性

图示二阶段提交 [来源](https://dzone.com/articles/xa-transactions-2-phase-commit)

二阶段提交算法的成立基于以下假设(来自 WikiPedia:2PC):

该分布式系统中,存在一个节点作为协调者(Coordinator),其他节点作为参与者(Cohorts)。且节点之间可以进行网络通信。

所有节点都采用预写式日志,且日志被写入后即被保持在可靠的存储设备上,即使节点损坏不会导致日志数据的消失。

所有节点不会永久性损坏,即使损坏后仍然可以恢复。

二阶段提交的工作流程是这样的(来自 WikiPedia:2PC):

第一阶段(提交请求阶段)
协调者节点向所有参与者节点询问是否可以执行提交操作,并开始等待各参与者节点的响应。
参与者节点执行询问发起为止的所有事务操作,并将Undo信息(undo logs)Redo信息(redo logs)写入日志。

各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个"同意"消息;如果参与者节点的事务操作实际执行失败,则它返回一个"中止"消息。

有时候,第一阶段也被称作投票阶段,即各参与者投票是否要继续接下来的提交操作。


第二阶段(提交执行阶段)

成功
当协调者节点从所有参与者节点获得的相应消息都为"同意"时:

  • 协调者节点向所有参与者节点发出"正式提交"的请求。
  • 参与者节点正式完成操作,并释放在整个事务期间内占用的资源。
  • 参与者节点向协调者节点发送"完成"消息。
  • 协调者节点收到所有参与者节点反馈的"完成"消息后,完成事务。

失败
如果任一参与者节点在第一阶段返回的响应消息为"终止",或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:

  • 协调者节点向所有参与者节点发出"回滚操作"的请求。
  • 参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源。
  • 参与者节点向协调者节点发送"回滚完成"消息。
  • 协调者节点收到所有参与者节点反馈的"回滚完成"消息后,取消事务。

⚠️有时候,第二阶段也被称作完成阶段,因为无论结果怎样,协调者都必须在此阶段结束当前事务。

二阶段提交的局限性(来自 WikiPedia:2PC

  • 二阶段提交算法的最大缺点就在于 它的执行过程中间,节点都处于阻塞状态。即节点之间在等待对方的相应消息时,它将什么也做不了。特别是,当一个节点在已经占有了某项资源的情况下,为了等待其他节点的响应消息而陷入阻塞状态时,当第三个节点尝试访问该节点占有的资源时,这个节点也将连带陷入阻塞状态。
  • 另外,协调者节点指示参与者节点进行提交等操作时,如有参与者节点出现了崩溃等情况而导致协调者始终无法获取所有参与者的响应信息,这时协调者将只能依赖协调者自身的超时机制来生效。但往往超时机制生效时,协调者都会指示参与者进行回滚操作。这样的策略显得比较保守。

除了二阶段提交,我们还可以用其他的提交方法。原理与其类似,但是它们在算法上会或多或少地优化性能。

扩展阅读:
关于分布式事务、两阶段提交协议、三阶提交协议
分布式系统的事务处理

⚠️以上所述的提交方式在各大数据库中(应该)都有实现。然而,在一些复杂的情况中(银行转账,用户退款),我们仍然需要自己实现事务功能,并对该模块进行严格的逻辑测试和压力测试

分离你的业务

系统中所选用的技术栈可能会导致其实现功能的局限性。比如:如果使用了Linux ,实现 Office 文档的预览会很困难,要么格式不对,要么体验不好;如果使用了 RDBMS,就不能把所有的数据放到 DB 中,否则会导致 IO 很慢——这些都是技术选型与限制。我并不是在说哪个技术好哪个技术坏,在公司中,技术选型一定是基于当前人员的配置来的,并且会有妥协。

随着系统功能的完善,上述的“局限性”终归需要解决。我建议,与其将就使用当前的技术栈,用不好的实践实现这些功能,还不如在其他技术栈上使用好的实践实现这些功能。

使用 缓存思想 提升系统 IO 的吞吐量 (MongoDB 为例)

与 RDBMS 相比,MongoDB IO 较快。其根本原因是,MongoDB 使用的存储引擎 会在存储数据时将数据存至内存中,每 60S 或存储到达2GB的时候会自动同步至硬盘中。

仿照 CPU 的架构,我们可以做一个类似缓存的东西(L1,L2,L3 Cache)来缓存新添加的数据,或者仿照 CLR 的GC算法,将“热数据”的一部分放到MongoDB的缓存中。缓存有一个“命中率”的概念。当命中率达到80%左右的时候,那么这个缓存的设计就是正确的。关于缓存命中率,这里有一篇文章

要使用缓存,我们可以这样做

场景描述
现在需要导入10000条招聘信息,一定要实现瞬间导入。

解决方案
用户上传文件 > ** 解析文件中的信息 ** > ** 放到MongoDB中** >** 后台开线程** ,将数据同步到RDBMS中(可以使用队列来增加系统的容错性)。

如果系统中的一些业务可以形成闭环,并且IO要求较高(文件、附件管理相关),和其他业务关联不大,可以直接将此模块独立出来,使用 MongoDB来提升系统性能。

⚠️在C#中,你可以使用LINQ对MongoDB进行查询;你可以使用內建的 Filter 和Update来进行某一字段的 Update,当然,你也可以整个对象全部更新——但笔者认为这是不推荐的。因为 MongoDB C# 驱动给出的 Update 语句已经是强类型的了,整个对象更新会让代码的可读性降低。在OO设计中,每一个需要存储持久化数据的方法不应该需要保存整个实体类的——这样做有些麻烦, 也不需要这样做。

⚠️在Java中,同样有优秀的MongoDB框架。虽然 Java 的驱动本身不带 POJO Mapper,你可以用这个:Spring Data MongoDB

多说一句:笔者扫了一眼这个框架,好像还比较符合Java的设计哲学——面向对象,并且该开源框架的GitHub上最近也有代码正在提交。值得一试

此外,还可以使用Redis来实现缓存机制。方法类似,在此不过多赘述。

使用HTTP负载均衡(Nginx 为例)

CDN和流量分发是构建大型网站应用的常见的技术。我们可以使用Nginx来进行流量分发(C# 和 Java)。如果是Node.js,我们可以使用PM2的机架(Cluster)模式来做负载均衡。

你可以像以下这样设置Nginx的负载均衡:

http {
    upstream myapp1 {
        server srv1.example.com;
        server srv2.example.com;
        server srv3.example.com;
    }

    server {
        listen 80;

        location / {
            proxy_pass http://myapp1;
        }
    }
}

被负载均衡的应用应该符合 12 Factor Apps规范。该规范旨在:

如今,软件通常会作为一种服务来交付,它们被称为网络应用程序,或软件即服务(SaaS)。12-Factor 为构建如下的 SaaS 应用提供了方法论:

  • 使用标准化流程自动配置,从而使新的开发者花费最少的学习成本加入这个项目。
  • 和操作系统之间尽可能的划清界限,在各个系统中提供最大的可移植性
  • 适合部署在现代的云计算平台,从而在服务器和系统管理方面节省资源。
  • 将开发环境和生产环境的差异降至最低,并使用持续交付实施敏捷开发
  • 可以在工具、架构和开发流程不发生明显变化的前提下实现扩展

其中,进程一节提到,

12-Factor 应用的进程必须无状态且 无共享 任何需要持久化的数据都要存储在 后端服务 内,比如数据库。

根据该节的描述,如果我们使用了Session,需要实现服务间共享Session,才能对应用进行负载均衡的操作。

降低方法和程序的副作用(side effects)也是一种分离的方式。如果方法和程序的副作用过多,那么说明此方法耦合度过紧(tough coupled),这时候就需要考虑该设计是否合理了。

结语

以上,所有知识都是我脑袋里能抽出来的知识了。有些讨论可能会有谬误。如果有,请指出,感激不尽!
以上内容每个分类都可以独立出一个专题来讲,这里只是泛泛地谈这些内容。希望能帮助到你

©天职信息
[EOF]

上一篇下一篇

猜你喜欢

热点阅读