分布式系统基础设施
原书《大型分布式网站架构设计与实践》第二章——分布式系统基础设施
分布式缓存
绝大多数数据库存储在磁盘上,磁盘IO的性能远不如内存,当大量请求并发到来,在磁盘前面架设一道缓存系统非常必要。分布式缓存可以解决单体系统内存成本高,处理能力有限的缺点。
Memcache
是一个开源对象缓存系统,基于键值对,可以看作在内存中维护了一张巨大的哈希表。它本身不具备分布式的能力,分布式需要客户端自己完成。
安装和使用示例可以自己看书,这里主要记录下如何实现Memcache系统的分布式架构。
下图是一种办法,比如有N个memcache系统,客户端通过对请求的Key进行余N操作,将该请求映射到任意一个缓存中。
该解决办法的缺点就是扩展或一台缓存服务器宕机时,由于N改变了,也就是哈希算法改变了,所以后面同样的键会被映射到不同的服务器上,导致无法访问,这种现象称为雪崩效应,这时需要使用新的N值重新映射所有键,这会导致系统长期处于不可用状态。
另一种办法是consistent Hash
,它的思想就是将整个Key的值域分割成几部分,比如下图有4个节点,Key是32位无符号整数,那么就要将0~(2^32)-1
分成四份,node1~node2之间的数会被映射到node2上,其它的按顺时针以此类推。
分布式Session
分布式缓存的另一个用处就是分布式Session。
传统的Session都固定在一台服务器上,这成为了我们开发分布式系统的绊脚石,我们需要在每台分布式服务器上都能够识别用户的Session。一种办法是使用cookie,但cookie的安全性和长度限制导致它不能成为一个万全之策。
下图是分布式Session的一种架构图,WebServer不再保存Session,而是统一的使用一个Session集群,用户请求到达负载均衡服务器,负载均衡服务器选中一个WebServer,WebServer去缓存集群中获取和用户的SessionID相关的数据。
memcached-session-manager
是一个Tomcat和Memcache的Session共享方案。可以将该工具下载并放到Tomcat服务器的lib目录下,并配置conf/context.xml
持久化存储
MySQL扩展
传统的关系型数据库的优点就是提供ACID、事务、强大的关联操作、多表连接等特性,不过在高并发场景下,我们必须做一些妥协。比如将不同表分在不同数据库上,这需要放弃关联查询;进行反范式设计,通过冗余数据来提高查询性能,降低对数据一致性的要求(只保证最终一致性)。
业务拆分
业务拆分是将同一个系统中的多张表放置在不同MySQL数据库实例上,单一数据库的设计会让数据库系统承载太多请求。
复制策略
假如拆分后你的访问量还是过大,某个模块(比如user)的数据库系统的压力还是过大,可以通过复制策略,再提供一批数据库服务器,并将当前压力过大的服务器种的数据复制到那些服务器上,保证这些服务器上的数据相同,这样对数据库的请求就可以映射到这其中任意一台服务器上了。
MySQL的复制策略是通过在多台机器上同步binlog
实现的,binlog
中记录了对数据库进行更改的所有操作,可以基于语句(Statement Level)和基于记录(Row Level),基于语句的只记录执行的语句,占用空间小,基于记录的则是记录整个数据行,占用空间大。但是如果你的SQL中有某些依赖环境的操作,比如UUID
,那除了同步binlog
外,可能还需要同步当时SQL执行的上下文信息。
一个常见的复制策略是主从复制,即一台服务器作为主(Master),其它服务器作为(Slave),对于写请求,只由主服务器处理,处理后同步给从服务器,而读请求由剩下的从服务器处理。大部分的业务都是写少读多,所以这样会大大加大系统能承载的并发能力。
该系统中的主服务器是脆弱的,如果它发生问题,整个系统无法处理写请求。Dual-Master
架构通过添加一个主服务器作为Stand by来解决这个问题。
为了防止两个主节点同时更新引发的数据不一致问题,只有一个主节点可以更新,另一台也作为和其它节点一样的读节点(或不开放读)。只不过当当前主节点发生故障时,它被切换成新的主节点。
分表分库
当一张表的数据量过大,比如大型互联网公司可能有十几亿用户,不可能把用户全部存放在一张表中,这样对该表任何的更新可能都会非常低效。因为更新时可能要改变若干次文件结构,可能还要对很多索引文件进行改动,并且极高的并发访问可能让基于复制机制的数据库系统的主节点难以承受,此时,分表分库就是必要的了。
如果你要进行分表操作,那么你就要给MySQL数据库提供一个分表策略,比如通过id除当前分表数得到的余数进行分表。最好使用查询时用到较多、不重复并且没有任何特定分布的字段,这样首先能加速查询,其次能将数据均匀的分配在多张表中。
单纯的分表无法解决主节点压力过大的问题,如需解决需要分库,下图展示了一个分库的策略,和分表并没有什么不同。
分表扩展了单表性能,分库提升了数据库系统的并发性能,分表和分库应该混合使用,这样这两种优点就都齐了。
分库分表比之前的单纯分库或单纯分表复杂些,需要通过一个值先映射到某个库,再映射到某个表。
比如分配256个库,每个库中1024张表,中间变量在0~(256 * 1024 - 1)
之间,这时库就在0~255
之间,而表就在0~1023
之间。
HBase
一种列族型NoSQL数据库,可以和Hadoop和ZooKeeper整合,很方便的支持集群。
列族型数据库通过行上的row-key
和列来确定一个存储单元,每个列属于一个特定列族。
当表记录不断增大后,表会分裂成一个个Region,由(startkey, endkey)
表示,HBase集群中包含HMaster和HRegionServer,HRegionServer负责管理多个Region,HMaster负责HRegionServer的调度和集群管理。
当时看NoSQL的书的时候就对列族数据库不明不白的,先记这些,后面学时候再说。
Redis
也是一个高效的、基于内存的Key-Value数据库,支持丰富的数据类型,它也可以用于和memcache一样作为分布式系统中的缓存系统。
消息系统
消息系统即请求者发送一条消息,该消息保存在消息队列中,然后请求发送者无需同步等待响应,而是可以继续去做其它事,消息接收者会取出队列中的消息。这种设计的好处就是无需同步等待,提高效率和解开请求放和响应方的耦合。而且消息队列很像一个缓冲,当系统处于峰值压力时,请求被缓存在消息队列中,这样一来整个系统的峰值就被削了。
ActiveMQ & JMS
ActiveMQ是Apache实现的基于Java的消息队列系统,JMS是Java提供的消息服务的规范,类似于JDBC规范,ActiveMQ支持这个规范。
JMS支持两种消息发送和接收模型,P2P和发布/订阅模型。
下图是P2P模型,即消息生产者直接将消息发送给队列,队列缓存该消息,然后消息的消费者去队列中取消息并消费掉。
发布/订阅模型基于topic,发布者发布某个消息到某个topic上,订阅该topic的订阅者会接收到这个消息。
P2P和发布订阅的区别是,P2P产生的一个消息只会被最先拿到它的consumer消费,之后就没了,而发布订阅中的一个消息会被所有订阅者收到。
我的猜测是,P2P类似于生产者消费者模式,它是consumer主动消费消息,而发布订阅是topic主动把消息广播给每一个订阅者
持久订阅(durable subscription)是指当一个已经注册到某个topic的订阅者失去与联系时,它没得到的消息仍然得到保留,等到它上线后依然会被接收到。
下面是基于P2P模式的例子,先导包
<dependency>
<groupId>org.apache.activemq</groupId>
<artifactId>activemq-all</artifactId>
<version>5.11.2</version>
</dependency>
生产者
public class ActiveMQProducer {
public static void main(String[] args) throws JMSException {
ConnectionFactory connectionFactory = new ActiveMQConnectionFactory(
ActiveMQConnection.DEFAULT_USER,
ActiveMQConnection.DEFAULT_PASSWORD,
"tcp://192.168.50.2:61616"
);
Connection connection = connectionFactory.createConnection();
connection.start();
Session session = connection.createSession(Boolean.TRUE, Session.AUTO_ACKNOWLEDGE);
Destination destination = session.createQueue("MessageQueue");
MessageProducer producer = session.createProducer(destination);
producer.setDeliveryMode(DeliveryMode.NON_PERSISTENT);
ObjectMessage msg = session.createObjectMessage("Hello, ActiveMQ.");
producer.send(msg);
session.commit();
System.out.println("commited");
}
}
消费者
public class ActiveMQConsumer {
public static void main(String[] args) throws JMSException {
ConnectionFactory connectionFactory = new ActiveMQConnectionFactory(
ActiveMQConnection.DEFAULT_USER,
ActiveMQConnection.DEFAULT_PASSWORD,
"tcp://192.168.50.2:61616"
);
Connection connection = connectionFactory.createConnection();
connection.start();
Session session = connection.createSession(Boolean.TRUE, Session.AUTO_ACKNOWLEDGE);
Destination destination = session.createQueue("MessageQueue");
MessageConsumer consumer = session.createConsumer(destination);
while (true) {
ObjectMessage message = (ObjectMessage) consumer.receive(10000);
if (null != message) {
String messageContent = (String) message.getObject();
System.out.println(messageContent);
} else {
break;
}
}
}
}
发布订阅模式的示例:
发布者:
public class ActiveMQPublisher {
public static void main(String[] args) throws JMSException {
ConnectionFactory connectionFactory = new ActiveMQConnectionFactory(
ActiveMQConnectionFactory.DEFAULT_USER,
ActiveMQConnectionFactory.DEFAULT_PASSWORD,
"tcp://192.168.50.2:61616"
);
Connection connection = connectionFactory.createConnection();
connection.start();
Session session = connection.createSession(Boolean.FALSE, Session.AUTO_ACKNOWLEDGE);
Topic topic = session.createTopic("MyTopic");
MessageProducer producer = session.createProducer(topic);
producer.setDeliveryMode(DeliveryMode.NON_PERSISTENT);
TextMessage message = session.createTextMessage();
message.setText("Hello, Pub/Sub Mode!");
producer.send(message);
System.out.println("sended!");
}
}
订阅者
public class ActiveMQSubscriber {
public static void main(String[] args) throws JMSException {
ConnectionFactory connectionFactory = new ActiveMQConnectionFactory(
ActiveMQConnectionFactory.DEFAULT_USER,
ActiveMQConnectionFactory.DEFAULT_PASSWORD,
"tcp://192.168.50.2:61616"
);
Connection connection = connectionFactory.createConnection();
connection.start();
Session session = connection.createSession(Boolean.FALSE, Session.AUTO_ACKNOWLEDGE);
Topic topic = session.createTopic("MyTopic");
MessageConsumer consumer = session.createConsumer(topic);
consumer.setMessageListener(new MessageListener() {
@Override
public void onMessage(Message message) {
try {
System.out.println(((TextMessage) message).getText());
} catch (JMSException e) {
e.printStackTrace();
}
}
});
}
}
也没啥太大区别,不过是Consumer从主动拉取消息变成了被动接收消息到来的通知。
ActiveMQ集群部署
高可用
使用Master-Slave模式,首先Master提供服务,客户端连接到Master,当Master宕机,Slave自动成为新的Master提供服务,当Master再次上线,Master成为一个Slave。
有基于文件系统的Master-Slave模式,也有基于数据库系统的Master-Slave模式,原理都是Master先获取系统的一个排他锁(比如文件系统或数据库表),其他Slave阻塞,当Master下线,释放排他锁,其他Slave中的一个争夺该排他锁。
客户端也需要配置failover
当失去与Master的连接时,客户端会自动挑选一个slave进行连接
高并发
- 垂直扩展机器的性能、使用nio、调整JVM参数等
- 横向扩展,将不同的业务拆分给不同的broker(即一台activemq服务器)
垂直搜索引擎Lucene
用于解决数据库全文检索和模糊匹配太差和分布式环境下由于分表分库所造成的无法使用关联查询的问题。
如下是Lucene在一个程序中的位置
它从各种地方收集信息,把它们索引起来,然后用户可以查询这些信息。
名词介绍
- 倒排索引:实现全文索引和模糊匹配时使用的索引方式,是通过将文档中的词分开,达到搜索一些关键词就可以检索到相关的文档的效果
- 分词:实现倒排索引需要一种手段把文档中的词给切开,对于英文这很简单,对于中文需要使用一些分词器
- 停止词:有些词没有意义,比如英文中的
a
、the
,中文中的的
、在
,这些词不需要作为匹配时的参考依据,所以不会对它们进行索引 - 排序:搜索结果一般需要通过相关度排序
- 文档(Document):Lucene中的一个概念,你可以将它看作数据库中的某一行,这一行代表了一个实体,如一个用户
- 域(Field):Lucene中的一个概念,你可以将它看作数据库中的某一列,文档中包含域,比如文档是一个用户,域是用户的个人简介
- 词(Term):Lucene中的一个概念,是最基本的搜索单元,包括域的名称和搜索的关键词。用它来查询指定域中包含特定关键词的文档。
- 查询(Query):
- 分词器(Analyzer):
写入和索引过程
Lucene中写入文档的过程,由你构造好的文档被传递进分词器,分词过后通过IndexWriter
将索引写入到指定目录
Lucene中查询文档的过程,由你构造好的查询通过IndexSearcher
进行查询,得到命中的TopDocs,通过TopDocs的scoreDocs方法拿到ScoreDoc,通过ScoreDoc得到对应的文档编号,IndexReader
通过文档编号对指定目录下的索引内容进行读取,并返回。
示例
前置工作
导包
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>8.11.1</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>8.11.1</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>8.11.1</version>
</dependency>
初始化索引目录和分词器
static Directory dir;
static Analyzer analyzer;
@BeforeAll
static void createDirectory() throws IOException {
dir = FSDirectory.open(new File("./index_dir").toPath());
analyzer = new StandardAnalyzer();
}
提供一个通过User构造文档的方法,注意sex
字段参考价值不高,所以它是StoredField
,意思是仅仅写入,不进行索引。
Document createByUser(User user) {
Document document = new Document();
document.add(new TextField("name", user.getName(), Field.Store.YES));
document.add(new StoredField("sex", user.getSex()));
document.add(new TextField("address", user.getAddress(), Field.Store.YES));
document.add(new TextField("introduce", user.getIntroduce(), Field.Store.YES));
return document;
}
测试添加索引
@Test
void testWriteIndex() throws IOException {
IndexWriter indexWriter = new IndexWriter(dir, new IndexWriterConfig(analyzer));
indexWriter.addDocument(
createByUser(new User("zhangsan", "male", "hangzhou", "I am a coder, my name is zhangsan"))
);
indexWriter.close();
}
测试更新索引
Lucene不能更新索引,所以需要以删除再插入的方式进行更新
@Test
void testUpdateIndexManul() throws IOException {
Document newData = createByUser(
new User("zhangsan", "male", "guangzhou", "I am a coder, my name is zhangsan")
);
IndexWriter indexWriter = new IndexWriter(dir, new IndexWriterConfig(analyzer));
indexWriter.deleteDocuments(new Term("name", "zhangsan"));
indexWriter.addDocument(newData);
}
Lucene提供了这一过程的封装:
@Test
void testUpdateIndexAuto() throws IOException {
Document newData = createByUser(
new User("zhangsan", "male", "guangzhou", "I am a coder, my name is zhangsan")
);
IndexWriter indexWriter = new IndexWriter(dir, new IndexWriterConfig(analyzer));
indexWriter.updateDocument(new Term("name", "zhangshan"), newData);
}
条件查询
@Test
void testSearch() throws IOException, ParseException {
// 查询字符串和要检索的域
String queryStr = "zhangsan";
String[] fields = new String[] { "name", "introduce" };
// 分词器和查询编译器,得到一个查询
Analyzer analyzer = new StandardAnalyzer();
QueryParser queryParser = new MultiFieldQueryParser(fields, analyzer);
Query query = queryParser.parse(queryStr);
// 初始化IndexSearcher
IndexSearcher searcher = new IndexSearcher(DirectoryReader.open(dir));
TopDocs topDocs = searcher.search(query, 1000);
System.out.println("hits : " + topDocs.totalHits);
for (ScoreDoc doc : topDocs.scoreDocs) {
System.out.println(searcher.doc(doc.doc));
}
}
索引优化
Lucene的索引由段组成,每个段可能包含多个索引文件。操作系统会限制每个进程打开的文件句柄数量。当索引段数量达到设置的上限时,Lucene会自动进行索引段的合并,提高查询性能,减少打开的文件句柄数。
索引段合并需要大量的IO操作,合并过程中,查询的性能会大打折扣。所以在一般的系统中,查询是单独的服务器,而生成索的又是单独的服务器。
分布式扩展
对于搜索业务,通常能够接受数据在一段时间内不是那么新,并且对数据一致性的要求很低。
通常使用查询服务器和存储索引的服务器分离个架构,系统中只有一个服务器用于存储索引,dumpserver会周期性的生成索引并推送给每一个queryserver,保证数据最终一致性。
当索引太大时也需要对索引进行切分,请求通过mergeserver发送给所有indexserver,并且mergeserver会把搜索到的结果和合并。提供多台dumpserver来分担存储和构建索引的压力。
Solr
对Lucene进行扩展,提供一系列功能强大的HTTP操作接口。