面向服务的体系架构
原书《大型分布式网站架构设计与实践》第一章——面向服务的体系架构(SOA)
RPC
如上图,最初的应用是单体架构,一台服务器就可以完成所有工作,如果服务器的性能无法满足需求就升级服务器配置。当应用规模越来越大,单体应用架构中的逻辑越来越复杂,再加上对单台服务器的配置的扩充成本过高,人们将单体应用垂直划分成多个子系统当中。
当规模再度增加,应用中越来越多的垂直模块变得难以管理,模块间相互交互的需求越来越多,模块中核心的功能被抽取出来作为单独的系统向外提供服务,不同业务之间可以重复调用,这就形成了目前的分布式应用架构。
一个模块向外提供服务和使用其它服务都离不开远程过程调用(RPC),RPC就是指通过某种手段调用其它系统中的服务,比如通过TCP,或者HTTP。
对象的序列化
进行远程过程调用一定要传递一些对象,这些对象必须要在服务的使用端(consumer)被序列化,在服务的提供端(provider)被反序列化。
Java中你可以使用很多种序列化机制,大体上分为二进制编码和某种纯文本格式的。二进制编码如Java自带的序列化机制、Hessian,纯文本可以使用XML和JSON。
远程调用示例
下面是一个sayhello
服务接口,和一个简单的实现,这个实现当传入参数是hello
时返回hi
,否则返回byebye
。
interface SayhelloService {
String sayHello(String helloArg);
}
class SayhelloServiceImpl implements SayhelloService {
public String sayHello(String helloArg) {
if (helloArg.equals("hello")) {
return "hi";
} else {
return "byebye";
}
}
}
远程调用的目的就是提供一些手段,在其它系统上调用这个服务。下面构造RPC的客户端,它提供一个rCall
函数发起RPC调用,调用者需要提供服务名,调用的方法名,参数类型和参数,rCall
中使用Hessian
进行二进制编码,使用套接字接口向服务端发送TCP数据。
public class RPCClient {
public void rCall(String serviceName, String serviceMethod, Class<?>[] paramTypes, Object[] args) throws IOException {
System.out.println("Sending rCall " + serviceName + "." + serviceMethod + "...");
Socket socket = new Socket("127.0.0.1", Constants.INSTANCE.getSERVER_PORT());
HessianOutput ho = new HessianOutput(socket.getOutputStream());
ho.writeString(serviceName);
ho.writeString(serviceMethod);
ho.writeObject(paramTypes);
ho.writeObject(args);
Hessian2Input hi = new Hessian2Input(socket.getInputStream());
System.out.println(hi.readObject());
}
}
下面是RPC调用的服务器端,服务器端使用一个Map来预先注册服务列表,并且对于每个调用请求,找到对应的服务方法和参数,进行调用,并写回返回值。
public class RPCServer {
private final Map<String, Object> rpcServicies;
public RPCServer() {
rpcServicies = new HashMap<>();
rpcServicies.put("sayhello", new SayhelloServiceImpl());
}
public void serveForever() throws IOException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
System.out.println("Start serving...");
ServerSocket server = new ServerSocket(Constants.INSTANCE.getSERVER_PORT());
while (true) {
Socket socket = server.accept();
System.out.println("Got rCall from " + socket.getInetAddress().getHostAddress());
HessianInput hi = new HessianInput(socket.getInputStream());
String interfaceName = hi.readString();
String methodName = hi.readString();
Class<?>[] paramTypes = (Class<?>[]) hi.readObject();
Object[] arguments = (Object[]) hi.readObject();
if (rpcServicies.containsKey(interfaceName)) {
Object service = rpcServicies.get(interfaceName);
Object result = service.getClass().getMethod(methodName, paramTypes).invoke(service, arguments);
HessianOutput ho = new HessianOutput(socket.getOutputStream());
ho.writeObject(result);
}
}
}
}
下面是两个客户端和服务器的调用示例
// server
RPCServer server = new RPCServer();
server.serveForever();
// client
RPCClient client = new RPCClient();
client.rCall("sayhello", "sayHello", new Class[] {String.class}, new Object[] {"hello"});
使用基于HTTP的RPC,我们不必纠结底层实现细节,很多已经成熟的HTTP服务器甚至开发框架都可以被直接利用,这意味着我们不必考虑我们服务器基础设施的正确性以及并发管理。我们上面基于TCP实现的RCP服务器没有并发,它一次只能服务一个调用者。
HTTP的劣势就是在同等数据量的交换下,HTTP的性能肯定不如TCP,因为毕竟HTTP是上层协议,它会带有很多用于描述自身的字节。使用gzip压缩可以减小这一劣势。
服务路由和负载均衡
从上面的一些描述中,我们可以看出现在的分布式系统是将各种微型公共业务拆分出来,作为服务向外部提供功能,并且服务间存在相互调用,一个用户发起的请求可能需要多个服务共同完成。这样设计的好处是可以将注意力聚焦在单个模块上,减少错误,方便维护和复用。这种设计就称为SOA(Service-Oriented Architecture)。
上面的例子说明,在SOA系统中有一系列的服务,为了使这些服务的可用性更高,通常一个服务会有多个实例,任何一个实例作为一个单独的系统,都能提供这个服务,当consumer请求服务时,通过负载均衡算法选择一个具体的服务实例。
这样做的好处一是提高性能,相当于有多个东西同时为用户服务,再有就是当一个服务实例出现故障时,其它实例依然可以正常提供服务。
静态编码服务列表
我们可以在服务器启动之初静态的配置或者硬编码一个服务的所有服务实例,并使用硬件负载均衡设备或软件(如Nginx)来实现负载均衡。如下图,这就是一个最简单的静态编码服务列表的SOA系统。
考虑一个问题,此时若业务需求要求我们扩展一个服务或者只是在一个服务中添加一个服务实例,或者某个服务的一个服务实例宕机了该怎么办。在静态编码的情况下只能修改源代码,添加或去掉一些内容,重启服务器。而且,如果某个服务的负载均衡设备发生了故障,所有服务都将失效,即使它们能正常工作。
静态编码的痛点就是我们的业务稍有改动就必须修改代码,重启服务器,此时我们需要一种动态注册以及管理服务信息的方法。
服务配置中心
服务配置中心相当于一个注册处(Regristry),服消提供者向它报告自己的服务信息,服务使用者通过它来获取能够使用的服务。当某个服务的一台服务器宕机或下线,相应的机器需要能够动态地从服务配置中心中移除。
还有个词叫去中心化,它是指服务消费者以订阅的形式了解服务配置中心中的服务信息变更。消费者先缓存服务配置中心中的内容到本地,然后它不再访问服务配置中心,而是读取本地拉取下来的服务配置。当服务配置中心中的服务信息发生变更,消费者就会接收到通知,并重新去中心获取相应的服务列表。
ZooKeeper可以完成服务配置中心的工作,它可以近乎实时的感受到提供服务的服务器的状态,通过zab协议保证集群间的服务配置信息一致。后面介绍。
负载均衡算法
轮询(Round Robin)
每个服务的服务实例列表可以被看作一个环形队列,指针从队列头开始,每个服务调用到来,使用当前指针位置的服务实例,并且将指针下移一个位置。
轮询法需要使用锁机制来控制指针在并发情况下的正确下移,这是一笔开销。
随机(Random)
每次随机使用一个服务实例。
该算法简单,开销小,并且在访问量大的情况下无限接近于轮询法。
源地址哈希
使用哈希算法得到一个服务实例。
加权轮询
和轮询一致,只不过对于服务能力强的服务实例的权重更高。
下面的伪代码是加权轮询构造轮询队列时的一种写法,在该算法下,权重为1的服务器只会在队列中存在一次,权重为4的就会存在4次,所以权重高的比权重低的得到的调用次数更多。
round_queue = []
for server in all_server:
for i in 0 to server.weight:
round_queue.enqueue(server)
加权随机
和随机一致,只不过对于服务能力强的服务器权重更高。
最小连接数
上面的所有算法都只是考虑到将请求平均分配到每个服务器上,没有考虑有些请求相对于另外一些更复杂,需要的响应时间更长。
最小连接数选取当前服务实例中连接数最小的服务器。
动态配置规则
负载均衡策略没有哪个好哪个坏之说,不同的场景适合不同的策略,可以通过Groovy脚本或其它手段来动态配置使用的负载均衡策略。
ZooKeeper实现服务配置中心的示例
安装我就不记了,并且这里原书也没过多的介绍ZooKeeper,只是让大家体验一下使用ZooKeeper实现中心化的服务配置中心。
先要介绍一下,ZooKeeper中的关键是znode
,也就是一个节点,这有点类似于Linux下的文件系统。每个节点中可以保存一些自己的数据,节点间可以有父子关系。使用ZooKeeper可以搭建成这样的服务配置中心,其中每一个圈圈都是一个znode
:
下面来看看ZooKeeper的基本用法
- 创建一个公用的持久节点
ZooKeeper zooKeeper = new ZooKeeper("192.168.50.2", SESSION_TIMEOUT, null);
zooKeeper.create("/root", "root data".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
- 使用stat获取节点状态
Stat stat = zookeeper.exists(path, false);
if (stat == null) {
// 节点不存在
} else {
// 节点已存在
}
- 使用Watcher监听节点的状态改变
public class RootZKWatcher implements Watcher {
private final ZooKeeper zookeeper;
public RootZKWatcher(ZooKeeper zooKeeper) {
this.zookeeper = zooKeeper;
}
public void regWatcher() throws InterruptedException, KeeperException {
zookeeper.addWatch("/root", this, AddWatchMode.PERSISTENT);
}
@Override
public void process(WatchedEvent watchedEvent) {
System.out.println("[+] watcher process : " + watchedEvent.getPath());
switch (watchedEvent.getType()) {
case NodeCreated:
System.out.println("[+] Node created!");
break;
case NodeDeleted:
System.out.println("[+] Node deleted!");
break;
case NodeDataChanged:
System.out.println("[+] Node data changed!");
break;
case NodeChildrenChanged:
System.out.println("[+] Node children changed!");
break;
}
}
}
需要注意的是,ZooKeeper的Watcher注册是一次性的,消费过后需要重新注册。可以使用zkClient
库将这个注册机制转换为订阅发布机制。
当服务间调用过于复杂时,可以使用下图的结构,即每个服务又通过consumer
子节点记录谁调用了它,通过provider
子节点记录它需要哪些服务。
HTTP服务网关
网关过滤恶意或有问题的服务请求。如果需要提高可用性,网关也需要以集群的方式提供服务,并且通过两台互相进行心跳检测的负载均衡服务器来提供负载均衡。