Google Spanner简介

Spanner 是Google的全球级的分布式数据库 (Globally-Distributed Database) 。Spanner的扩展性达到了令人咋舌的全球级,可以扩展到数百万的机器,数已百计的数据中心,上万亿的行。更给力的是,除了夸张的扩展性之外,他还能同时通过同步复制和多版本来满足外部一致性,可用性也是很好的。冲破CAP的枷锁,在三者之间完美平衡。

Spanner是个可扩展,多版本,全球分布式还支持同步复制的数据库。他是Google的第一个可以全球扩展并且支持外部一致的事务。Spanner能做到这些,离不开一个用GPS和原子钟实现的时间API。这个API能将数据中心之间的时间同步精确到10ms以内。因此有几个给力的功能:无锁读事务,原子schema修改,读历史数据无block。

EMC中国研究院实时紧盯业界动态,Google最近发布的一篇论文《Spanner: Google’s Globally-Distributed Database》, 笔者非常感兴趣,对Spanner进行了一些调研,并在这里分享。由于Spanner并不是开源产品,笔者的知识主要来源于Google的公开资料,通过现有公开资料仅仅只能窥得Spanner的沧海一粟,Spanner背后还依赖有大量Google的专有技术。研究院原文

下文主要是Spanner的背景,设计和并发控制。

Spanner背景

要搞清楚Spanner原理,先得了解Spanner在Google的定位。

从上图可以看到。Spanner位于F1和GFS之间,承上启下。所以先提一提F1和GFS。

F1

和众多互联网公司一样,在早期Google大量使用了Mysql。Mysql是单机的,可以用Master-Slave来容错,分区来扩展。但是需要大量的手工运维工作,有很多的限制。因此Google开发了一个可容错可扩展的RDBMS——F1。和一般的分布式数据库不同,F1对应RDMS应有的功能,毫不妥协。起初F1是基于Mysql的,不过会逐渐迁移到Spannerr。

F1有如下特点:

  • 7×24高可用。哪怕某一个数据中心停止运转,仍然可用。
  • 可以同时提供强一致性和弱一致。
  • 可扩展
  • 支持SQL
  • 事务提交延迟50-100ms,读延迟5-10ms,高吞吐

众所周知Google BigTable是重要的Nosql产品,提供很好的扩展性,开源世界有HBase与之对应。为什么Google还需要F1,而不是都使用BigTable呢?因为BigTable提供的最终一致性,一些需要事务级别的应用无法使用。同时BigTable还是NoSql,而大量的应用场景需要有关系模型。就像现在大量的互联网企业都使用Mysql而不愿意使用HBase,因此Google才有这个可扩展数据库的F1。而Spanner就是F1的至关重要的底层存储技术。

Colossus(GFS II)

Colossus也是一个不得不提起的技术。他是第二代GFS,对应开源世界的新HDFS。GFS是著名的分布式文件系统。

初代GFS是为批处理设计的。对于大文件很友好,吞吐量很大,但是延迟较高。所以使用他的系统不得不对GFS做各种优化,才能获得良好的性能。那为什么Google没有考虑到这些问题,设计出更完美的GFS ? 因为那个时候是2001年,Hadoop出生是在2007年。如果Hadoop是世界领先水平的话,GFS比世界领先水平还领先了6年。同样的Spanner出生大概是2009年,现在我们看到了论文,估计Spanner在Google已经很完善,同时Google内部已经有更先进的替代技术在酝酿了。笔者预测,最早在2015年才会出现Spanner和F1的山寨开源产品。

Colossus是第二代GFS。Colossus是Google重要的基础设施,因为他可以满足主流应用对FS的要求。Colossus的重要改进有:

  • 优雅Master容错处理 (不再有2s的停止服务时间)
  • Chunk大小只有1MB (对小文件很友好)
  • Master可以存储更多的Metadata(当Chunk从64MB变为1MB后,Metadata会扩大64倍,但是Google也解决了)

Colossus可以自动分区Metadata。使用Reed-Solomon算法来复制,可以将原先的3份减小到1.5份,提高写的性能,降低延迟。客户端来复制数据。具体细节笔者也猜不出。

与BigTable, Megastore对比

Spanner主要致力于跨数据中心的数据复制上,同时也能提供数据库功能。在Google类似的系统有BigTable和Megastore。和这两者相比,Spanner又有什么优势呢。

BigTable在Google得到了广泛的使用,但是他不能提供较为复杂的Schema,还有在跨数据中心环境下的强一致性。Megastore有类RDBMS的数据模型,同时也支持同步复制,但是他的吞吐量太差,不能适应应用要求。Spanner不再是类似BigTable的版本化 key-value存储,而是一个“临时多版本”的数据库。何为“临时多版本”,数据是存储在一个版本化的关系表里面,存储的时间数据会根据其提交的时间打上时间戳,应用可以访问到较老的版本,另外老的版本也会被垃圾回收掉。

Google官方认为 Spanner是下一代BigTable,也是Megastore的继任者。

Google Spanner设计
功能

从高层看Spanner是通过Paxos状态机将分区好的数据分布在全球的。数据复制全球化的,用户可以指定数据复制的份数和存储的地点。Spanner可以在集群或者数据发生变化的时候将数据迁移到合适的地点,做负载均衡。用户可以指定将数据分布在多个数据中心,不过更多的数据中心将造成更多的延迟。用户需要在可靠性和延迟之间做权衡,一般来说复制1,2个数据中心足以保证可靠性。

作为一个全球化分布式系统,Spanner提供一些有趣的特性。

  • 应用可以细粒度的指定数据分布的位置。精确的指定数据离用户有多远,可以有效的控制读延迟(读延迟取决于最近的拷贝)。指定数据拷贝之间有多远,可以控制写的延迟(写延迟取决于最远的拷贝)。还要数据的复制份数,可以控制数据的可靠性和读性能。(多写几份,可以抵御更大的事故)
  • Spanner还有两个一般分布式数据库不具备的特性:读写的外部一致性,基于时间戳的全局的读一致。这两个特性可以让Spanner支持一致的备份,一致的MapReduce,还有原子的Schema修改。

这写特性都得益有Spanner有一个全球时间同步机制,可以在数据提交的时候给出一个时间戳。因为时间是系列化的,所以才有外部一致性。这个很容易理解,如果有两个提交,一个在T1,一个在T2。那有更晚的时间戳那个提交是正确的。

这个全球时间同步机制是用一个具有GPS和原子钟的TrueTime API提供了。这个TrueTime API能够将不同数据中心的时间偏差缩短在10ms内。这个API可以提供一个精确的时间,同时给出误差范围。Google已经有了一个TrueTime API的实现。笔者觉得这个TrueTime API 非常有意义,如果能单独开源这部分的话,很多数据库如MongoDB都可以从中受益。

体系结构

Spanner由于是全球化的,所以有两个其他分布式数据库没有的概念。

  • Universe。一个Spanner部署实例称之为一个Universe。目前全世界有3个。一个开发,一个测试,一个线上。因为一个Universe就能覆盖全球,不需要多个。
  • Zones. 每个Zone相当于一个数据中心,一个Zone内部物理上必须在一起。而一个数据中心可能有多个Zone。可以在运行时添加移除Zone。一个Zone可以理解为一个BigTable部署实例

如图所示。一个Spanner有上面一些组件。实际的组件肯定不止这些,比如TrueTime API Server。如果仅仅知道这些知识,来构建Spanner是远远不够的。但Google都略去了。那笔者就简要介绍一下。

  • Universemaster: 监控这个universe里zone级别的状态信息
  • Placement driver:提供跨区数据迁移时管理功能
  • Zonemaster:相当于BigTable的Master。管理Spanserver上的数据。
  • Location proxy:存储数据的Location信息。客户端要先访问他才知道数据在那个Spanserver上。
  • Spanserver:相当于BigTable的ThunkServer。用于存储数据。

 

?可以看出来这里每个组件都很有料,但是Google的论文里只具体介绍了Spanserver的设计,笔者也只能介绍到这里。下面详细阐述Spanserver的设计。
Spanserver

本章详细介绍Spanserver的设计实现。Spanserver的设计和BigTable非常的相似。参照下图

从下往上看。每个数据中心会运行一套Colossus (GFS II) 。每个机器有100-1000个tablet。Tablet概念上将相当于数据库一张表里的一些行,物理上是数据文件。打个比方,一张1000行的表,有10个tablet,第1-100行是一个tablet,第101-200是一个tablet。但和BigTable不同的是BigTable里面的tablet存储的是Key-Value都是string,Spanner存储的Key多了一个时间戳:

(Key: string, timestamp: int64) -> string。

因此spanner天生就支持多版本,tablet在文件系统中是一个B-tree-like的文件和一个write-ahead日志。

每个Tablet上会有一个Paxos状态机。Paxos是一个分布式一致性协议。Table的元数据和log都存储在上面。Paxos会选出一个replica做leader,这个leader的寿命默认是10s,10s后重选。Leader就相当于复制数据的master,其他replica的数据都是从他那里复制的。读请求可以走任意的replica,但是写请求只有去leader。这些replica统称为一个paxos group。

每个leader replica的spanserver上会实现一个lock table还管理并发。Lock table记录了两阶段提交需要的锁信息。但是不论是在Spanner还是在BigTable上,但遇到冲突的时候长时间事务会将性能很差。所以有一些操作,如事务读可以走lock table,其他的操作可以绕开lock table。

每个leader replica的spanserver上还有一个transaction manager。如果事务在一个paxos group里面,可以绕过transaction manager。但是一旦事务跨多个paxos group,就需要transaction manager来协调。其中一个Transaction manager被选为leader,其他的是slave听他指挥。这样可以保证事务。

Directories and Placement

之所以Spanner比BigTable有更强的扩展性,在于Spanner还有一层抽象的概念directory, directory是一些key-value的集合,一个directory里面的key有一样的前缀。更妥当的叫法是bucketing。Directory是应用控制数据位置的最小单元,可以通过谨慎的选择Key的前缀来控制。据此笔者可以猜出,在设计初期,Spanner是作为F1的存储系统而设立,甚至还设计有类似directory的层次结构,这样的层次有很多好处,但是实现太复杂被摒弃了。

Directory作为数据放置的最小单元,可以在paxos group里面移来移去。Spanner移动一个directory一般出于如下几个原因:

  • 一个paxos group的负载太大,需要切分
  • 将数据移动到access更近的地方
  • 将经常同时访问的directory放到一个paxos group里面

Directory可以在不影响client的前提下,在后台移动。移动一个50MB的directory大概需要的几秒钟。

那么directory和tablet又是什么关系呢。可以理解为Directory是一个抽象的概念,管理数据的单元;而tablet是物理的东西,数据文件。由于一个Paxos group可能会有多个directory,所以spanner的tablet实现和BigTable的tablet实现有些不同。BigTable的tablet是单个顺序文件。Google有个项目,名为Level DB,是BigTable的底层,可以看到其实现细节。而Spanner的tablet可以理解是一些基于行的分区的容器。这样就可以将一些经常同时访问的directory放在一个tablet里面,而不用太在意顺序关系。

在paxos group之间移动directory是后台任务。这个操作还被用来移动replicas。移动操作设计的时候不是事务的,因为这样会造成大量的读写block。操作的时候是先将实际数据移动到指定位置,然后再用一个原子的操作更新元数据,完成整个移动过程。

Directory还是记录地理位置的最小单元。数据的地理位置是由应用决定的,配置的时候需要指定复制数目和类型,还有地理的位置。比如(上海,复制2份;南京复制1分) 。这样应用就可以根据用户指定终端用户实际情况决定的数据存储位置。比如中国队的数据在亚洲有3份拷贝, 日本队的数据全球都有拷贝。

前面对directory还是被简化过的,还有很多无法详述。

数据模型

Spanner的数据模型来自于Google内部的实践。在设计之初,Spanner就决心有以下的特性:

  • 支持类似关系数据库的schema
  • Query语句
  • 支持广义上的事务

为何会这样决定呢?在Google内部还有一个Megastore,尽管要忍受性能不够的折磨,但是在Google有300多个应用在用它,因为Megastore支持一个类似关系数据库的schema,而且支持同步复制 (BigTable只支持最终一致的复制) 。使用Megastore的应用有大名鼎鼎的Gmail, Picasa, Calendar, Android Market和AppEngine。 而必须对Query语句的支持,来自于广受欢迎的Dremel,笔者不久前写了篇文章来介绍他。 最后对事务的支持是比不可少了,BigTable在Google内部被抱怨的最多的就是其只能支持行事务,再大粒度的事务就无能为力了。Spanner的开发者认为,过度使用事务造成的性能下降的恶果,应该由应用的开发者承担。应用开发者在使用事务的时候,必须考虑到性能问题。而数据库必须提供事务机制,而不是因为性能问题,就干脆不提供事务支持。

数据模型是建立在directory和key-value模型的抽象之上的。一个应用可以在一个universe中建立一个或多个database,在每个database中建立任意的table。Table看起来就像关系型数据库的表。有行,有列,还有版本。Query语句看起来是多了一些扩展的SQL语句。

Spanner的数据模型也不是纯正的关系模型,每一行都必须有一列或多列组件。看起来还是Key-value。主键组成Key,其他的列是Value。但这样的设计对应用也是很有裨益的,应用可以通过主键来定位到某一行。

上图是一个例子。对于一个典型的相册应用,需要存储其用户和相册。可以用上面的两个SQL来创建表。Spanner的表是层次化的,最顶层的表是directory table。其他的表创建的时候,可以用 interleave in parent来什么层次关系。这样的结构,在实现的时候,Spanner可以将嵌套的数据放在一起,这样在分区的时候性能会提升很多。否则Spanner无法获知最重要的表之间的关系。

TrueTime

TrueTime API 是一个非常有创意的东西,可以同步全球的时间。上表就是TrueTime API。TT.now()可以获得一个绝对时间TTinterval,这个值和UnixTime是相同的,同时还能够得到一个误差e。TT.after(t)和TT.before(t)是基于TT.now()实现的。

那这个TrueTime API实现靠的是GFS和原子钟。之所以要用两种技术来处理,是因为导致这两个技术的失败的原因是不同的。GPS会有一个天线,电波干扰会导致其失灵。原子钟很稳定。当GPS失灵的时候,原子钟仍然能保证在相当长的时间内,不会出现偏差。

实际部署的时候。每个数据中心需要部署一些Master机器,其他机器上需要有一个slave进程来从Master同步。有的Master用GPS,有的Master用原子钟。这些Master物理上分布的比较远,怕出现物理上的干扰。比如如果放在一个机架上,机架被人碰倒了,就全宕了。另外原子钟不是并很贵。Master自己还会不断比对,新的时间信息还会和Master自身时钟的比对,会排除掉偏差比较大的,并获得一个保守的结果。最终GPS master提供时间精确度很高,误差接近于0。

 

每个Slave后台进程会每个30秒从若干个Master更新自己的时钟。为了降低误差,使用Marzullo算法。每个slave还会计算出自己的误差。这里的误差包括的通信的延迟,机器的负载。如果不能访问Master,误差就会越走越大,知道重新可以访问。

Google Spanner并发控制

Spanner使用TrueTime来控制并发,实现外部一致性。支持以下几种事务。

  • 读写事务
  • 只读事务
  • 快照读,客户端提供时间戳
  • 快照读,客户端提供时间范围

例如一个读写事务发生在时间t,那么在全世界任何一个地方,指定t快照读都可以读到写入的值。

Operation Concurrency Control Replica Required
Read-Write Transaction pessimistic leader
Read-Only Transaction lock-free leader for timestamp; any for read
Snapshot Read, client-provided timestamp lock-free any
Snapshot Read, client-provided bound lock-free any

上表是Spanner现在支持的事务。单独的写操作都被实现为读写事务 ; 单独的非快照被实现为只读事务。事务总有失败的时候,如果失败,对于这两种操作会自己重试,无需应用自己实现重试循环。

时间戳的设计大大提高了只读事务的性能。事务开始的时候,要声明这个事务里没有写操作,只读事务可不是一个简单的没有写操作的读写事务。它会用一个系统时间戳去读,所以对于同时的其他的写操作是没有Block的。而且只读事务可以在任意一台已经更新过的replica上面读。

对于快照读操作,可以读取以前的数据,需要客户端指定一个时间戳或者一个时间范围。Spanner会找到一个已经充分更新好的replica上读取。

还有一个有趣的特性的是,对于只读事务,如果执行到一半,该replica出现了错误。客户端没有必要在本地缓存刚刚读过的时间,因为是根据时间戳读取的。只要再用刚刚的时间戳读取,就可以获得一样的结果。

读写事务

正如BigTable一样,Spanner的事务是会将所有的写操作先缓存起来,在Commit的时候一次提交。这样的话,就读不出在同一个事务中写的数据了。不过这没有关系,因为Spanner的数据都是有版本的。

在读写事务中使用wound-wait算法来避免死锁。当客户端发起一个读写事务的时候,首先是读操作,他先找到相关数据的leader replica,然后加上读锁,读取最近的数据。在客户端事务存活的时候会不断的向leader发心跳,防止超时。当客户端完成了所有的读操作,并且缓存了所有的写操作,就开始了两阶段提交。客户端闲置一个coordinator group,并给每一个leader发送coordinator的id和缓存的写数据。

leader首先会上一个写锁,他要找一个比现有事务晚的时间戳。通过Paxos记录。每一个相关的都要给coordinator发送他自己准备的那个时间戳。

Coordinator leader一开始也会上个写锁,当大家发送时间戳给他之后,他就选择一个提交时间戳。这个提交的时间戳,必须比刚刚的所有时间戳晚,而且还要比TT.now()+误差时间 还有晚。这个Coordinator将这个信息记录到Paxos。

在让replica写入数据生效之前,coordinator还有再等一会。需要等两倍时间误差。这段时间也刚好让Paxos来同步。因为等待之后,在任意机器上发起的下一个事务的开始时间,都比如不会比这个事务的结束时间早了。然后coordinator将提交时间戳发送给客户端还有其他的replica。他们记录日志,写入生效,释放锁。

只读事务

对于只读事务,Spanner首先要指定一个读事务时间戳。还需要了解在这个读操作中,需要访问的所有的读的Key。Spanner可以自动确定Key的范围。

如果Key的范围在一个Paxos group内。客户端可以发起一个只读请求给group leader。leader选一个时间戳,这个时间戳要比上一个事务的结束时间要大。然后读取相应的数据。这个事务可以满足外部一致性,读出的结果是最后一次写的结果,并且不会有不一致的数据。

如果Key的范围在多个Paxos group内,就相对复杂一些。其中一个比较复杂的例子是,可以遍历所有的group leaders,寻找最近的事务发生的时间,并读取。客户端只要时间戳在TT.now().latest之后就可以满足要求了。

最后的话

本文介绍了Google Spanner的背景,设计和并发控制。希望不久的将来,会有开源产品出现。

文章来源:http://www.yankay.com/google-spanner%E5%8E%9F%E7%90%86-%E5%85%A8%E7%90%83%E7%BA%A7%E7%9A%84%E5%88%86%E5%B8%83%E5%BC%8F%E6%95%B0%E6%8D%AE%E5%BA%93/

多核处理器越来越普及。有没有一种简单的办法,能够让我们写的软件释放多核的威力?是有的。随着Golang, Erlang, Scala等为并发设计的程序语言的兴起,新的并发模式逐渐清晰。正如过程式编程和面向对象一样,一个好的编程模式有一个极其简洁的内核,还有在此之上丰富的外延。可以解决现实世界中各种各样的问题。本文以GO语言为例,解释其中内核、外延。

并发模式之内核

这种并发模式的内核只需要协程通道就够了。协程负责执行代码,通道负责在协程之间传递事件。

不久前,并发编程是个非常困难的事。要想编写一个良好的并发程序,我们不得不了解线程,锁,semaphore,barrier甚至CPU更新高速缓存的方式,而且他们个个都有怪脾气,处处是陷阱。笔者除非万不得以,决不会自己操作这些底层并发元素。一个简洁的并发模式不需要这些复杂的底层元素,协程和通道就够了。

协程是轻量级的线程。在过程式编程中,当调用一个过程的时候,需要等待其执行完才返回。而调用一个协程的时候,不需要等待其执行完,会立即返回。协程十分轻量,Go语言可以在一个进程中执行有数以十万计的协程,依旧保持高性能。而对于普通的平台,一个进程有数千个线程,其CPU会忙于上下文切换,性能急剧下降。随意创建线程可不是一个好主意,但是我们可以大量使用的协程。

通道是协程之间的数据传输通道。通道可以在众多的协程之间传递数据,具体可以值也可以是个引用。通道有两种使用方式。

  • 协程可以试图向通道放入数据,如果通道满了,会挂起协程,直到通道可以为他放入数据为止。
  • 协程可以试图向通道索取数据,如果通道没有数据,会挂起协程,直到通道返回数据为止。

如此,通道就可以在传递数据的同时,控制协程的运行。有点像事件驱动,也有点像阻塞队列。

这两个概念非常的简单,各个语言平台都会有相应的实现。在Java和C上也各有库可以实现两者。

Golang Erlang Scala(Actor)
协程 goroutines process actor
消息队列 channel mailbox channel

只要有协程和通道,就可以优雅的解决并发的问题。不必使用其他和并发有关的概念。那如何用这两把利刃解决各式各样的实际问题呢?

并发模式之外延

协程相较于线程,可以大量创建。打开这扇门,我们拓展出新的用法,可以做生成器,可以让函数返回“服务”,可以让循环并发执行,还能共享变量。但是出现新的用法的同时,也带来了新的棘手问题,协程也会泄漏,不恰当的使用会影响性能。下面会逐一介绍各种用法和问题。演示的代码用GO语言写成,因为其简洁明了,而且支持全部功能。

生成器

有的时候,我们需要有一个函数能不断生成数据。比方说这个函数可以读文件,读网络,生成自增长序列,生成随机数。这些行为的特点就是,函数的已知一些变量,如文件路径。然后不断调用,返回新的数据。

下面生成随机数为例, 以让我们做一个会并发执行的随机数生成器。

非并发的做法是这样的:

1
2
3
4
// 函数 rand_generator_1 ,返回 int
func rand_generator_1() int {
    return rand.Int()
}

上面是一个函数,返回一个int。假如rand.Int()这个函数调用需要很长时间等待,那该函数的调用者也会因此而挂起。所以我们可以创建一个协程,专门执行rand.Int()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 函数 rand_generator_2,返回 通道(Channel)
func rand_generator_2() chan int {
    // 创建通道
    out := make(chan int)
    // 创建协程
    go func() {
        for {
            //向通道内写入数据,如果无人读取会等待
            out <- rand.Int()
        }
    }()
    return out
}
func main() {
    // 生成随机数作为一个服务
    rand_service_handler := rand_generator_2()
    // 从服务中读取随机数并打印
    fmt.Printf(“%dn”, <-rand_service_handler)
}

上面的这段函数就可以并发执行了rand.Int()。有一点值得注意到函数的返回可以理解为一个“服务”。但我们需要获取随机数据 时候,可以随时向这个服务取用,他已经为我们准备好了相应的数据,无需等待,随要随到。如果我们调用这个服务不是很频繁,一个协程足够满足我们的需求了。但如果我们需要大量访问,怎么办?我们可以用下面介绍的多路复用技术,启动若干生成器,再将其整合成一个大的服务。

调用生成器,可以返回一个“服务”。可以用在持续获取数据的场合。用途很广泛,读取数据,生成ID,甚至定时器。这是一种非常简洁的思路,将程序并发化。

多路复用

多路复用是让一次处理多个队列的技术。Apache使用处理每个连接都需要一个进程,所以其并发性能不是很好。而Nighx使用多路复用的技术,让一个进程处理多个连接,所以并发性能比较好。同样,在协程的场合,多路复用也是需要的,但又有所不同。多路复用可以将若干个相似的小服务整合成一个大服务。

那么让我们用多路复用技术做一个更高并发的随机数生成器吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 函数 rand_generator_3 ,返回通道(Channel)
func rand_generator_3() chan int {
    // 创建两个随机数生成器服务
    rand_generator_1 := rand_generator_2()
    rand_generator_2 := rand_generator_2()
    //创建通道
    out := make(chan int)
    //创建协程
    go func() {
        for {
            //读取生成器1中的数据,整合
            out <- <-rand_generator_1
        }
    }()
    go func() {
        for {
            //读取生成器2中的数据,整合
            out <- <-rand_generator_2
        }
    }()
    return out
}

上面是使用了多路复用技术的高并发版的随机数生成器。通过整合两个随机数生成器,这个版本的能力是刚才的两倍。虽然协程可以大量创建,但是众多协程还是会争抢输出的通道。Go语言提供了Select关键字来解决,各家也有各家窍门。加大输出通道的缓冲大小是个通用的解决方法。

多路复用技术可以用来整合多个通道。提升性能和操作的便捷。配合其他的模式使用有很大的威力。

Furture技术

Furture是一个很有用的技术,我们常常使用Furture来操作线程。我们可以在使用线程的时候,可以创建一个线程,返回Furture,之后可以通过它等待结果。 但是在协程环境下的Furtue可以更加彻底,输入参数同样可以是Furture的。

调用一个函数的时候,往往是参数已经准备好了。调用协程的时候也同样如此。但是如果我们将传入的参数设为通道,这样我们就可以在不准备好参数的情况下调用函数。这样的设计可以提供很大的自由度和并发度。函数调用和函数参数准备这两个过程可以完全解耦。下面举一个用该技术访问数据库的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//一个查询结构体
type query struct {
    //参数Channel
    sql chan string
    //结果Channel
    result chan string
}
//执行Query
func execQuery(q query) {
    //启动协程
    go func() {
        //获取输入
        sql := <-q.sql
        //访问数据库,输出结果通道
        q.result <- “get ” + sql
    }()
}
func main() {
    //初始化Query
    q :=
        query{make(chan string, 1), make(chan string, 1)}
    //执行Query,注意执行的时候无需准备参数
    execQuery(q)
    //准备参数
    q.sql <- “select * from table”
    //获取结果
    fmt.Println(<-q.result)
}

上面利用Furture技术,不单让结果在Furture获得,参数也是在Furture获取。准备好参数后,自动执行。Furture和生成器的区别在于,Furture返回一个结果,而生成器可以重复调用。还有一个值得注意的地方,就是将参数Channel和结果Channel定义在一个结构体里面作为参数,而不是返回结果Channel。这样做可以增加聚合度,好处就是可以和多路复用技术结合起来使用。

Furture技术可以和各个其他技术组合起来用。可以通过多路复用技术,监听多个结果Channel,当有结果后,自动返回。也可以和生成器组合使用,生成器不断生产数据,Furture技术逐个处理数据。Furture技术自身还可以首尾相连,形成一个并发的pipe filter。这个pipe filter可以用于读写数据流,操作数据流。

Future是一个非常强大的技术手段。可以在调用的时候不关心数据是否准备好,返回值是否计算好的问题。让程序中的组件在准备好数据的时候自动跑起来。

并发循环

循环往往是性能上的热点。如果性能瓶颈出现在CPU上的话,那么九成可能性热点是在一个循环体内部。所以如果能让循环体并发执行,那么性能就会提高很多。

要并发循环很简单,只有在每个循环体内部启动协程。协程作为循环体可以并发执行。调用启动前设置一个计数器,每一个循环体执行完毕就在计数器上加一个元素,调用完成后通过监听计数器等待循环协程全部完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
//建立计数器
sem := make(chan int, N);
//FOR循环体
for i,xi := range data {
    //建立协程
    go func (i int, xi float) {
        doSomething(i,xi);
        //计数
        sem <- 0;
    } (i, xi);
}
// 等待循环结束
for i := 0; i < N; ++i { <-sem }

上面是一个并发循环例子。通过计数器来等待循环全部完成。如果结合上面提到的Future技术的话,则不必等待。可以等到真正需要的结果的地方,再去检查数据是否完成。

通过并发循环可以提供性能,利用多核,解决CPU热点。正因为协程可以大量创建,才能在循环体中如此使用,如果是使用线程的话,就需要引入线程池之类的东西,防止创建过多线程,而协程则简单的多。

Chain Filter技术

前面提到了Future技术首尾相连,可以形成一个并发的pipe filter。这种方式可以做很多事情,如果每个Filter都由同一个函数组成,还可以有一种简单的办法把他们连起来。

由于每个Filter协程都可以并发运行,这样的结构非常有利于多核环境。下面是一个例子,用这种模式来产生素数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// A concurrent prime sieve
package main
// Send the sequence 2, 3, 4, … to channel ‘ch’.
func Generate(ch chan<- int) {
    for i := 2; ; i++ {
        ch <- i // Send ‘i’ to channel ‘ch’.
    }
}
// Copy the values from channel ‘in’ to channel ‘out’,
// removing those divisible by ‘prime’.
func Filter(in <-chan int, out chan<- int, prime int) {
    for {
        i := <-in // Receive value from ‘in’.
        if i%prime != 0 {
            out <- i // Send ‘i’ to ‘out’.
        }
    }
}
// The prime sieve: Daisy-chain Filter processes.
func main() {
    ch := make(chan int) // Create a new channel.
    go Generate(ch)      // Launch Generate goroutine.
    for i := 0; i < 10; i++ {
        prime := <-ch
        print(prime, “n”)
        ch1 := make(chan int)
        go Filter(ch, ch1, prime)
        ch = ch1
    }
}

上面的程序创建了10个Filter,每个分别过滤一个素数,所以可以输出前10个素数。

Chain-Filter通过简单的代码创建并发的过滤器链。这种办法还有一个好处,就是每个通道只有两个协程会访问,就不会有激烈的竞争,性能会比较好。

共享变量

 

协程之间的通信只能够通过通道。但是我们习惯于共享变量,而且很多时候使用共享变量能让代码更简洁。比如一个Server有两个状态开和关。其他仅仅希望获取或改变其状态,那又该如何做呢。可以将这个变量至于0通道中,并使用一个协程来维护。

下面的例子描述如何用这个方式,实现一个共享变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//共享变量有一个读通道和一个写通道组成
type sharded_var struct {
    reader chan int
    writer chan int
}
//共享变量维护协程
func sharded_var_whachdog(v sharded_var) {
    go func() {
        //初始值
        var value int = 0
        for {
            //监听读写通道,完成服务
            select {
            case value = <-v.writer:
            case v.reader <- value:
            }
        }
    }()
}
func main() {
    //初始化,并开始维护协程
    v := sharded_var{make(chan int), make(chan int)}
    sharded_var_whachdog(v)
    //读取初始值
    fmt.Println(<-v.reader)
    //写入一个值
    v.writer <- 1
    //读取新写入的值
    fmt.Println(<-v.reader)
}

这样,就可以在协程和通道的基础上实现一个协程安全的共享变量了。定义一个写通道,需要更新变量的时候,往里写新的值。再定义一个读通道,需要读的时候,从里面读。通过一个单独的协程来维护这两个通道。保证数据的一致性。

一般来说,协程之间不推荐使用共享变量来交互,但是按照这个办法,在一些场合,使用共享变量也是可取的。很多平台上有较为原生的共享变量支持,到底用那种实现比较好,就见仁见智了。另外利用协程和通道,可以还实现各种常见的并发数据结构,如锁等等,就不一一赘述。

协程泄漏

协程和内存一样,是系统的资源。对于内存,有自动垃圾回收。但是对于协程,没有相应的回收机制。会不会若干年后,协程普及了,协程泄漏和内存泄漏一样成为程序员永远的痛呢?一般而言,协程执行结束后就会销毁。协程也会占用内存,如果发生协程泄漏,影响和内存泄漏一样严重。轻则拖慢程序,重则压垮机器。

C和C++都是没有自动内存回收的程序设计语言,但只要有良好的编程习惯,就能解决规避问题。对于协程是一样的,只要有好习惯就可以了。

只有两种情况会导致协程无法结束。一种情况是协程想从一个通道读数据,但无人往这个通道写入数据,或许这个通道已经被遗忘了。还有一种情况是程想往一个通道写数据,可是由于无人监听这个通道,该协程将永远无法向下执行。下面分别讨论如何避免这两种情况。

对于协程想从一个通道读数据,但无人往这个通道写入数据这种情况。解决的办法很简单,加入超时机制。对于有不确定会不会返回的情况,必须加入超时,避免出现永久等待。另外不一定要使用定时器才能终止协程。也可以对外暴露一个退出提醒通道。任何其他协程都可以通过该通道来提醒这个协程终止。

对于协程想往一个通道写数据,但通道阻塞无法写入这种情况。解决的办法也很简单,就是给通道加缓冲。但前提是这个通道只会接收到固定数目的写入。比方说,已知一个通道最多只会接收N次数据,那么就将这个通道的缓冲设置为N。那么该通道将永远不会堵塞,协程自然也不会泄漏。也可以将其缓冲设置为无限,不过这样就要承担内存泄漏的风险了。等协程执行完毕后,这部分通道内存将会失去引用,会被自动垃圾回收掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func never_leak(ch chan int) {
    //初始化timeout,缓冲为1
    timeout := make(chan bool, 1)
    //启动timeout协程,由于缓存为1,不可能泄露
    go func() {
        time.Sleep(1 * time.Second)
        timeout <- true
    }()
    //监听通道,由于设有超时,不可能泄露
    select {
    case <-ch:
        // a read from ch has occurred
    case <-timeout:
        // the read from ch has timed out
    }
}

上面是个避免泄漏例子。使用超时避免读堵塞,使用缓冲避免写堵塞。

和内存里面的对象一样,对于长期存在的协程,我们不用担心泄漏问题。一是长期存在,二是数量较少。要警惕的只有那些被临时创建的协程,这些协程数量大且生命周期短,往往是在循环中创建的,要应用前面提到的办法,避免泄漏发生。协程也是把双刃剑,如果出问题,不但没能提高程序性能,反而会让程序崩溃。但就像内存一样,同样有泄漏的风险,但越用越溜了。

并发模式之实现

在并发编程大行其道的今天,对协程和通道的支持成为各个平台比不可少的一部分。虽然各家有各家的叫法,但都能满足协程的基本要求—并发执行和可大量创建。笔者对他们的实现方式总结了一下。

下面列举一些已经支持协程的常见的语言和平台。

语言/平台 实现时间 协程名称 备注
GoLang 原生支持 goroutines
Erlang 原生支持 process 函数式语言
Scala 原生支持 actor 函数式编程
Python 2.5版本后 coroutine 官方Python不完全实现
Stackless Python支持
Perl 6.0版本后 coroutine
Ruby 1.9 版本后 fiber
Lua 原生支持 coroutine
C# .net 2.0版本后 fiber

GoLang 和Scala作为最新的语言,一出生就有完善的基于协程并发功能。Erlang最为老资格的并发编程语言,返老还童。其他二线语言则几乎全部在新的版本中加入了协程。

令人惊奇的是C/C++和Java这三个世界上最主流的平台没有在对协程提供语言级别的原生支持。他们都背负着厚重的历史,无法改变,也无需改变。但他们还有其他的办法使用协程。

Java平台有很多方法实现协程:

  • 修改虚拟机:对JVM打补丁来实现协程,这样的实现效果好,但是失去了跨平台的好处
  • 修改字节码:在编译完成后增强字节码,或者使用新的JVM语言。稍稍增加了编译的难度。
  • 使用JNI:在Jar包中使用JNI,这样易于使用,但是不能跨平台。
  • 使用线程模拟协程:使协程重量级,完全依赖JVM的线程实现。

其中修改字节码的方式比较常见。因为这样的实现办法,可以平衡性能和移植性。最具代表性的JVM语言Scala就能很好的支持协程并发。流行的Java Actor模型类库akka也是用修改字节码的方式实现的协程。

对于C语言,协程和线程一样。可以使用各种各样的系统调用来实现。协程作为一个比较高级的概念,实现方式实在太多,就不讨论了。比较主流的实现有libpcl, coro,lthread等等。

对于C++,有Boost实现,还有一些其他开源库。还有一门名为μC++语言,在C++基础上提供了并发扩展。

可见这种编程模型在众多的语言平台中已经得到了广泛的支持,不再小众。如果想使用的话,随时可以加到自己的工具箱中。

结语

本文探讨了一个极其简洁的并发模型。在只有协程和通道这两个基本元件的情况下。可以提供丰富的功能,解决形形色色实际问题。而且这个模型已经被广泛的实现,成为潮流。相信这种并发模型的功能远远不及此,一定也会有更多更简洁的用法出现。或许未来CPU核心数目将和人脑神经元数目一样多,到那个时候,我们又要重新思考并发模型了。

 

文章来源:http://www.yankay.com/go-clear-concurreny/

我们设计关系数据库Schema的都有一套完整的方案,而NoSQL却没有这些。半年前笔者读了本《SQL反模式》的书,觉得非常好。就开始留意,对于NoSQL是否也有反模式?好的反模式可以在我们设计Schema告诉哪里是陷阱和悬崖。NoSQL宣传的时候往往宣称是SchemaLess的,这会让人误解其不需要设计Schema。但如果不意识到设计Schema的必要,陷阱就在一直在黑暗中等着我们。这篇文章就总结一些别人的,也有自己犯过的深痛的设计Schema错误。

NoSQL数据库最主流的有文档数据库,列存数据库,键值数据库。三者分别有代表作MongoDB,HBase和Redis。如果将NoSQL比作兵器的话,可以这样(MySQL是典型的关系型数据库,一样参与比较): 

  • MySQL产生年代较早,而且随着LAMP大潮得以成熟。尽管其没有什么大的改进,但是新兴的互联网使用的最多的数据库。就像传统的菜刀,结构简单,几百年没有改进。但是不妨碍产生各式各样的刀法,只要有一把,就能胜任厨房里的大部分事务。MySQL也是一样,核心已经稳定。但是切库,分表,备份,监控,等等手段一应俱全。
  • MongoDB是个新生事物,提供更灵活的Schema,Capped Collection,异步提交,地理位置索引等五花十色的功能。就像瑞士军刀,不但可以当刀用,还可以开瓶盖,剪指甲。但是他也不比MySQL强,因为还缺乏时间的磨砺。一是系统本身的稳定性,二是开发,运维需要更多经验才能流行。
  • HBase是个仗势欺人的大象兵。依仗着Hadoop的生态环境,可以有很好的扩展性。但是就像象兵一样,使用者需要养一头大象(Hadoop),才能驱使他。
  • Redis是键值存储的代表,功能最简单。提供随机数据存储。就像一根棒子一样,没有多余的构造。但是也正是因此,他的伸缩性特别好。就像悟空手里的金箍棒,大可捅破天,小能成缩成针。

文档数据库的得失

关系模型试图将数据库模型和数据库实现分开,让开发者可以脱离底层很好的操作数据。但笔者以为关系模型在一些应用场景下有弱点,现在已经不得不面对。

  • SQL弱点一:必须支持Join。因为数据不能够有重复。所以使用范式的关系模型会不可避免的大量Join。如果参与Join的是一张比内存小的表还好。但是如果大表Join或者表分布在多台机器上的话,Join就是性能的噩梦。
  • SQL弱点二:计算和存储耦合。关系模型作为统一的数据模型既可以用于数据分析,也可以用于在线业务。但这两者一个强调高吞吐,一个强调低延时,已经演化出完全不同的架构。用同一套模型来抽象显然是不合适的。Hadoop针对的就是计算的部分。MongoDB,Redis等针对在线业务。两者都抛弃了关系模型。

针对这两个梦魇。文档数据库如MongoDB的的主要目的是 提供更丰富的数据结构来抛弃Join来适应在线业务。当然也不是MongoDB完全不能用Join,不能拿来做数据分析,讨论这个只是见仁见智的问题。所以文档数据库并不比关系数据库强大,由于对Join的弱支持,功能会弱许多。设计关系模型的时候,通常只需要考虑好数据直接的关系,定义数据模型。而设计文档数据库模型的时候,还需要考虑应用如何使用。因此设计好一个的文档数据库Schema比设计关系模型更加的困难。除此之外,由于文档数据库事务的支持也是比较弱,一般NoSQL只会提供一个行锁。这也给设计Schema更加增加了难度。对于文档数据库的使用有很多需要注意的地方,本文只关注模型设计的部分。

反模式一:惯性思维/沿用关系模型

关系模型是数据存储的经典模型,使用数据模型范式的好处非常的明显。但是由于文档数据库不支持Join(包括和外键息息相关的外键约束)等特性,习惯性的沿用关系模型有的时候会出现问题。需要利用起文档数据库提供的丰富的数据模型来应对。

值得一提的是文档数据库的设计和关系模型不同,是灵活多样的。对于同一个情形,可以设计出有多种能够工作的模型,没有绝对意义上最好的模型。

下图是关系模型和文档模型的对比。

关系模型 VS 文档模型

这个一个博客的数据模型,有Blog,User等表。左侧是关系模型,右侧是文档模型。这个文档模型并不是完全合理,可以作为“正反两面教材”在下文不断阐述。

问题一:存在描述多对多的关系表
症状:文档数据库中存储在有纯粹的关系表,例如:

id user_id blog_id
0 0 0
1 0 1

这样的表就算在关系模型中也是不妥的,因为这个ID非常的多余,可以用联合主键来解决。但是在文档数据库中,由于必须强制单主键,不得不采取这样的设计。

坏处:

  1. 破坏数据完备性。由于ID是主键,在数据模型上没有约束来保证不出现重复的user_id,blog_id对。一旦数据出现重复,更新删除都是问题。
  2. 索引过多。由于是关系表,必须在user_id和blog_id上面分别建一个索引。影响性能。

解决方案:
使用文档数据库典型的处理多对多的办法。不是建立一张关系表,而是在其中一个文档(如User)中,加入一个List字段。

user_id user_name blog_id[] ……
0 Jake 0,1 ……
1 Rose 1,2 ……

 

问题二:没有区分”一对多关系”和“多对一关系”
症状:关系模型不区分“一对多”和“多对一”,对于文档数据库来讲,关系模型只有“多对一”。就像这张Comment表:

comment_id user_id content ……
0 0 “NoSQL反模式是好文章” ……
1 0 “是啊” ……

如果整个模型都是这样的“多对一”关系就需要反思了。

坏处:

  1. 额外索引。如果客户端已知user_id,需要获得User信息和Comment信息,需要执行两次查询。其中一次查询需要使用索引。并且要在客户端自己Join。这样可能有潜在性能问题。

解决方案:
问题的核心在于是已知user_id查询两张表,还是已知comment_id查询两张表。如果是已知comment_id这样的设计就是合理的,但是如果是已知user_id来查询,把关系放在user表里的设计更合理一些。

user_id user_name comment_id[] ……
0 Jake 0,1 ……
1 Rose 1,2 ……

这样的设计,就可以避免一个索引。同理,对于多对多也是一样的,通过合理的安排字段的位置可以避免索引。

正确使用的场合:

关系型模型是非常成功的数据模型,合理的沿用是非常好的。但是由于文档数据库的特点,需要适当的调整,这样得出的数据模型,尽管性能不是最优,但是有最好的灵活性。并且也有利于和关系数据库转换。

反模式二:处处引用客户端Join

症状:数据库设计中充满了xx_id的字端,在查询的时候需要大量的手动Join操作。就涉及到了这个反模式。正如上面提到的博客的关系模型,如果已知blog_id查询comments,需要至少执行3次查询,并且手动Join。

坏处:

  1. 手动Join,麻烦且易出错。文档数据库不支持Join且没有外键保证。因此需要在客户端Join,这样的操作对于软件开发来讲是比较繁琐的。由于没有外键保证,因此不能保证取得的ID在数据库里面是有数据的。在处理的时候需要不断判断,容易出错。
  2. 多次查询。如果引用过多,查询的时候需要多次查询才能查到足够的数据。本来文档数据库是很快的,但是由于多次查询,给数据库增加了压力,获取全部数据的时间也会增加。
  3. 事务处理繁琐。文档数据库一般不支持一般意义上事务,只支持行锁。如果文档数据库有给多个连接。在插入的时候,事务的处理就是噩梦。在文档数据库中使用事务,需要使用行锁,在进行大量的处理。太过繁琐,感兴趣的读者可以搜一下。

解决方案:
适当使用内联数据结构。由于文档数据库支持更复杂的数据结构可以将引用转换为内联的数据,而不用新建一张表。这样做可以解决上面的一些问题,是一个推荐的方案。就像上面博客的例子一样。将五张表简化成了两张表。那什么时候使用内联呢?一般认为

  • 使用内联可以解决读性能问题,明显减少Query的次数的时候。
  • 可以简化数据模型,化简表之间的关系,而同时不会影响灵活性的时候。
  • 事务可以得到简化为单行事务的时候
正确使用的场合:

范式化的使用场景,文档数据库会被多个应用使用。由于数据库设计无法估计多个应用现在及将来的查询情况,需要极大的灵活性。在这个时候,使用引用比内联靠谱。

反模式三 滥用内联后患无穷

问题一 妨碍到查询的内联
症状:频繁查询一些内联字段,丢弃其他字段。

坏处:

  1. 无ID约束:使用内联字段和引用不同,是没有ID约束的。因此不能通过ID(主键)来管理,如果经常需要单独操作内联对象会非常不便。
  2. 索引泛滥:如果以内联字段为条件进行查询,需要建立索引。有可能造成索引泛滥。
  3. 性能浪费:大部分文档数据库的实现是按行存储的,也就意味着,尽管只查询一个字段,但是DB需要将整行从磁盘中取出。如果字段够小,文档够大,是很不合算的。

解决方案:
如果出现以上的症结,就可以考虑使用引用代替内联了。内联特性主要的用途在于提高性能,如果出现性能不升反降,那就没有意义了。如果对性能有很强烈的要求,可以考虑使用重复数据,同样的数据即在内联字段中也在引用的表里面。这样可以结合内联和引用的性能优势。缺点是数据出现重复,维护会比较麻烦。

问题二 无限膨胀的内联
症状:List,Map类型的内联字段不断膨胀,而且没有限制。就像前面提到的Blog的内联字段Comment。如果对每一篇Blog的Comment数量没有限制的话,Comment会无限膨胀。轻则影响性能,重则插入失败。

Blog_id content Comment[] ……
0 “…” “NoSQL反模式是好文章”, “是啊”,”无限增长中”… ……

坏处:

  1. 插入失败。文档数据库的每条记录都有最大大小,并且也有推荐最佳的大小。一般不会超过4M。就像刚刚提到的例子,如果是篇热门的博文的话,评论的大小很容易就超过4M。届时文档将无法更新,新的评论无法插入。
  2. 性能拖油瓶。由于内联字段膨胀,其大小将远远超过其他部分,影响其他部分的性能表现。并且因此导致该记录大小频繁变化,对档数据库的数据文件内部可能因此产生很多碎片。

解决方案:
设定最大数目或者使用引用。还是Blog和Comment的例子,可以将Comment从Blog中剥离出成一张表。如果考虑到性能,可以在Blog表中新建一个字段如最近的评论。这样既保证了性能,又能够预防膨胀。

Blog_id content last_five_comment[] ……
0 “…” “NoSQL反模式是好文章”, “是啊”,”最多5条”… ……

 

问题三 无法维护的内联
症状:DBA想单独维护内联字段,但无法做到。

坏处:

  1. 权限管理难。数据库的权限管理的最小粒度是表。如果使用内联技术,就意味着内联部分必须和其他字段用同一个权限来管理。没有办法在DB级别隐藏。
  2. 切表难。如果发现一张表的庞大需要切表。这个时候就比较纠结了。如果一刀切,partion Key的选择;索引的失效都会成为问题。如果觉得拆为两张表,就会很好操作的话,就是内联的过度使用了 。
  3. 备份难。关系数据库中每张表可以有不同的备份策略。但是如果内联起来,这样的备份就做不到了。
解决办法:
设计数据库模型的时候需要考量之后的维护操作,尤其是内联的字段需不需要单独的维护。需要和运维商量。如果对内联的字段有单独维护的要求,可以拆分出来作为引用。

问题四 盯死应用的内联
症状:应用可以非常好的运行在数据库上。但是当新的应用接入的时候会很麻烦。因为设计数据模型的时候考虑到了查询。所以当有新应用,新查询接入的时候,就会难于使用原有的模型。

坏处:

  1. 新应用接入难。当新的应用试图使用同一个数据库的时候,接入比较困难。因为查询时不同的,需要调整数据模型才能适应。但是调整模型又会影响原有应用。
  2. 集成难。不同的关系型数据库可以集成在一起,共同使用。但是对于文档数据库,虽然功能上可以互补,但是由于内联数据结构的差异,也比较难于集成。
  3. ETL难。现在大部分的数据分析系统使用的是关系模型,就连Hadoop虽然不用关系模型,但是其上的Hive的常用工具也是按关系模型设计的。

解决方案:

使用范式设计数据库,即用引用代替内联。或者在使用内联的时候,给每个内联对象一个全局唯一的Key,保证其和关系模型直接可以存在映射关系,这样可以提高数据模型的灵活性。如Blog表:

Blog_id content Comment[] ……
0 “…” [{“id”=1,”content”=“NoSQL反模式是好文章”}, {“id”=2,”content”=“是啊”}…] ……
这样的设计既可以利用到内联的好处,又能将其和关系模型映射起来。确定是需要手动维护comment_id,保证其全局唯一性。

 

反模式四:在线计算

症状:有一些运行时间很长的Query,由于有聚合计算,索引也不能解决。随着数据量的增长,逐渐成为性能瓶颈。

坏处:

  1. 影响用户体验。在线业务中,如果一个查询大于4s,用户体验会急剧下降。按主键和按索引的查询都能满足要求。但是聚合操作往往需要扫描全表或者大量的数据,随着数据量的增加,查询时间会变长,用户不可容忍。
  2. 影响数据库性能。长查询的坏处数不清。在线上应用中,如果出现长查询,可能会霸占数据的大部分资源,包括IO,连接,CPU等等。导致其他很好的查询,轻则性能也下降,重者无法使用数据库。长查询可以称之为DB杀手。

解决方案:
首先要权衡,这个聚合操作是不是必要的,必须实时完成。如果没有必要实时完成的话,可以采取离线操作的方案。在夜深人静的时候,跑一个长查询,将结果缓存起来,给第二天使用。如果必须实时完成,则可以新建一个字段,用“incr”这样的操作,在运行的时候,实时聚合结果。而不是查询的时候执行一次长查询。如果逻辑比较复杂,或者觉得大量“incr”操作给数据库系统带来了压力,可以使用Storm之类的实时数据处理框架。总之,要慎用长查询。

反模式五:把内联Map对象的Key当作ID用

症状:文档数据库支持内联Map类型。将其中Map的Key当作数据库的主键来用。

Blog_id content Comment{} ……
0 “…” {“1″=“NoSQL反模式是好文章”, “2”=“是啊”} ……

这个反模式很容易犯,因为在编程语言中Map数据结构就是这么用的。但是对于数据库模型来说,这是不折不扣的反模式。

坏处:

  1. 无法通过数据库做各种(><=)查询。对于关系型数据库来说,虽然数据结构可以很灵活,但查询的时候都是按层次的。比如comment.id,comment.content。也就是说其Map类型中的Key可以理解为属性名的,而不是用作ID。因此一旦这样使用,就脱离的数据库管制,无法使用各种查询功能。
  2. 无法通过索引查询。文档数据可建立索引是需要列名的。比如comment.id。而这样的数据结构没有固定的列名,因此无法建立索引。

解决方案:
使用数组+Map来解决。如:

Blog_id content Comment[] ……
0 “…” [{“id”=1,”content”=“NoSQL反模式是好文章”}, {“id”=2,”content”=“是啊”}…] ……
这样,就可以使用comment.id作为索引,也可以使用数据库的查询功能。简单有效。Map类型中的Key是属性名,Value是属性值。这样的用法是文档数据库数据模型的本意,因此其提供的各种功能才能利用上。否则就无法使用。

 

反模式六:不合理的ID

症状:使用String甚至更复杂数据结构作为的ID,或者全部使用数据库提供的自生成ID。如:

id(该ID系系统自生成) Blog_id content ……
0 0 ……

坏处:

  1. ID混乱。如果使用数据库提供的自生成ID,同时表中还有一个类似有主键含义的Blog_id,这样很不好,容易造成逻辑混乱。由于文档数据库不支持ID的重命名,习惯关系数据库做法的人可能会再建立一个自己的逻辑ID字段。这是没有必要的。
  2. 索引庞大,性能低下。ID是数据库的非常重要的部分。ID的长度将决定索引(包括主键的索引)的大小,直接影响到数据库性能。如果索引比内存小,性能会很好。但一旦索引大小超过内存,出现数据交换,性能会急剧下降。一个Long占8字节,一个20个字符的UTF8 String占用约60个字节。相差10倍之巨,不能不考虑。

解决方案:
尽量使用有一定意义的字段做ID,并且不在其他字段中重复出现。不使用复杂的数据类型做ID,只使用int,long或者系统提供的主键类型做ID。

文档数据库的反模式总结

阐述了这么多的反模式,下面有个一览表,涵盖了上面所有的反模式。这个一览表,是按照文档数据库模型建立的。是个文档数据库模型的例子。

ID 反模式名 问题
0 存在描述多对多的关系表 [{ID:00
症状:文档数据库中存储在有纯粹的关系表
坏处:[破坏数据完备性,索引过多]
解决方案:加入一个List字段
},{
ID:01
症状:关系模型不区分“一对多”和“多对一”
坏处:额外索引
解决方案:合理的安排字段的位置
}]
1 处处引用客户端Join [{
ID:10
症状:查询的时候需要大量的手动Join操作
坏处:[手动Join,多次查询, 事务处理繁琐]
解决方案:适当使用内联数据结构。
}]
2 滥用内联后患无穷 [{
ID:20
症状:频繁查询一些内联字段,丢弃其他字段
坏处:[无ID约束,索引泛滥, 性能浪费]
解决方案:使用引用代替内联了,允许重复数据
},{
ID:21
症状:List,Map类型的内联字段不断膨胀,而且没有限制
坏处:[插入失败, 性能拖油瓶]
解决方案:设定最大数目或者使用引用。
},{
ID:22
症状:DBA想单独维护内联字段,但无法做到
坏处:[权限管理难, 切表难, 备份难]
解决方案:设计数据库模型的时候需要考量之后的维护操作
},{
ID:23
症状:应用可以非常好的运行在数据库上。但是当新的应用接入的时候会很麻烦。内联盯死了应用
坏处:[新应用接入难, 集成难, ETL难]
解决方案:使用范式设计数据库,即用引用代替内联。保证其和关系模型直接可以存在映射关系
}]
3 在线计算 [{
ID:30
症状:有一些运行时间很长的Query, 逐渐成为性能瓶颈。
坏处:[影响用户体验,影响数据库性能]
解决方案:取消不必要的聚合操作. 运行的时候,实时聚合结果.使用第三方实时或非实时工具。如Hadoop,Storm.
}]
4 把内联Map对象的Key当作ID用 [{
ID:40
症状:文档数据库支持内联Map类型。将其中Map的Key当作数据库的主键来用。
坏处:[无法通过数据库做各种(><“”” =)查询,无法通过索引查询]
解决方案:使用数组+Map来解决。
}]
5 不合理的ID [{
ID:50
症状:用String甚至更复杂数据结构作为的ID,或者全部使用数据库提供的自生成ID。
坏处:[ID混乱,索引庞大]
解决方案:尽量使用有一定意义的字段做ID。不使用复杂的数据类型做ID。
}]

本文试图总结了笔者知道的重要的文档数据库的反模式。现在关于NoSQL数据模型设计模式的讨论才刚刚起步,将来也许会逐渐自成体系。对于列数据库和Key-Value的反模式,笔者等到有了足够积累的时候,再和大家分享。

文章来源: http://www.yankay.com/nosql-anti-pattern-document/

php5.2.x php5.3.x php5.4.x php5.5.x php5.6.x 对比详解

截至目前(2014.2), PHP 的最新稳定版本是 PHP5.5, 但有差不多一半的用户仍在使用已经不在维护 [注] 的 PHP5.2, 其余的一半用户在使用 PHP5.3 [注].
因为 PHP 那“集百家之长”的蛋疼语法,加上社区氛围不好,很多人对新版本,新特征并无兴趣。
本文将会介绍自 PHP5.2 起,直至 PHP5.6 中增加的新特征。

  • PHP5.2 以前:autoload, PDO 和 MySQLi, 类型约束
  • PHP5.2:JSON 支持
  • PHP5.3:弃用的功能,匿名函数,新增魔术方法,命名空间,后期静态绑定,Heredoc 和 Nowdoc, const, 三元运算符,Phar
  • PHP5.4:Short Open Tag, 数组简写形式,Traits, 内置 Web 服务器,细节修改
  • PHP5.5:yield, list() 用于 foreach, 细节修改
  • PHP5.6: 常量增强,可变函数参数,命名空间增强

注:已于2011年1月停止支持: http://www.php.net/eol.php
注:http://w3techs.com/technologies/details/pl-php/5/all

PHP5.2以前

(2006前)
顺便介绍一下 PHP5.2 已经出现但值得介绍的特征。

autoload

大家可能都知道 __autoload() 函数,如果定义了该函数,那么当在代码中使用一个未定义的类的时候,该函数就会被调用,你可以在该函数中加载相应的类实现文件,如:

function __autoload($classname)
{
    require_once("{$classname}.php")
}

但该函数已经不被建议使用,原因是一个项目中仅能有一个这样的 __autoload() 函数,因为 PHP 不允许函数重名。但当你使用一些类库的时候,难免会出现多个 autoload 函数的需要,于是 spl_autoload_register() 取而代之:

spl_autoload_register(function($classname)
{
    require_once("{$classname}.php")
});

spl_autoload_register() 会将一个函数注册到 autoload 函数列表中,当出现未定义的类的时候,SPL [注] 会按照注册的倒序逐个调用被注册的 autoload 函数,这意味着你可以使用 spl_autoload_register() 注册多个 autoload 函数.

注:SPL: Standard PHP Library, 标准 PHP 库, 被设计用来解决一些经典问题(如数据结构).

PDO 和 MySQLi

即 PHP Data Object, PHP 数据对象,这是 PHP 的新式数据库访问接口。

按照传统的风格,访问 MySQL 数据库应该是这样子:

// 连接到服务器,选择数据库
$conn = mysql_connect("localhost", "user", "password");
mysql_select_db("database");

// 执行 SQL 查询
$type = $_POST['type'];
$sql = "SELECT * FROM `table` WHERE `type` = {$type}";
$result = mysql_query($sql);

// 打印结果
while($row = mysql_fetch_array($result, MYSQL_ASSOC))
{
    foreach($row as $k => $v)
        print "{$k}: {$v}\n";
}

// 释放结果集,关闭连接
mysql_free_result($result);
mysql_close($conn);

为了能够让代码实现数据库无关,即一段代码同时适用于多种数据库(例如以上代码仅仅适用于MySQL),PHP 官方设计了 PDO.
除此之外,PDO 还提供了更多功能,比如:

  • 面向对象风格的接口
  • SQL预编译(prepare), 占位符语法
  • 更高的执行效率,作为官方推荐,有特别的性能优化
  • 支持大部分SQL数据库,更换数据库无需改动代码

上面的代码用 PDO 实现将会是这样:

// 连接到数据库
$conn = new PDO("mysql:host=localhost;dbname=database", "user", "password");

// 预编译SQL, 绑定参数
$query = $conn->prepare("SELECT * FROM `table` WHERE `type` = :type");
$query->bindParam("type", $_POST['type']);

// 执行查询并打印结果
foreach($query->execute() as $row)
{
    foreach($row as $k => $v)
        print "{$k}: {$v}\n";
}

PDO 是官方推荐的,更为通用的数据库访问方式,如果你没有特殊需求,那么你最好学习和使用 PDO.
但如果你需要使用 MySQL 所特有的高级功能,那么你可能需要尝试一下 MySQLi, 因为 PDO 为了能够同时在多种数据库上使用,不会包含那些 MySQL 独有的功能。

MySQLi 是 MySQL 的增强接口,同时提供面向过程和面向对象接口,也是目前推荐的 MySQL 驱动,旧的C风格 MySQL 接口将会在今后被默认关闭。
MySQLi 的用法和以上两段代码相比,没有太多新概念,在此不再给出示例,可以参见 PHP 官网文档 [注]。

注:http://www.php.net/manual/en/mysqli.quickstart.php

类型约束

通过类型约束可以限制参数的类型,不过这一机制并不完善,目前仅适用于类和 callable(可执行类型) 以及 array(数组), 不适用于 string 和 int.

// 限制第一个参数为 MyClass, 第二个参数为可执行类型,第三个参数为数组
function MyFunction(MyClass $a, callable $b, array $c)
{
    // ...
}

PHP5.2

(2006-2011)

JSON 支持

包括 json_encode(), json_decode() 等函数,JSON 算是在 Web 领域非常常用的数据交换格式,可以被 JS 直接支持,JSON 实际上是 JS 语法的一部分。
JSON 系列函数,可以将 PHP 中的数组结构与 JSON 字符串进行转换:

$array = ["key" => "value", "array" => [1, 2, 3, 4]];
$json = json_encode($array);
echo "{$json}\n";

$object = json_decode($json);
print_r($object);

输出:

{"key":"value","array":[1,2,3,4]}
stdClass Object
(
    [key] => value
    [array] => Array
        (
            [0] => 1
            [1] => 2
            [2] => 3
            [3] => 4
        )
)

值得注意的是 json_decode() 默认会返回一个对象而非数组,如果需要返回数组需要将第二个参数设置为 true.

PHP5.3

(2009-2012)

PHP5.3 算是一个非常大的更新,新增了大量新特征,同时也做了一些不向下兼容的修改。

弃用的功能

以下几个功能被弃用,若在配置文件中启用,则 PHP 会在运行时发出警告。

Register Globals

这是 php.ini 中的一个选项(register_globals), 开启后会将所有表单变量($_GET和$_POST)注册为全局变量.
看下面的例子:

if(isAuth())
    $authorized = true;
if($authorized)
    include("page.php");

这段代码在通过验证时,将 $authorized 设置为 true. 然后根据 $authorized 的值来决定是否显示页面.

但由于并没有事先把 $authorized 初始化为 false, 当 register_globals 打开时,可能访问 /auth.php?authorized=1 来定义该变量值,绕过身份验证。

该特征属于历史遗留问题,在 PHP4.2 中被默认关闭,在 PHP5.4 中被移除。

Magic Quotes

对应 php.ini 中的选项 magic_quotes_gpc, 这个特征同样属于历史遗留问题,已经在 PHP5.4 中移除。

该特征会将所有用户输入进行转义,这看上去不错,在第一章我们提到过要对用户输入进行转义。
但是 PHP 并不知道哪些输入会进入 SQL , 哪些输入会进入 Shell, 哪些输入会被显示为 HTML, 所以很多时候这种转义会引起混乱。

Safe Mode

很多虚拟主机提供商使用 Safe Mode 来隔离多个用户,但 Safe Mode 存在诸多问题,例如某些扩展并不按照 Safe Mode 来进行权限控制。
PHP官方推荐使用操作系统的机制来进行权限隔离,让Web服务器以不同的用户权限来运行PHP解释器,请参见第一章中的最小权限原则.

匿名函数

也叫闭包(Closures), 经常被用来临时性地创建一个无名函数,用于回调函数等用途。

$func = function($arg)
{
    print $arg;
};

$func("Hello World");

以上代码定义了一个匿名函数,并赋值给了 $func.
可以看到定义匿名函数依旧使用 function 关键字,只不过省略了函数名,直接是参数列表。

然后我们又调用了 $func 所储存的匿名函数。

匿名函数还可以用 use 关键字来捕捉外部变量:

function arrayPlus($array, $num)
{
    array_walk($array, function(&$v) use($num){
        $v += $num;
    });
}

上面的代码定义了一个 arrayPlus() 函数(这不是匿名函数), 它会将一个数组($array)中的每一项,加上一个指定的数字($num).

在 arrayPlus() 的实现中,我们使用了 array_walk() 函数,它会为一个数组的每一项执行一个回调函数,即我们定义的匿名函数。
在匿名函数的参数列表后,我们用 use 关键字将匿名函数外的 $num 捕捉到了函数内,以便知道到底应该加上多少。

魔术方法:__invoke(), __callStatic()

PHP 的面向对象体系中,提供了若干“魔术方法”,用于实现类似其他语言中的“重载”,如在访问不存在的属性、方法时触发某个魔术方法。

随着匿名函数的加入,PHP 引入了一个新的魔术方法 __invoke().
该魔术方法会在将一个对象作为函数调用时被调用:

class A
{
    public function __invoke($str)
    {
        print "A::__invoke(): {$str}";
    }
}

$a = new A;
$a("Hello World");

输出毫无疑问是:

A::__invoke(): Hello World

__callStatic() 则会在调用一个不存在的静态方法时被调用。

命名空间

PHP的命名空间有着前无古人后无来者的无比蛋疼的语法:

<?php
// 命名空间的分隔符是反斜杠,该声明语句必须在文件第一行。
// 命名空间中可以包含任意代码,但只有 **类, 函数, 常量** 受命名空间影响。
namespace XXOO\Test;

// 该类的完整限定名是 \XXOO\Test\A , 其中第一个反斜杠表示全局命名空间。
class A{}

// 你还可以在已经文件中定义第二个命名空间,接下来的代码将都位于 \Other\Test2 .
namespace Other\Test2;

// 实例化来自其他命名空间的对象:
$a = new \XXOO\Test\A;
class B{}

// 你还可以用花括号定义第三个命名空间
namespace Other {
    // 实例化来自子命名空间的对象:
    $b = new Test2\B;

    // 导入来自其他命名空间的名称,并重命名,
    // 注意只能导入类,不能用于函数和常量。
    use \XXOO\Test\A as ClassA
}

更多有关命名空间的语法介绍请参见官网 [注].

命名空间时常和 autoload 一同使用,用于自动加载类实现文件:

spl_autoload_register(
    function ($class) {
        spl_autoload(str_replace("\\", "/", $class));
    }
);

当你实例化一个类 \XXOO\Test\A 的时候,这个类的完整限定名会被传递给 autoload 函数,autoload 函数将类名中的命名空间分隔符(反斜杠)替换为斜杠,并包含对应文件。
这样可以实现类定义文件分级储存,按需自动加载。

注:http://www.php.net/manual/zh/language.namespaces.php

后期静态绑定

PHP 的 OPP 机制,具有继承和类似虚函数的功能,例如如下的代码:

class A
{
    public function callFuncXXOO()
    {
        print $this->funcXXOO();
    }

    public function funcXXOO()
    {
        return "A::funcXXOO()";
    }
}

class B extends A
{
    public function funcXXOO()
    {
        return "B::funcXXOO";
    }
}

$b = new B;
$b->callFuncXXOO();

输出是:

B::funcXXOO

可以看到,当在 A 中使用 $this->funcXXOO() 时,体现了“虚函数”的机制,实际调用的是 B::funcXXOO().
然而如果将所有函数都改为静态函数:

class A
{
    static public function callFuncXXOO()
    {
        print self::funcXXOO();
    }

    static public function funcXXOO()
    {
        return "A::funcXXOO()";
    }
}

class B extends A
{
    static public function funcXXOO()
    {
        return "B::funcXXOO";
    }
}

$b = new B;
$b->callFuncXXOO();

情况就没这么乐观了,输出是:

A::funcXXOO()

这是因为 self 的语义本来就是“当前类”,所以 PHP5.3 给 static 关键字赋予了一个新功能:后期静态绑定:

class A
{
    static public function callFuncXXOO()
    {
        print static::funcXXOO();
    }

    // ...
}

// ...

这样就会像预期一样输出了:

B::funcXXOO

Heredoc 和 Nowdoc

PHP5.3 对 Heredoc 以及 Nowdoc 进行了一些改进,它们都用于在 PHP 代码中嵌入大段字符串。

Heredoc 的行为类似于一个双引号字符串:

$name = "MyName";
echo <<< TEXT
My name is "{$name}".
TEXT;

Heredoc 以三个左尖括号开始,后面跟一个标识符(TEXT), 直到一个同样的顶格的标识符(不能缩进)结束。
就像双引号字符串一样,其中可以嵌入变量。

Heredoc 还可以用于函数参数,以及类成员初始化:

var_dump(<<<EOD
Hello World
EOD
);

class A
{
    const xx = <<< EOD
Hello World
EOD;

    public $oo = <<< EOD
Hello World
EOD;
}

Nowdoc 的行为像一个单引号字符串,不能在其中嵌入变量,和 Heredoc 唯一的区别就是,三个左尖括号后的标识符要以单引号括起来:

$name = "MyName";
echo <<< 'TEXT'
My name is "{$name}".
TEXT;

输出:

My name is "{$name}".

用 const 定义常量

PHP5.3 起同时支持在全局命名空间和类中使用 const 定义常量。

旧式风格:

define("XOOO", "Value");

新式风格:

const XXOO = "Value";

const 形式仅适用于常量,不适用于运行时才能求值的表达式:

// 正确
const XXOO = 1234;
// 错误
const XXOO = 2 * 617;

三元运算符简写形式

旧式风格:

echo $a ? $a : "No Value";

可简写成:

echo $a ?: "No Value";

即如果省略三元运算符的第二个部分,会默认用第一个部分代替。

Phar

Phar即PHP Archive, 起初只是Pear中的一个库而已,后来在PHP5.3被重新编写成C扩展并内置到 PHP 中。
Phar用来将多个 .php 脚本打包(也可以打包其他文件)成一个 .phar 的压缩文件(通常是ZIP格式)。
目的在于模仿 Java 的 .jar, 不对,目的是为了让发布PHP应用程序更加方便。同时还提供了数字签名验证等功能。

.phar 文件可以像 .php 文件一样,被PHP引擎解释执行,同时你还可以写出这样的代码来包含(require) .phar 中的代码:

require("xxoo.phar");
require("phar://xxoo.phar/xo/ox.php");

更多信息请参见官网 [注].

注:http://www.php.net/manual/zh/phar.using.intro.php

PHP5.4

(2012-2013)

Short Open Tag

Short Open Tag 自 PHP5.4 起总是可用。
在这里集中讲一下有关 PHP 起止标签的问题。即:

<?php
// Code...
?>

通常就是上面的形式,除此之外还有一种简写形式:

<? /* Code... */ ?>

还可以把

<?php echo $xxoo;?>

简写成:

<?= $xxoo;?>

这种简写形式被称为 Short Open Tag, 在 PHP5.3 起被默认开启,在 PHP5.4 起总是可用。
使用这种简写形式在 HTML 中嵌入 PHP 变量将会非常方便。

对于纯 PHP 文件(如类实现文件), PHP 官方建议顶格写起始标记,同时 省略 结束标记。
这样可以确保整个 PHP 文件都是 PHP 代码,没有任何输出,否则当你包含该文件后,设置 Header 和 Cookie 时会遇到一些麻烦 [注].

注:Header 和 Cookie 必须在输出任何内容之前被发送。

数组简写形式

这是非常方便的一项特征!

// 原来的数组写法
$arr = array("key" => "value", "key2" => "value2");
// 简写形式
$arr = ["key" => "value", "key2" => "value2"];

Traits

所谓Traits就是“构件”,是用来替代继承的一种机制。PHP中无法进行多重继承,但一个类可以包含多个Traits.

// Traits不能被单独实例化,只能被类所包含
trait SayWorld
{
    public function sayHello()
    {
        echo 'World!';
    }
}

class MyHelloWorld
{
    // 将SayWorld中的成员包含进来
    use SayWorld;
}

$xxoo = new MyHelloWorld();
// sayHello() 函数是来自 SayWorld 构件的
$xxoo->sayHello();

Traits还有很多神奇的功能,比如包含多个Traits, 解决冲突,修改访问权限,为函数设置别名等等。
Traits中也同样可以包含Traits. 篇幅有限不能逐个举例,详情参见官网 [注].

注:http://www.php.net/manual/zh/language.oop5.traits.php

内置 Web 服务器

PHP从5.4开始内置一个轻量级的Web服务器,不支持并发,定位是用于开发和调试环境。

在开发环境使用它的确非常方便。

php -S localhost:8000

这样就在当前目录建立起了一个Web服务器,你可以通过 http://localhost:8000/ 来访问。
其中localhost是监听的ip,8000是监听的端口,可以自行修改。

很多应用中,都会进行URL重写,所以PHP提供了一个设置路由脚本的功能:

php -S localhost:8000 index.php

这样一来,所有的请求都会由index.php来处理。

你还可以使用 XDebug 来进行断点调试。

细节修改

PHP5.4 新增了动态访问静态方法的方式:

$func = "funcXXOO";
A::{$func}();

新增在实例化时访问类成员的特征:

(new MyClass)->xxoo();

新增支持对函数返回数组的成员访问解析(这种写法在之前版本是会报错的):

print func()[0];

PHP5.5

(2013起)

yield

yield关键字用于当函数需要返回一个迭代器的时候, 逐个返回值。

function number10()
{
    for($i = 1; $i <= 10; $i += 1)
        yield $i;
}

该函数的返回值是一个数组:

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

list() 用于 foreach

可以用 list() 在 foreach 中解析嵌套的数组:

$array = [
    [1, 2, 3],
    [4, 5, 6],
];

foreach ($array as list($a, $b, $c))
    echo "{$a} {$b} {$c}\n";

结果:

1 2 3
4 5 6

细节修改

不推荐使用 mysql 函数,推荐使用 PDO 或 MySQLi, 参见前文。
不再支持Windows XP.

可用 MyClass::class 取到一个类的完整限定名(包括命名空间)。

empty() 支持表达式作为参数。

try-catch 结构新增 finally 块。

PHP5.6

更好的常量

定义常量时允许使用之前定义的常量进行计算:

const A = 2;
const B = A + 1;

class C
{
    const STR = "hello";
    const STR2 = self::STR + ", world";
}

允许常量作为函数参数默认值:

function func($arg = C::STR2)

更好的可变函数参数

用于代替 func_get_args()

function add(...$args)
{
    $result = 0;
    foreach($args as $arg)
        $result += $arg;
    return $result;
}

同时可以在调用函数时,把数组展开为函数参数:

$arr = [2, 3];
add(1, ...$arr);
// 结果为 6

命名空间

命名空间支持常量和函数:

namespace Name\Space {
    const FOO = 42;
    function f() { echo __FUNCTION__."\n"; }
}

namespace {
    use const Name\Space\FOO;
    use function Name\Space\f;

    echo FOO."\n";
    f();
}

Docker Getting Start: Related Knowledge

Docker 介绍: 相关技术

13 December 2013

Abstract

本文在现有文档的基础上总结了以下几点内容

  1. docker的介绍,包括由来、适用场景等
  2. docker背后的一系列技术 – namespace, cgroup, lxc, aufs等
  3. docker在利用LXC的同时提供了哪些创新
  4. 笔者对docker这种container, PaaS的一些理解
  5. docker存在的问题和现有的解决思路

Docker 简介

Docker is an open-source engine that automates the deployment of any application as a lightweight, portable, self-sufficient container that will run virtually anywhere.

Docker 是 PaaS 提供商 dotCloud 开源的一个基于 LXC 的高级容器引擎, 源代码托管在 Github 上, 基于go语言并遵从Apache2.0协议开源。 Docker近期非常火热,无论是从 github 上的代码活跃度,还是Redhat在RHEL6.5中集成对Docker的支持, 就连 Google 家的 Compute Engine 也支持 docker 在其之上运行, 最近百度也用 Docker 作为其PaaS的基础(不知道规模多大)。

一款开源软件能否在商业上成功,很大程度上依赖三件事 – 成功的 user case, 活跃的社区和一个好故事。 dotCloud 自家的 PaaS 产品建立在docker之上,长期维护 且有大量的用户,社区也十分活跃,接下来我们看看docker的故事。

  • 环境管理复杂 – 从各种OS到各种中间件到各种app, 一款产品能够成功作为开发者需要关心的东西太多,且难于管理,这个问题几乎在所有现代IT相关行业都需要面对
  • 云计算时代的到来 – AWS的成功, 引导开发者将应用转移到 cloud 上, 解决了硬件管理的问题,然而中间件相关的问题依然存在 (所以openstack HEAT和 AWS cloudformation 都着力解决这个问题)。开发者思路变化提供了可能性。
  • 虚拟化手段的变化 – cloud 时代采用标配硬件来降低成本,采用虚拟化手段来满足用户按需使用的需求以及保证可用性和隔离性。然而无论是KVM还是Xen在 docker 看来, 都在浪费资源,因为用户需要的是高效运行环境而非OS, GuestOS既浪费资源又难于管理, 更加轻量级的LXC更加灵活和快速
  • LXC的移动性 – LXC在 linux 2.6 的 kernel 里就已经存在了,但是其设计之初并非为云计算考虑的,缺少标准化的描述手段和容器的可迁移性,决定其构建出的环境难于 迁移和标准化管理(相对于KVM之类image和snapshot的概念)。docker 就在这个问题上做出实质性的革新。这正式笔者第一次听说docker时觉得最独特的地方。

container vs VM

面对上述几个问题,docker设想是交付运行环境如同海运,OS如同一个货轮,每一个在OS基础上的软件都如同一个集装箱,用户可以通过标准化手段自由组装运行环境, 同时集装箱的内容可以由用户自定义,也可以由专业人员制造。这样,交付一个软件,就是一系列标准化组件的集合的交付,如同乐高积木,用户只需要选择合适的积木组合, 并且在最顶端署上自己的名字(最后个标准化组件是用户的app)。这也就是基于docker的PaaS产品的原型。

What Docker Can Do

在docker的网站上提到了docker的典型场景:

  • Automating the packaging and deployment of applications
  • Creation of lightweight, private PAAS environments
  • Automated testing and continuous integration/deployment
  • Deploying and scaling web apps, databases and backend services

由于其基于LXC的轻量级虚拟化的特点,docker相比KVM之类最明显的特点就是启动快,资源占用小。因此对于构建隔离的标准化的运行环境,轻量级的PaaS(如dokku), 构建自动化测试和持续集成环境,以及一切可以横向扩展的应用(尤其是需要快速启停来应对峰谷的web应用)。

  1. 构建标准化的运行环境,现有的方案大多是在一个base OS上运行一套puppet/chef,或者一个image文件,其缺点是前者需要base OS许多前提条件,后者几乎不可以修改(因为copy on write 的文件格式在运行时rootfs是read only的)。并且后者文件体积大,环境管理和版本控制本身也是一个问题。
  2. PaaS环境是不言而喻的,其设计之初和dotcloud的案例都是将其作为PaaS产品的环境基础
  3. 因为其标准化构建方法(buildfile)和良好的REST API,自动测试和持续集成/部署能够很好的集成进来
  4. 因为LXC轻量级的特点,其启动快,而且docker能够只加载每个container变化的部分,这样资源占用小,能够在单机环境下与KVM之类的虚拟化方案相比能够更加快速和占用更少资源

What Docker Can NOT Do

Docker并不是全能的,设计之初也不是KVM之类虚拟化手段的替代品,个人简单总结了几点

  1. Docker是基于Linux 64bit的,无法在windows/unix或32bit的linux环境下使用(虽然64-bit现在很普及了)
  2. LXC是基于cgroup等linux kernel功能的,因此container的guest系统只能是linux base的
  3. 隔离性相比KVM之类的虚拟化方案还是有些欠缺,所有container公用一部分的运行库
  4. 网络管理相对简单,主要是基于namespace隔离
  5. cgroup的cpu和cpuset提供的cpu功能相比KVM的等虚拟化方案相比难以度量(所以dotcloud主要是安内存收费)
  6. docker对disk的管理比较有限
  7. container随着用户进程的停止而销毁,container中的log等用户数据不便收集

针对1-2,有windows base应用的需求的基本可以pass了; 3-5主要是看用户的需求,到底是需要一个container还是一个VM, 同时也决定了docker作为 IaaS 不太可行。 针对6,7虽然是docker本身不支持的功能,但是可以通过其他手段解决(disk quota, mount --bind)。总之,选用container还是vm, 就是在隔离性和资源复用性上做tradeoff

另外即便docker 0.7能够支持非AUFS的文件系统,但是由于其功能还不稳定,商业应用或许会存在问题,而AUFS的稳定版需要kernel 3.8, 所以如果想复制dotcloud的 成功案例,可能需要考虑升级kernel或者换用ubuntu的server版本(后者提供deb更新)。我想这也是为什么开源界更倾向于支持ubuntu的原因(kernel版本)

Docker Usage

由于篇幅所限,这里就不再展开翻译,可参见链接 – http://docs.docker.io/en/latest/use/

Docker Build File

由于篇幅所限,这里就不再展开翻译,可参见链接 – http://docs.docker.io/en/latest/use/builder/


Docker’s Trick

What Docker Needs

Docker核心解决的问题是利用LXC来实现类似VM的功能,从而利用更加节省的硬件资源提供给用户更多的计算资源。同VM的方式不同, LXC 其并不是一套硬件虚拟化方法 – 无法归属到全虚拟化、部分虚拟化和半虚拟化中的任意一个,而是一个操作系统级虚拟化方法, 理解起来可能并不像VM那样直观。所以我们从虚拟化要docker要解决的问题出发,看看他是怎么满足用户虚拟化需求的。

用户需要考虑虚拟化方法,尤其是硬件虚拟化方法,需要借助其解决的主要是以下4个问题:

  • 隔离性 – 每个用户实例之间相互隔离, 互不影响。 硬件虚拟化方法给出的方法是VM, LXC给出的方法是container,更细一点是kernel namespace
  • 可配额/可度量 – 每个用户实例可以按需提供其计算资源,所使用的资源可以被计量。硬件虚拟化方法因为虚拟了CPU, memory可以方便实现, LXC则主要是利用cgroups来控制资源
  • 移动性 – 用户的实例可以很方便地复制、移动和重建。硬件虚拟化方法提供snapshot和image来实现,docker(主要)利用AUFS实现
  • 安全性 – 这个话题比较大,这里强调是host主机的角度尽量保护container。硬件虚拟化的方法因为虚拟化的水平比较高,用户进程都是在KVM等虚拟机容器中翻译运行的, 然而对于LXC, 用户的进程是lxc-start进程的子进程, 只是在Kernel的namespace中隔离的, 因此需要一些kernel的patch来保证用户的运行环境不会受到来自host主机的恶意入侵, dotcloud(主要是)利用kernel grsec patch解决的.

Linux Namespace (ns)

LXC所实现的隔离性主要是来自kernel的namespace, 其中pidnetipcmntuts 等namespace将container的进程, 网络, 消息, 文件系统和hostname 隔离开。

pid namespace

之前提到用户的进程是lxc-start进程的子进程, 不同用户的进程就是通过pidnamespace隔离开的,且不同 namespace 中可以有相同PID。具有以下特征:

  1. 每个namespace中的pid是有自己的pid=1的进程(类似/sbin/init进程)
  2. 每个namespace中的进程只能影响自己的同一个namespace或子namespace中的进程
  3. 因为/proc包含正在运行的进程,因此在container中的pseudo-filesystem的/proc目录只能看到自己namespace中的进程
  4. 因为namespace允许嵌套,父namespace可以影响子namespace的进程,所以子namespace的进程可以在父namespace中看到,但是具有不同的pid

正是因为以上的特征,所有的LXC进程在docker中的父进程为docker进程,每个lxc进程具有不同的namespace。同时由于允许嵌套,因此可以很方便的实现 LXC in LXC

net namespace

有了 pid namespace, 每个namespace中的pid能够相互隔离,但是网络端口还是共享host的端口。网络隔离是通过netnamespace实现的, 每个net namespace有独立的 network devices, IP addresses, IP routing tables,/proc/net 目录。这样每个container的网络就能隔离开来。 LXC在此基础上有5种网络类型,docker默认采用veth的方式将container中的虚拟网卡同host上的一个docker bridge连接在一起。

ipc namespace

container中进程交互还是采用linux常见的进程间交互方法(interprocess communication – IPC), 包括常见的信号量、消息队列和共享内存。然而同VM不同,container 的进程间交互实际上还是host上具有相同pid namespace中的进程间交互,因此需要在IPC资源申请时加入namespace信息 – 每个IPC资源有一个唯一的 32bit ID。

mnt namespace

类似chroot,将一个进程放到一个特定的目录执行。mnt namespace允许不同namespace的进程看到的文件结构不同,这样每个 namespace 中的进程所看到的文件目录就被隔离开了。同chroot不同,每个namespace中的container在/proc/mounts的信息只包含所在namespace的mount point。

uts namespace

UTS(“UNIX Time-sharing System”) namespace允许每个container拥有独立的hostname和domain name, 使其在网络上可以被视作一个独立的节点而非Host上的一个进程。

user namespace

每个container可以有不同的 user 和 group id, 也就是说可以以container内部的用户在container内部执行程序而非Host上的用户。

有了以上6种namespace从进程、网络、IPC、文件系统、UTS和用户角度的隔离,一个container就可以对外展现出一个独立计算机的能力,并且不同container从OS层面实现了隔离。 然而不同namespace之间资源还是相互竞争的,仍然需要类似ulimit来管理每个container所能使用的资源 – LXC 采用的是cgroup

参考文献

[1]http://blog.dotcloud.com/under-the-hood-linux-kernels-on-dotcloud-part

[2]http://lwn.net/Articles/531114/

Control Groups (cgroups)

cgroups 实现了对资源的配额和度量。 cgroups 的使用非常简单,提供类似文件的接口,在 /cgroup目录下新建一个文件夹即可新建一个group,在此文件夹中新建task 文件,并将pid写入该文件,即可实现对该进程的资源控制。具体的资源配置选项可以在该文件夹中新建子 subsystem ,{子系统前缀}.{资源项} 是典型的配置方法, 如memory.usage_in_bytes 就定义了该group 在subsystem memory中的一个内存限制选项。 另外,cgroups中的 subsystem可以随意组合,一个subsystem可以在不同的group中,也可以一个group包含多个subsystem – 也就是说一个 subsystem

关于术语定义

A *cgroup* associates a set of tasks with a set of parameters for one
or more subsystems.

A *subsystem* is a module that makes use of the task grouping
facilities provided by cgroups to treat groups of tasks in
particular ways. A subsystem is typically a "resource controller" that
schedules a resource or applies per-cgroup limits, but it may be
anything that wants to act on a group of processes, e.g. a
virtualization subsystem.

我们主要关心cgroups可以限制哪些资源,即有哪些subsystem是我们关心。

cpu : 在cgroup中,并不能像硬件虚拟化方案一样能够定义CPU能力,但是能够定义CPU轮转的优先级,因此具有较高CPU优先级的进程会更可能得到CPU运算。 通过将参数写入cpu.shares,即可定义改cgroup的CPU优先级 – 这里是一个相对权重,而非绝对值。当然在cpu这个subsystem中还有其他可配置项,手册中有详细说明。

cpusets : cpusets 定义了有几个CPU可以被这个group使用,或者哪几个CPU可以供这个group使用。在某些场景下,单CPU绑定可以防止多核间缓存切换,从而提高效率

memory : 内存相关的限制

blkio : block IO相关的统计和限制,byte/operation统计和限制(IOPS等),读写速度限制等,但是这里主要统计的都是同步IO

net_cls, cpuacct , devices , freezer 等其他可管理项。

参考文献

http://blog.dotcloud.com/kernel-secrets-from-the-paas-garage-part-24-c

http://en.wikipedia.org/wiki/Cgroups

https://www.kernel.org/doc/Documentation/cgroups/cgroups.txt

LinuX Containers(LXC)

借助于namespace的隔离机制和cgroup限额功能,LXC提供了一套统一的API和工具来建立和管理container, LXC利用了如下 kernel 的features:

  • Kernel namespaces (ipc, uts, mount, pid, network and user)
  • Apparmor and SELinux profiles
  • Seccomp policies
  • Chroots (using pivot_root)
  • Kernel capabilities
  • Control groups (cgroups)

LXC 向用户屏蔽了以上 kernel 接口的细节, 提供了如下的组件大大简化了用户的开发和使用工作:

  • The liblxc library
  • Several language bindings (python3, lua and Go)
  • A set of standard tools to control the containers
  • Container templates

LXC 旨在提供一个共享kernel的 OS 级虚拟化方法,在执行时不用重复加载Kernel, 且container的kernel与host共享,因此可以大大加快container的 启动过程,并显著减少内存消耗。在实际测试中,基于LXC的虚拟化方法的IO和CPU性能几乎接近 baremetal 的性能(论据参见文献[3]), 大多数数据有相比 Xen具有优势。当然对于KVM这种也是通过Kernel进行隔离的方式, 性能优势或许不是那么明显, 主要还是内存消耗和启动时间上的差异。在参考文献[4]中提到了利用iozone进行 Disk IO吞吐量测试KVM反而比LXC要快,而且笔者在device mapping driver下重现同样case的实验中也确实能得到如此结论。参考文献[5]从网络虚拟化中虚拟路由的场景(个人理解是网络IO和CPU角度)比较了KVM和LXC, 得到结论是KVM在性能和隔离性的平衡上比LXC更优秀 – KVM在吞吐量上略差于LXC, 但CPU的隔离可管理项比LXC更明确。

关于CPU, DiskIO, network IO 和 memory 在KVM和LXC中的比较还是需要更多的实验才能得出可信服的结论。

参考文献

[1]http://linuxcontainers.org/

[2]http://en.wikipedia.org/wiki/LXC

[3]http://marceloneves.org/papers/pdp2013-containers.pdf (性能测试)

[4]http://www.spinics.net/lists/linux-containers/msg25750.html (与KVM IO比较)

[5]http://article.sciencepublishinggroup.com/pdf/10.11648.j.ajnc.20130204.11.pdf

AUFS

Docker对container的使用基本是建立唉LXC基础之上的,然而LXC存在的问题是难以移动 – 难以通过标准化的模板制作、重建、复制和移动 container。 在以VM为基础的虚拟化手段中,有image和snapshot可以用于VM的复制、重建以及移动的功能。想要通过container来实现快速的大规模部署和更新, 这些功能不可或缺。 Docker正是利用AUFS来实现对container的快速更新 – 在docker0.7中引入了storage driver, 支持AUFS, VFS, device mapper, 也为BTRFS以及ZFS引入提供了可能。 但除了AUFS都未经过dotcloud的线上使用,因此我们还是从AUFS的角度介绍。

AUFS (AnotherUnionFS) 是一种 Union FS, 简单来说就是支持将不同目录挂载到同一个虚拟文件系统下(unite several directories into a single virtual filesystem)的文件系统, 更进一步地, AUFS支持为每一个成员目录(AKA branch)设定’readonly’, ‘readwrite’ 和 ‘whiteout-able’ 权限, 同时AUFS里有一个类似 分层的概念, 对 readonly 权限的branch可以逻辑上进行修改(增量地, 不影响readonly部分的)。通常 Union FS有两个用途, 一方面可以实现不借助 LVM, RAID 将多个disk和挂在到一个目录下, 另一个更常用的就是将一个readonly的branch和一个writeable的branch联合在一起,Live CD正是基于此可以允许在 OS image 不变的基础上允许用户在其上进行一些写操作。Docker在AUFS上构建的container image也正是如此,接下来我们从启动container中的linux为例介绍docker在AUFS特性的运用。

典型的Linux启动到运行需要两个FS – bootfs + rootfs (从功能角度而非文件系统角度)

docker-filesystems-generic

bootfs (boot file system) 主要包含 bootloader 和 kernel, bootloader主要是引导加载kernel, 当boot成功后 kernel 被加载到内存中后 bootfs就被umount了. rootfs (root file system) 包含的就是典型 Linux 系统中的 /dev/proc,/bin/etc 等标准目录和文件。

由此可见对于不同的linux发行版, bootfs基本是一致的, rootfs会有差别, 因此不同的发行版可以公用bootfs 如下图:

docker-filesystems-multiroot

典型的Linux在启动后,首先将 rootfs 置为 readonly, 进行一系列检查, 然后将其切换为 “readwrite” 供用户使用。在docker中,起初也是将 rootfs 以readonly方式加载并检查,然而接下来利用 union mount 的将一个 readwrite 文件系统挂载在 readonly 的rootfs之上,并且允许再次将下层的 file system设定为readonly 并且向上叠加, 这样一组readonly和一个writeable的结构构成一个container的运行目录, 每一个被称作一个Layer。如下图:

docker-filesystems-multilayer

得益于AUFS的特性, 每一个对readonly层文件/目录的修改都只会存在于上层的writeable层中。这样由于不存在竞争, 多个container可以共享readonly的layer。 所以docker将readonly的层称作 “image” – 对于container而言整个rootfs都是read-write的,但事实上所有的修改都写入最上层的writeable层中, image不保存用户状态,可以用于模板、重建和复制。

docker-filesystems-debiandocker-filesystems-debianrw

上层的image依赖下层的image,因此docker中把下层的image称作父image,没有父image的image称作base image

docker-filesystems-multilayer

因此想要从一个image启动一个container,docker会先加载其父image直到base image,用户的进程运行在writeable的layer中。所有parent image中的数据信息以及 ID、网络和lxc管理的资源限制等具体container的配置,构成一个docker概念上的container。如下图:

docker-filesystems-busyboxrw

由此可见,采用AUFS作为docker的container的文件系统,能够提供如下好处:

  1. 节省存储空间 – 多个container可以共享base image存储
  2. 快速部署 – 如果要部署多个container,base image可以避免多次拷贝
  3. 内存更省 – 因为多个container共享base image, 以及OS的disk缓存机制,多个container中的进程命中缓存内容的几率大大增加
  4. 升级更方便 – 相比于 copy-on-write 类型的FS,base-image也是可以挂载为可writeable的,可以通过更新base image而一次性更新其之上的container
  5. 允许在不更改base-image的同时修改其目录中的文件 – 所有写操作都发生在最上层的writeable层中,这样可以大大增加base image能共享的文件内容。

以上5条 1-3 条可以通过 copy-on-write 的FS实现, 4可以利用其他的union mount方式实现, 5只有AUFS实现的很好。这也是为什么Docker一开始就建立在AUFS之上。

由于AUFS并不会进入linux主干 (According to Christoph Hellwig, linux rejects all union-type filesystems but UnionMount.), 同时要求kernel版本3.0以上(docker推荐3.8及以上),因此在RedHat工程师的帮助下在docker0.7版本中实现了driver机制, AUFS只是其中的一个driver, 在RHEL中采用的则是Device Mapper的方式实现的container文件系统,相关内容在下文会介绍。

参考文献

[1]https://groups.google.com/forum/#!topic/docker-dev/KcCT0bACksY

[2]http://blog.docker.io/2013/11/docker-0-7-docker-now-runs-on-any-linux-distribution/

[3]http://blog.dotcloud.com/kernel-secrets-from-the-paas-garage-part-34-a

[4]http://aufs.sourceforge.net/aufs.html

[5]http://aufs.sourceforge.net/

[6]http://en.wikipedia.org/wiki/Aufs

[7]http://docs.docker.io/en/latest/terms/filesystem/

[8]http://docs.docker.io/en/latest/terms/layer/

[9]http://docs.docker.io/en/latest/terms/image/

[10]http://docs.docker.io/en/latest/terms/container/

GRSEC

grsec是linux kernel安全相关的patch, 用于保护host防止非法入侵。由于其并不是docker的一部分,我们只进行简单的介绍。 grsec可以主要从4个方面保护进程不被非法入侵:

  • 随机地址空间 – 进程的堆区地址是随机的
  • 用只读的memory management unit来管理进程流程, 堆区和栈区内存只包含数据结构/函数/返回地址和数据, 是non-executeable
  • 审计和Log可疑活动
  • 编译期的防护

安全永远是相对的,这些方法只是告诉我们可以从这些角度考虑container类型的安全问题可以关注的方面。

参考文献

[1] http://blog.dotcloud.com/kernel-secrets-from-the-paas-garage-part-44-g

[2] http://grsecurity.net/


What docker do more than LXC

看似docker主要的OS级虚拟化操作是借助LXC, AUFS只是锦上添花。那么肯定会有人好奇docker到底比LXC多了些什么。无意中发现 stackoverflow 上正好有人问这个问题, 回答者是Dotcloud的创始人,出于备忘目的原文摘录如下。

http://stackoverflow.com/questions/17989306/what-does-docker-add-to-just-plain-lxc

On top of this low-level foundation of kernel features, Docker offers a high-level tool with several powerful functionalities:

  • Portable deployment across machines. Docker defines a format for bundling an application and all its dependencies into a single object which can be transferred to any docker-enabled machine, and executed there with the guarantee that the execution environment exposed to the application will be the same. Lxc implements process sandboxing, which is an important pre-requisite for portable deployment, but that alone is not enough for portable deployment. If you sent me a copy of your application installed in a custom lxc configuration, it would almost certainly not run on my machine the way it does on yours, because it is tied to your machine’s specific configuration: networking, storage, logging, distro, etc. Docker defines an abstraction for these machine-specific settings, so that the exact same docker container can run – unchanged – on many different machines, with many different configurations.
  • Application-centric. Docker is optimized for the deployment of applications, as opposed to machines. This is reflected in its API, user interface, design philosophy and documentation. By contrast, the lxc helper scripts focus on containers as lightweight machines – basically servers that boot faster and need less ram. We think there’s more to containers than just that.
  • Automatic build. Docker includes a tool for developers to automatically assemble a container from their source code, with full control over application dependencies, build tools, packaging etc. They are free to use make, maven, chef, puppet, salt, debian packages, rpms, source tarballs, or any combination of the above, regardless of the configuration of the machines.
  • Versioning. Docker includes git-like capabilities for tracking successive versions of a container, inspecting the diff between versions, committing new versions, rolling back etc. The history also includes how a container was assembled and by whom, so you get full traceability from the production server all the way back to the upstream developer. Docker also implements incremental uploads and downloads, similar to “git pull”, so new versions of a container can be transferred by only sending diffs.
  • Component re-use. Any container can be used as an “base image” to create more specialized components. This can be done manually or as part of an automated build. For example you can prepare the ideal python environment, and use it as a base for 10 different applications. Your ideal postgresql setup can be re-used for all your future projects. And so on.
  • Sharing. Docker has access to a public registry (http://index.docker.io) where thousands of people have uploaded useful containers: anything from redis, couchdb, postgres to irc bouncers to rails app servers to hadoop to base images for various distros. The registry also includes an official “standard library” of useful containers maintained by the docker team. The registry itself is open-source, so anyone can deploy their own registry to store and transfer private containers, for internal server deployments for example.
  • Tool ecosystem. Docker defines an API for automating and customizing the creation and deployment of containers. There are a huge number of tools integrating with docker to extend its capabilities. PaaS-like deployment (Dokku, Deis, Flynn), multi-node orchestration (maestro, salt, mesos, openstack nova), management dashboards (docker-ui, openstack horizon, shipyard), configuration management (chef, puppet), continuous integration (jenkins, strider, travis), etc. Docker is rapidly establishing itself as the standard for container-based tooling.

What we can do with Docker

有了docker这么个强有力的工具,更多的玩家希望了解围绕docker能做什么

Sandbox

作为sandbox大概是container的最基本想法了 – 轻量级的隔离机制, 快速重建和销毁, 占用资源少。用docker在开发者的单机环境下模拟分布式软件部署和调试,可谓又快又好。 同时docker提供的版本控制和image机制以及远程image管理,可以构建类似git的分布式开发环境。可以看到用于构建多平台image的packer以及同一作者的vagrant已经在这方面有所尝试了,笔者会后续的blog中介绍这两款来自同一geek的精致小巧的工具。

PaaS

dotcloud、heroku以及cloudfoundry都试图通过container来隔离提供给用户的runtime和service,只不过dotcloud采用docker, heroku采用LXC, cloudfoundry采用 自己开发的基于cgroup的warden。基于轻量级的隔离机制提供给用户PaaS服务是比较常见的做法 – PaaS 提供给用户的并不是OS而是runtime+service, 因此OS级别的隔离机制 向用户屏蔽的细节已经足够。而docker的很多分析文章提到『能够运行任何应用的“PaaS”云』只是从image的角度说明docker可以从通过构建image实现用户app的打包以及标准服务service image的复用, 而非常见的buildpack的方式。

由于对Cloud Foundry和docker的了解, 接下来谈谈笔者对PaaS的认识。PaaS号称的platform一直以来都被当做一组多语言的runtime和一组常用的middleware,提供这两样东西 即可被认为是一个满足需求的PaaS。然而PaaS对能部署在其上的应用要求很高:

  • 运行环境要简单 – buildpack虽然用于解决类似问题,但仍然不是很理想
  • 要尽可能的使用service – 常用的mysql, apache倒能理解,但是类似log之类的如果也要用service就让用户接入PaaS平台, 让用户难以维护
  • 要尽可能的使用”平台” – 单机环境构建出目标PaaS上运行的实际环境比较困难,开发测试工作都离不开”平台”
  • 缺少可定制性 – 可选的中间件有限,难于调优和debug。

综上所述部署在PaaS上的应用几乎不具有从老平台迁移到之上的可能,新应用也难以进入参数调优这种深入的工作。个人理解还是适合快速原型的展现,和短期应用的尝试。

然而docker确实从另一个角度(类似IaaS+orchestration tools)实现了用户运行环境的控制和管理,然而又基于轻量级的LXC机制,确实是一个了不起的尝试。 笔者也认为IaaS + 灵活的orchestration tools(深入到app层面的管理 如bosh)是交付用户环境最好的方式。


Open Solution

前文也提到docker存在disk/network不便限额和在较低版本kernel中(如RHEL的2.6.32)AUFS不支持的问题。本节尝试给出解答。

disk/network quota

虽然cgroup提供IOPS之类的限制机制,但是从限制用户能使用的磁盘大小和网络带宽上还是非常有限的。

Disk/network的quota现在有两种思路:

  • 通过docker run -v命令将外部存储mount到container的目录下,quota从Host方向限制,在device mapper driver中更采用实际的device因此更好控制。 参考[1]
  • 通过使用disk quota来限制AUFS的可操作文件大小。类似cloud foundry warden的方法, 维护一个UID池,每次创建container都从中取一个user name, 在container里和Host上用这个username创建用户,在Host上用setquota限制该username的UID的disk. 网络上由于docker采用veth的方式,可以采用tc来控制host上的veth的设备。参考[2]

参考文献:

[1]https://github.com/dotcloud/docker/issues/111

[2]https://github.com/dotcloud/docker/issues/471

RHEL 6.5

这里简单介绍下device mapper driver的思路,参考文献[2]中的讨论非常有价值。 docker的dirver要利用snapshot机制,起初的fs是一个空的ext4的目录,然后写入每个layer。每次创建image其实就是对其父image/base image进行snapshot, 然后在此snapshot上的操作都会被记录在fs的metadata中和AUFS layer(没读代码不是很理解?),docker commit将 diff信息在parent image上执行一遍. 这样创建出来的image就可以同当前container的运行环境分离开独立保存了。

这里仅仅查看材料理解不是很透彻,还是需要深入代码去了解详情。贴出 mail list 的片段,如果有理解的请不吝赐教。

The way it works is that we set up a device-mapper thin provisioning pool with a single base device containing an empty ext4 filesystem. Then each time we create an image we take a snapshot of the parent image (or the base image) and manually apply the AUFS layer to this. Similarly we create snapshots of images when we create containers and mount these as the container filesystem.

"docker diff" is implemented by just scanning the container filesystem and the parent image filesystem, looking at the metadata for changes. Theoretically this can be fooled if you do in-place editing of a file (not changing the size) and reset the mtime/ctime, but in practice I think this will be good enough. 

"docker commit" uses the above diff command to get a list of changed files which are used to construct a tarball with files and AUFS whiteouts (for deletes). This means you can commit containers to images, run new containers based on the image, etc. You should be able to push them to the index too (although I've not tested this yet).

Docker looks for a "docker-pool" device-mapper device (i.e. /dev/mapper/docker-pool) when it starts up, but if none exists it automatically creates two sparse files (100GB for the data and 2GB for the metadata) and loopback mount these and sets these up as the block devices for docker-pool, with a 10GB ext4 fs as the base image. 

This means that there is no need for manual setup of block devices, and that generally there should be no need to pre-allocate large amounts of space (the sparse files are small, and we things up so that discards are passed through all the way back to the sparse loopbacks, so deletes in a container should fully reclaim space.

目前已知存在的问题是删除的image的 block 文件没有被删除,见https://github.com/dotcloud/docker/issues/3182, 笔者发现此问题前4个小时作者给出了原因,看起来是kernel的issue,在讨论中包含work around的方法。

参考文献:

[1]http://blog.docker.io/2013/11/docker-0-7-docker-now-runs-on-any-linux-distribution/

[2]https://groups.google.com/forum/#!topic/docker-dev/KcCT0bACksY


Summary

本文总结了以下几点内容

  1. docker的介绍,包括由来、适用场景等
  2. docker背后的一系列技术 – namespace, cgroup, lxc, aufs等
  3. docker在利用LXC的同时提供了哪些创新
  4. 笔者对docker这种container, PaaS的一些理解
  5. docker存在的问题和现有的解决思路

希望能对想要了解docker的朋友有所帮助,更细致的了解还是得深入代码, 了解个中原委。

docker@github – https://github.com/dotcloud/docker

docker_maillist – https://groups.google.com/forum/#!forum/docker-dev

原文出处:http://tiewei.github.io/cloud/Docker-Getting-Start/

以下内容纯为娱乐,如有雷同纯属巧合

从供应商的角度看,一个物理机 开尽可能多的虚拟机,一个虚拟机,占用尽可能少的资源,一个物理机,空载的时候尽可能关机,这才是IAAS供应商的未来和追求。而技术也因此而发展。无论是从部署、迁移、伸缩等角度看,云计算的本质就是提高资源利用率,降低成本,至于这个成本的降低是否惠及用户,则取决于商家。

所以一切伪云,青云、盛大、ucloud、美团等等等都是骗子,何不直接一点、厚道一点如linode,告诉大家我就是VPS。如果我再愤青一点,美团、UCLOUD之类早就被灭。(我的话:这个实在是有点喷过头了~ 我是他们的用户也~嘿嘿)

阿里第一次将BGP以良心价面世,我就认为很了不起,虽然阿里实际给用户的也是伪BGP,但是其质量,因为正规互联路由表的存在和完善的运维制度,远胜电信通等等。阿里第一次基于分布式存储上的夜间物理机休眠,虚拟机集中化(虽然仅仅是实验),其他厂家呢?阿里经历读写速率到1kb而没有丢数据,其他厂家呢? 现在的这些VPS厂家,谁支持了热快照,谁的热快照的是包含状态的?为什么要定义VCPU或所谓ECU?是因为用户压根用不到物理核心的性能,用得到的自然乐意花钱,用不到的花钱了也是浪费,这就是社会现实在技术上的解决方案:社会大同!(我的话:太看得起阿里云了。)

SDN软定义网络 (这个东西貌似很火啊!不过我觉得哪怕在以后,也是特定场景有用)

再说SDN,所谓软件定义网络,无非是将网络的控制界面和数据界面分离,使得数据界面可以专心转发用户的业务数据流,而不带控制逻辑。那么那些是控制逻辑,说直白来,就是比如协议、路由策略、ACL、流控等等等等。难道,给每个用户设置个独立网络,再通过linux隧道 起3层组网就是SDN了?可以说是,但是也可以说不是。平生最讨厌接概念炒作以期资本收益的。

就像,盛大当初拿隔离用户的虚拟机与其他用户的虚拟机之间的通信来说事情一般,难道这不是应该的么?虽然很多人不知道怎么做,但是其实无论你是给每个用户创建独立的dummy和桥接再创建tap或直接创建VLAN+tunnel又或直接一点arptables或ebtables就可以解决这个问题。最后说弹性IP的问题了。

NAT不好么?对运营商和服务提供商来说,地址转换本就是因为他们有需求而发展的技术。使用NAT能够提升地址的使用效率,能够提升服务商的控制力度(我NAT的时候就给你限制了你只能做什么不能做什么),能够方便迁移(虽然NAT场景下的迁移会产生大约1个丢包)、重定向等等,何乐而不为。对于用户来说,NAT在某种场景下降低了网络维护带来的故障率,要知道大2层地址沉降模式下为了有效的隔离和阻断,在维护时候的故障率一向不低,虽然迁移的时候几乎不会有丢包,但是你要更好用户地址的时候你怎么办?重新注射metadata写用户虚拟机镜像内的网络配置文件?代价可不低,用户配置文件可不是你随意就可以操控并且具备规范性的。用户犯事的时候但是又需要内网通备份的情况下怎么办?假设网关为物理设备,独立出来,带来的网内跨设备流量怎么处理?点多多点,多点对多点就变成了多点过单点再到多点,网络不郁闷死?

 

—运维群 群友 程理梁(化名)