终于支持markdown了, 排版突然变化望见谅
大型网站系统与 Java 中间件实践
分布式系统
为什么要使用分布式系统?
- 升级单机处理能力的性价比越来越低
- 单机处理能力存在瓶颈
- 稳定性与可用性
多种并发执行模式
- 生产者消费者模型是一种典型的基于共享容器的多线程工作方式
- 通过事件协同的模式, 例如触发事件(中断)
- 多进程模式, 涉及到进程间通信, 序列化与反序列化等等, 但单进程不可用, 整体可能部分可用
BIO/NIO/AIO
- BIO 阻塞, 方式是一个 socket 需要一个线程, 建立, 读, 写都会产生阻塞
- NIO 基于事件驱动, 采用反应堆模式, 可以在一个线程中处理多个 socket (较为常用)
- AIO 异步IO, 采用 Proactor 模式, 通知发生在动作之前, Selector 发现事件后调用 Handler 处理.
控制器
- 硬件负载均衡
- LVS (Linux Virtual Server)
- 名称服务 (Name Server)
- 规则服务器
- 主从模式
储存器
- 代理 (例如 KV 服务可以根据 Key 进行 Sharding)
- 名称服务 (动态适应储存服务器的变化)
- 规则服务器 (固定 sharding)
- 主从模式
难点
- 时钟, 面对对时序与延迟严格要求的系统(例如交易系统), 其一致性比较难.
- 故障的独立性, 面对部分故障必须找到应对和解决故障独立性的方法
- 处理单点故障, 给单机做好备份, 降低单点故障的范围
- 事物的挑战, ACID/2PC(两阶段提交)/最终一致/BASE/CAP/Paxos 算法
大型网站的演进
从 Scenario/Service/Scale/Storage 考虑, 就是需求/应用/扩展/存储四个方面进行构建
-
单机系统:
app server -> jdbc -> db
-
数据库物理分离:
app server -> | -> jdbc -> | -> db
接下来我们考虑应用服务器集群化
-
引入负载均衡
2,3 客户端与服务端的 Session 可能存在几种解决方案.
- Session Sticky, 把 session 信息保存在对应的服务器上, 需要负载均衡每一次都发到同一个服务器. 造成的问题有, 某服务器宕机重启, 所有相关的都不可用/会话在应用层, 需要额外解析/负载均衡成了有状态的节点, 资源消耗与容灾有隐患
- Session Replicas, 把 session 信息在每一个服务器上放一份, 造成的问题有, 带宽开销/储存开销
- Session 集中存储, 将 session 服务脱离应用服务, 集中存储作为一个新的服务. 可靠性依赖于 session 服务的机器/集群
- Cookie Based 将 session 信息保存在 cookie 中. 但是可能有长度限制, 带宽消耗, 性能影响等诸多因素.
用滑雪存雪具, 打个比方, session sticky 是将自己的雪具只存在某个滑雪场, 假如该滑雪场不开门? session replicas 是每一个雪场放一份雪具, 假如人人都放? session 集中存储是崇礼县设置一个雪具存放大厅, 去哪儿滑就给你送到哪里. 假如集散中心人太多或者太慢? cookies based 是把雪具随身背着, 太重背得动吗? 占太多运输空间.
-
读写分离
很多业务读多写少, 可以另外设置一个读库. 这引入了数据复制的问题, 例如MySQL5.5 以前是异步复制, 完全镜像复制, 并且存在延迟. MySQL5.5 以后加入了半同步复制, 复制时的提交会被锁定, 直到至少一个 slave 收到事务.
-
缓存
- 数据缓存
- 页面缓存 (ESI 规范, 收集语言片段, 复用到其他的页面)
缓存很关键的指标就是命中率, 数据分布与更新策略也应当关注. 总之应当避免局部的热点, 扩容与缩容要尽量平滑.(Consistent Hash)
-
分布式存储
分布式存储通过集群提供了一个高容量, 高并发访问, 数据冗余容灾的支持. 对存储做分布式实际上是对数据做拆分.
- 垂直拆分, 不同的业务拆到不同的数据库中, 原来跨业务的事务处理方式将会不同. 一种方法是使用分布式事务, 另一种是去掉事务或者不去追求强事务支持.
- 水平拆分, 同一个表拆分成多个表, SQL 路由需要解决, 主键处理也需要解决
- 拆分业务, 将业务微服务化. 业务功能之间的访问不仅仅是单机内部的方法调用了, 还引入远程服务调用, 共享代码实现了中心化, 数据交互与业务逻辑解耦, 服务化的应用易于保持稳定性.
中间件与服务框架
Java 基础知识
- 需要了解前导知识, JVM, GC, JMM, ThreadPool, synchronized, ReentrantLock, volitile, Atomics, wait/notify/notifyAll, CountDownLatch, CyclicBarrier, Semaphore, Exchanger, Future/FutureTask, Concurrent*, Dynamic Proxy, Reflect, NIO. 这些都有了解, 不再总结.
服务框架
- 分布式遇到的第一个问题就是本地调用变成远程调用. 将服务接口抽象出来并实现独立. 在服务提供方一般需要完成 可用服务列表 -> 确定调用服务的实例 -> 建立连接 -> 请求序列化 -> 发送请求 -> 接受结果 -> 解析结果.在服务消费方一般需要完成 接受请求 -> 反序列化 -> 定位服务 -> 调用服务 -> 序列化结果 -> 发送响应
-
服务框架的使用方式, 最经典的方法是利用 Spring Bean 进行配置, (现在使用约定注解模式比较多), 例如:
<bean id="calculator" class="org.vanadies.ServiceFramework.ConsumerBean"> <properties> <!--interfaceName, name, group, etc.--> </properties>
-
运行时框架与容器的关系. Web 应用一般选用JBoss, Tomcat, Jetty 等容器, 集团后台服务也一直托管在 Tomcat 中, 逐渐会被 Pandora 取代.
-
Jar 包冲突, 无论是 Pandora 或者其他容器, 都会利用 ClassLoader 的特性进行隔离. 这是利用了类加载机制的双亲委派模型, 将服务框架自身用的类与应用用到的类控制在 User-Defined ClassLoader 的级别. 再在同一版本的时候, 令服务框架比应用优先启动. 双亲委派模型是指:
"某个特定的类加载器在接到加载类的请求时, 首先将加载任务委托给其父类加载器, 并依次递归. 如果父类加载器可以完成任务, 就成功返回加载结果. 只有父类加载器无法完成加载任务时才自己尝试去加载."
-
远程通信问题
与控制器的逻辑相类似, 可以选用负载均衡/LVS的方式, 但是一般选择服务注册查找中心的方案(HSF属于这种). 服务注册查找中心并不处在调用者与服务提供者之间, 只提供可用的服务提供者的列表.
而且并不是每次调用远程服务前都通过这个服务注册查找中来查找可用嗲之, 而是把地址缓存在调用本地, 当有变化时主动从服务注册查找中心发起通知, 告诉调用者可用的服务提供者列表的变化
-
路由问题
对服务的路由可以基于接口/方法/参数, 实际应用中一般基于接口作为服务粒度
-
基于接口/方法情况下, 在一个集群中会提供多个服务, 每个服务又存在多个方法. 执行速度快慢不同, 服务级别不同的方法之间如果都放在一个服务提供者中将会互相受到影响. 应当考虑隔离方法所需要的系统资源, 例如, 控制同一个集群中不同服务的路由, 并进行请求的隔离. 基于参数用的比较少.
-
多机房问题, 为了支持同城多机房, 可以在服务注册查找中心加入甄别不同机房调用者集群的功能, 允许给调用者提供不同服务这的地址. 或者服务注册查找中心提供相同的服务提供者列表, 在服务框架内部进行地址过滤. 需要考虑多机房的部署能力是否对等, 以及某个机房不可用后, 不可用机房的调用者如何调用远程可用机房的服务.
-
流控处理, 为了保证能有效应对异常以及可运维, 需要流量控制保证系统的稳定性. 可以设置0-1开关进行.
例如这周完成的小任务将服务端自己内部的 ding 设置了 metaQ 开关, 在diamond config 内添加了 metaq-switch 字段. 或者设置一个固定的值, 表示单位时间内可以请求的次数. 一般我们会给予服务端自身的接口/方法做控制, 这是为了服务端不同的接口与负载之间不受影响. 也可以根据来源做控制, 对于同样的接口/方法, 按照不同来源做限制, 一般用在比较基础的服务上.
-
-
序列化与反序列化, 需要考虑的问题包括:
- Java 提供的 Serializable 接口, 其跨语言的问题.
- 序列化涉及到的性能消耗
- 第三是序列化后的长度问题.
除了 Java 自身的接口, 还可以考虑 http(s), xml, json 等. 无论是那一种, 其扩展性, 向后兼容性需要重点考虑. 具体一点, 需要显示地标明版本号, 注明可扩展属性, 注明发起方能支持的能力(为了接收方返回自己可以阅读的内容).
-
网络通信的实现, BIO/NIO 的特性不再赘述. 其中可能有多种异步调用方式.
- Oneway 是指只管发送不管是否接收到, 是不保证可靠送达.
- CallBack 是在请求方发送请求后继续自己的工作, 直到对方有响应再执行回调, 如果超时也要执行回调, 但是会告知超时, Callback 的执行不在原请求线程中, 要么是在 IO 线程中, 要么在定时任务的线程中.
- Future 和 Java 的 Future/FutureTask 一样, 先把 Future 放入队列, 然后把数据放入队列, 等到其他工作完成后, 通过 Future 获取通信结果并直接控制超时.
-
暴露远程服务, 和消费端类似, 可以按照 Spring Bean 的方式配置. 需要 ProviderBean 在本地注册服务和对应服务实例的关系
<bean id="calculator" class="org.vanadies.ServiceFramework.ProviderBean"> <properties> <!--interfaceName, name, group, etc.--> </properties>
-
服务升级, 分为接口不变, 接口增加方法, 接口增加参数这几种情况.
- 接口不变, 采用灰度发布(一部分人先验证新的, 然后再全部发布)即可
- 增加方法
- 增加参数, 一般采用版本号进行解决, 或者在设计时就考虑了可拓展性.
-
服务治理
服务治理分为查看服务与管理服务. 查看服务应当包括:
- 服务信息
- 服务质量(出错率, 响应时间等)
- 服务容量(对请求数的支持)
- 服务依赖关系
- 服务分布(服务在物理上分布的机房)
- 服务统计(调用, 出错, 相应的统计和排名)
- 服务元数据
- 服务监视(关键数据的采集, 规则处理, 预警等系统)
服务的管理应当包括:
- 上下线(针对一个机器的服务, 针对一个服务的所有机器)
- 服务路由(之前提的基于接口/方法/参数的路由集中管理, 信息更改后的验证/对比, 多版本管理和回滚)
- 服务限流降级
- 服务归组(group 信息管理)
- 线程池管理
- 机房规则(主要是最大并发的处理)
- 服务授权
数据访问层
单机到分布式
-
数据库压力过大后, 在不升级硬件情况下, 可以考虑优化应用, 设置缓存等方法, 或者采用分布式系统.关于分布式事务, 需要了解以下的知识(比较冗长不赘述)
- XA 规范与 DTP 模型
- 2PC, 即两段提交
- CAP/BASE, CAP 即一致性/可用性/分区容忍性, 三者只能取其二, BASE 即基本可用/软状态(接受一段时间不同步)/最终一致
- Paxos 协议, 比 2PC轻量级, 具体比较复杂. 以后如果涉及到会展开
-
多机 Sequence, 在 Oracle 中, 提供对 Sequence 的支持, 在 MySQL 里, 提供对 Auto Increament 字段的支持. 但在分库分表后, 自增不重复的 ID 分配成了一个难题, 我们需要考虑
- 唯一性
- 连续性
如果只考虑唯一性, 可以考虑利用 IP/物理地址/时间/计数器等生成唯一 ID , 缺点在于在整个分布式系统的连续性不好.
我们可以把所有的 ID 集中在一个地方进行管理, 对每个 ID 序列独立管理, 每台机器需要 ID 时都从 ID 生成器上获取. 这带来了
- 性能问题(取 ID 会有资源消耗, 如果应用取了一段 ID 后宕机了, 那么这些 ID 不可用)
- 生成器稳定问题(一个无状态的集群, 其可用性要靠整个集群来保证)
- 存储问题
-
跨库 Join, 如果查询的某个用户在同库的不同表存在关系, 可以正常 Join , 如果在不同的库中则会产生跨库 Join 的问题. 解决方案包括:
- 把 Join 操作分成多次的数据库操作
- 对常用数据做一些数据冗余
- 借助搜索引擎等外部系统解决跨库问题.
-
外键约束 < ?作者此处也没多提, 似乎是一个很难解决的问题?>
访问层设计与实现
-
提供访问层的方式
- 专有 API , 没有通用性, 只为了便于实现功能, 或者这种方式对一些通用接口方式有比较大的改动和扩展.
- 通用方式, Java 应用中是 JDBC 方式, 数据层自身可以作为 JDBC, 使用成本低, 迁移方便
- 基于 ORM 或类 ORM 的方式. 这是介于两者之间, 例如 ORM 框架 iBatis, hibernate, SpringJDBC. 可以在自己使用的 ORM 框架上再包装一层, 实现数据层的功能.
使用 JDBC 成本最高, 兼容性扩展性最好, ORM 有一定的通用性.
-
访问流程
- SQL 解析, 需要考虑是否去支持所有的 SQL, 以及支持多少 SQL 的方言. 具体解析可以使用 antlr javacc 等其他工具, 也可以自己手写.
- 规则处理, 考虑固定哈希算法/一致性哈希算法. 固定哈希算法根据某个字段取模, 然后将数据分散到不同数据库表中. 字段可以取 id, 时间(和时间密切相关的场景). 根据固定哈希算法进行扩容时, 会产生大量的迁移(类似于 HashMap 的 resize(), 扩容倍数为 a 的话, 至少有 1/a 需要迁移). 一致性哈希算法将节点对应的哈希值映射在一个环上, 环上会有些节点, 分别代表不同主机(可能一个主机对应多个节点, 这叫虚拟节点). 哈希值顺着环下去的第一个节点即为映射结果. 这样无论是扩容还是删除节点, 只用产生部分的迁移.