Redis是一个开源的,高性能的 key-value 的数据库。基于 Redis 的分布式缓存已经有很多成功的商业应用,其中就包括阿里 ApsaraDB,阿里Tair中的rdb引擎,美团 MOS以及腾讯云 CRS。本章笔者将着重介绍 Redis-Cluster原理、类Codis分布式方案以及分布式信息一致性协议Gossip,以帮助大家深入理解 Redis。
顾名思义,单机模式就是指 Redis 主节点以单个节点的形式存在,这个主节点可读可写,上面存储数据全集。在3.0版本之前,Redis 只能支持单机模式,出于可靠性考量,通常单机模式为“1主 N 备”的结构,如下所示:
需要说明的是,即便有很多个 Redis 主节点,只要这些主节点以单机模式存在,本质上仍为单机模式。单机模式比较简单,足以支撑一般应用场景,但单机模式具有固有的局限性:不支持自动故障转移,扩容能力极为有限(只能Scale-Up, 垂直扩容),高并发瓶颈。
Redis 单机模式下,即便是“1主 N 备”结构,当主节点故障时,备节点也无法自动升主,即无法自动故障转移(Failover)。故障转移需要“哨兵”Sentinel 辅助,Sentinel 是 Redis 高可用的解决方案,由一个或者多个 Sentinel 实例组成的系统可以监视 Redis 主节点及其从节点,当检测到 Redis 主节点下线时,会根据特定的选举规则从该主节点对应的所有从节点中选举出一个“最优”的从节点升主,然后由升主的新主节点处理请求。具有 Sentinel 系统的单机模式示意图如下:
这一点应该很好理解,单机模式下,只有主节点能够写入数据,那么,最大数据容量就取决于主节点所在物理机的内存容量,而物理机的内存扩容(Scale-Up)能力目前仍是极为有限的。
Redis使用单线程的IO复用模型,对于单纯的IO操作来说,单线程可以将速度优势发挥到最大,但是Redis也提供了一些简单的计算功能,比如排序、聚合等,对于这些操作,单线程模型实际会严重影响整体吞吐量,CPU计算过程中,整个IO调度都是被阻塞住的。因此,单机模式下并发支持能力很容易陷入瓶颈。
单实例 Redis 虽然简单,但瓶颈明显。一是容量问题,在一些应用场景下,数据规模可达数十 G,甚至数百G,而物理机的资源却是有限的,内存无法无限扩充;二是并发性能问题,Redis 号称单实例10万并发,但也仅仅是10万并发。鉴于单机模式的局限性,历时三年,Redis-Cluster应运而生。
自3.0版本起,Redis官方推出了一个原生的分布式方案—— Redis-Cluster,它是一个分布式、容错的 Redis 实现。 Redis-Cluster中不存在中心节点或者代理节点, 集群主要设计目标之一是实现线性可扩展性。Redis-Cluster具有以下特点:
Redis 集群实现的基础是分片,即将数据集有机的分割为多个片,并将这些分片指派给多个 Redis 实例,每个实例只保存总数据集的一个子集。利用多台计算机内存和来支持更大的数据库,而避免受限于单机的内存容量;通过多核计算机集群,可有效扩展计算能力;通过多台计算机和网络适配器,允许我们扩展网络带宽。
Hash slot
基于“分片”的思想,Redis 提出了 Hash Slot。Redis-Cluster 把所有的物理节点映射到预分好的16384个 Slot,当需要在 Redis 集群中放置一个 Key-Value 时,根据 CRC16(key) Mod 16384的值,决定将一个 Key 放到哪个 Slot 中。
客户端直连 Redis 服务,进行读写操作时,Key 对应的 Slot 可能并不在当前直连的节点上,经过“重定向”才能转发到正确的节点。如下图所示,我们直接登陆127.0.0.1:6379客户端,进行 set 操作,当 Key 对应的 Slot 不在当前节点时(如 key-test),客户端会报错并返回正确的节点的 IP 和端口;set 成功则返回OK。
以集群模式登陆127.0.0.1:6379客户端(注意命令的差别:-c 表示集群模式),则可以清楚的看到“重定向”的信息,并且客户端也发生了切换:“6379”->“6381”。
和普通的查询路由相比,Redis-Cluster 借助客户端实现的请求路由是一种混合形式的查询路,它并非从一个 Redis 节点到另外一个 Redis,而是借助客户端转发到正确的节点。
在实际应用中,可以在客户端缓存 Slot 与 Redis 节点的映射关系,当接收到 MOVED 响应时修改缓存中的映射关系。如此,基于保存的映射关系,请求时会直接发送到正确的节点上,从而减少一次交互,提升效率。
目前,包括 Lettuce、Jedis、Redission 在内的许多 Redis Client,都已经实现了对 Redis-Cluster 的支持,关于客户端的内容,将在第五章详细介绍。
在第二章曾介绍:Redis自3.0版本起,支持Redis-Cluster,真正意义上实现了分布式。在分布式系统中,节点间的通信十分重要,是构建集群的基石。那么Redis-Cluster 中,节点间是如何通信的呢?又是如何保障一致性、可用性的呢?欲知答案,必先了解Gossip算法。
Gossip,英文释义为:n 流言蜚语,谣言; vi 传播流言,说长道短。Gossip算法源自流行病学的研究,经过不断的发展演化,作为一种分布式一致性协议而得到广泛应用,如Cassandra、Akka、Redis都有用到。
Gossip背景
Gossip算法如其名,在办公室,只要一个人八卦一下,在有限的时间内所有的人都会知道该八卦的信息,这种方式也与病毒传播类似,因此Gossip有众多的别名:“闲话算法”、“疫情传播算法”、“病毒感染算法”、“谣言传播算法”。但Gossip并不是一个新东西,之前的泛洪查找、路由算法都归属于这个范畴,不同的是Gossip给这类算法提供了明确的语义、具体实施方法及收敛性证明。
Gossip特点
Gossip算法又被称为反熵(Anti-Entropy),熵是物理学上的一个概念,代表杂乱无章,而反熵就是在杂乱无章中寻求一致,这充分说明了Gossip的特点:在一个有界网络中,每个节点都随机地与其它节点通信,经过一番杂乱无章的通信,最终所有节点的状态都会达成一致。每个节点可能知道所有其它节点,也可能仅知道几个邻居节点,只要这些节可以通过网络连通,最终它们的状态都是一致的。
要注意到的一点是,即使有的节点因宕机而重启,有新节点加入,但经过一段时间后,这些节点的状态也会与其他节点达成一致,也就是说,Gossip天然具有分布式容错的优点。
Gossip本质
Gossip是一个带冗余的容错算法,更进一步,Gossip是一个最终一致性算法。虽然无法保证在某个时刻所有节点状态一致,但可以保证在“最终”所有节点一致,“最终”是一个现实中存在,但理论上无法证明的时间点。
因为Gossip不要求节点知道所有其它节点,因此又具有去中心化的特点,节点之间完全对等,不需要任何的中心节点。实际上Gossip可以用于众多能接受“最终一致性”的领域:失败检测、路由同步、Pub/Sub、动态负载均衡。
但Gossip的缺点也很明显,冗余通信会对网路带宽、CUP资源造成很大的负载,而这些负载又受限于通信频率,该频率又影响着算法收敛的速度,下文中,笔者将结合Redis源码详细解释。
在分布式系统中,需要提供维护节点元数据信息的机制,所谓元数据是指:节点负责哪些数据、主从属性、是否出现故障等状态信息。常见的元数据维护方式分为:集中式和无中心式。Redis-Cluster采用Gossip协议实现了无中心式。
Redis-Cluster使用Gossip主要有两方面的作用:
Redis-Cluster中的每个 Redis 实例监听两个 TCP 端口,6379(默认)用于服务客户端查询,16379(默认服务端口+10000)用于集群内部通信。集群中节点通信方式如下:
集群中每个节点通过一定规则挑选要通信的节点,每个节点可能知道全部节点,也可能仅知道部分节点,只要这些节点彼此可以正常通信,最终它们会达到一致的状态。当节点故障、新节点加入、主从关系变化、槽信息变更等事件发生时,通过不断的ping/pong消息通信,经过一段时间后所有的节点都会知道集群全部节点的最新状态,从而达到集群状态同步的目的。
Gossip协议的主要职责就是信息交换。信息交换的载体就是节点彼此发送的Gossip消息,常用的Gossip消息可分为:ping消息、pong消息、meet消息、fail消息。
由于集群内部需要频繁地进行节点信息交换,而ping/pong消息会携带当前节点和部分其它节点的状态数据,势必会加重带宽和计算的负担。Redis集群内节点通信采用固定频率(定时任务每秒执行10次)。因此,节点每次选择需要通信的节点列表变得非常重要。通信节点选择过多虽然可以做到信息及时交换但成本过高。节点选择过少则会降低集群内所有节点彼此信息交换的频率,从而影响故障判定、新节点发现等需求的速度。因此Redis集群的Gossip协议需要兼顾信息交换实时性和成本开销。
Redis节点启动之后,会每间隔100ms执行一次集群的周期性函数clusterCron()。在Redis源码server.c中可见:
/* Run the Redis Cluster cron. */
run_with_period(100) {
if (server.cluster_enabled) clusterCron();
}
而clusterCron()中又会调用clusterSendPing()函数,该函数用于将随机选择的节点的信息加入到ping消息体中,然后发送出去。部分源码如下:
void clusterSendPing(clusterLink *link, int type) {
unsigned char *buf;
clusterMsg *hdr;
int gossipcount = 0; /* Number of gossip sections added so far. */
int wanted; /* Number of gossip sections we want to append if possible. */
int totlen; /* Total packet length. */
// freshnodes = 集群总节点数 - (2=当前节点+发送消息的目的节点)
// freshnodes 的值是ping消息体中可以携带节点信息的最大值
int freshnodes = dictSize(server.cluster->nodes)-2;
// wanted 的值是集群节点的十分之一向下取整,并且最小等于3
// wanted 表示ping消息体中期望携带的其它节点信息个数
wanted = floor(dictSize(server.cluster->nodes)/10);
if (wanted < 3) wanted = 3;
// 因此 wanted 最多等于 freshnodes。
if (wanted > freshnodes) wanted = freshnodes;
// 计算分配消息的最大空间
totlen = sizeof(clusterMsg)-sizeof(union clusterMsgData);
totlen += (sizeof(clusterMsgDataGossip)*wanted);
// 消息的总长最少为一个消息结构的大小
if (totlen < (int)sizeof(clusterMsg)) totlen = sizeof(clusterMsg);
// 分配空间
buf = zcalloc(totlen);
hdr = (clusterMsg*) buf;
// 设置发送PING命令的时间
if (link->node && type == CLUSTERMSG_TYPE_PING)
link->node->ping_sent = mstime();
// 构建消息的头部
clusterBuildMessageHdr(hdr,type);
int maxiterations = wanted*3;
// 循环体,构建消息内容
while(freshnodes > 0 && gossipcount < wanted && maxiterations--) {
// 随机选择一个集群节点
dictEntry *de = dictGetRandomKey(server.cluster->nodes);
clusterNode *this = dictGetVal(de);
clusterMsgDataGossip *gossip;
int j;
// 1. 跳过当前节点,不选myself节点,myself代表当前节点
if (this == myself) continue;
// 2. 偏爱选择处于下线状态或疑似下线状态的节点
if (maxiterations > wanted*2 &&
!(this->flags & (CLUSTER_NODE_PFAIL|CLUSTER_NODE_FAIL)))
continue;
// 以下节点不能作为被选中的节点:
/*
1. 处于握手状态的节点
2. 带有NOADDR标识的节点
3. 因为不处理任何槽而断开连接的节点
*/
if (this->flags & (CLUSTER_NODE_HANDSHAKE|CLUSTER_NODE_NOADDR) ||
(this->link == NULL && this->numslots == 0))
{
freshnodes--; /* Tecnically not correct, but saves CPU. */
continue;
}
}
//(中间部分代码省略.............)
// 发送消息
clusterSendMessage(link,buf,totlen);
zfree(buf);
}
根据上述代码,相信读者可以明确:
1. 当前节点向另一个节点发送ping消息时,携带的其它节点的消息数量至少为3,最大等于集群节点总数-2;
2. 为ping消息体中选择携带的其它节点的信息时,采用的是混合选择模式:随机选择+偏好性选择,这样不仅可以保证Gossip协议随机传播的原则,还可以尽量将当前节点掌握的其它节点的故障信息传播出去;
前面已经提到,集群的周期性函数clusterCron()执行周期是100ms,为了保证传播效率,每10个周期,也就是1s,每个节点都会随机选择5个其它节点,并从中选择一个最久没有通信的节点发送ping消息,源码如下:
当然,这样还是没法保证效率,毕竟5个节点是随机选出来的,其中最久没有通信的节点不一定是全局“最久”。因此,对哪些长时间没有 “被” 随机到的节点进行特殊照顾:每个周期(100ms)扫描本地节点列表,如果发现节点最近一次接受pong消息的时间大于cluster_node_timeout/2,则立刻发送ping消息,防止该节点信息太长时间未更新。源码如下:
1. 关键参数cluster_node_timeout
从上面的分析可以看出,cluster_node_timeout参数对消息发送的节点数量影响非常大。当带宽资源紧张时,可以适当调大这个参数,如从默认15秒改为30秒来降低带宽占用率。但是,过度调大cluster_node_timeout会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现的速度。因此需要根据业务容忍度和资源消耗进行平衡。同时整个集群消息总交换量也跟节点数成正比。
2. 消息体与集群规模
每个ping消息的数据量体现在消息头和消息体中,其中消息头空间占用相对固定。消息体会携带一定数量的其它节点信息用于信息交换,消息体携带数据量跟集群的节点数息息相关,更大的集群每次消息通信的成本也就更高,因此对于Redis集群来说并不是越大越好。
在上面已经介绍过单机模式的故障转移(主节点下线后,对应从节点升主并替代原主节点继续工作的过程),单机模式下故障转移需要 Sentinel 系统的辅助,与之不同,Redis 集群模式故障转移并不需要 Sentinel系统辅助,而是通过集群内部主节点选举完成,是一个“自治”的系统,以下详细介绍。
(1)单点视角检测
集群中的每个节点都会定期通过集群内部通信总线向集群中的其它节点发送 PING 消息,用于检测对方是否在线,如果接收 PING 消息的节点没有在规定的时间内向发送 PING 消息的节点返回 PONG 消息,那么,发送 PING 消息的节点就会将接收 PING 消息的节点标注为疑似下线状态(Probable Fail,Pfail)。
(2)检测信息传播
集群中的各个节点会通过相互发送消息的方式来交换自己掌握的集群中各个节点的状态信息,如在线,疑似下线(Pfail),下线(fail)。例如,当一个主节点 A 通过消息得知主节点 B 认为主节点 C 疑似下线时,主节点 A 会更新自己保存的集群状态信息,将从 B 获得的下线报告保存起来。
(3)基于检测信息作下线判决
如果在一个集群里,超过半数的持有slot(槽)的主节点都将某个主节点 X 报告为疑似下线,那么,主节点 X 将被标记为下线(fail),并广播出去,所有收到这条 fail 消息的节点都会立即将主节点 X 标记为 fail。至此,故障检测完成。
(1)从节点拉票
基于故障检测信息的传播,集群中所有正常节点都将感知到某个主节点下线的信息,当然也包括这个下线主节点的所有从节点。当从节点发现自己的复制的主节点状态为已下线时,从节点就会向集群广播一条请求消息,请求所有收到这条消息并且具有投票权的主节点给自己投票。
(2)拉票优先级
严格的讲,从节点在发现其主节点下线时,并不是立即发起故障转移流程而进行“拉票”的,而是要等待一段时间,在未来的某个时间点才发起选举。这个时间点有如下计算表达式:其中,固定延时500ms,是为了留出时间,使主节点下线的消息能传播到集群中其他节点,这样集群中的主节点才有可能投票;随机延时是为了避免两个从节点同时开始故障转移流程;rank 表示从节点的排名,排名是指当前从节点在下线主节点的所有从节点中的排名,排名主要是根据复制数据量来定,复制数据量越多,排名越靠前,因此,具有较多复制数据量的从节点可以更早发起故障转移流程,从而更可能成为新的主节点。
mstime() + 500ms + random()%500ms + rank*1000ms
(3)主节点投票
如果一个主节点具有投票权(负责处理 Slot 的主节点),并且这个主节点尚未投票给其它从节点,那么这个主节点将向请求投票的从节点返回一条回应消息,表示支持该从节点升主。
(4)根据投票结果决策
在一个具有 N 个主节点投票的集群中,理论上每个参与拉票的从节点都可以收到一定数量的主节点投票,但是,在同一轮选举中,只可能有一个从节点收到的票数大于 N/2 + 1,也只有这个从节点可以升级为主节点,并代替已下线的主节点继续工作。
(5)选举失败
跟生活中的选举一样,选举可能失败——没有一个候选从节点获得超过半数的主节点投票。遇到这种情况,集群将会进入下一轮选举,直到选出新的主节点为止。
(6)选举算法
选举新的主节点的算法是基于 Raft 算法的 Leader Election 方法来实现的,本文就不展开了,在本课程后面介绍ETCD的章节会详述Raft算法。
(1)身份切换
通过选举晋升的从节点会执行一系列的操作,清除曾经为从的信息,改头换面,成为新的主节点。
(2)接管职权
新的主节点会通过轮询所有 Slot,撤销所有对已下线主节点的 Slot 指派,消除影响,并且将这些 Slot 全部指派给自己。
(3)广而告之
升主了嘛,必须让圈子里面的都知道,新的主节点会向集群中广播一条 PONG 消息,将自己升主的信息通知到集群中所有节点。
(4)履行义务
在其位谋其政,新的主节点开始处理自己所负责 Slot 对应的请求,至此,故障转移完成。
随着应用场景的升级,缓存可能需要扩容,扩容的方式有两种:垂直扩容(Scale Up)和水平扩容(Scale Out)。垂直扩容无需详述。实际应用场景中,采用水平扩容更多一些,根据是否增加主节点数量,水平扩容方式有两种。
(1)主节点数量不变
比如,当前有一台物理机 A,构建了一个包含3个 Redis 实例的集群;扩容时,我们新增一台物理机 B,拉起一个 Redis 实例并加入物理机 A 的集群;B 上 Redis 实例对 A 上的一个主节点进行复制,然后进行主备倒换;如此,Redis 集群还是3个主节点,只不过变成了 A2-B1 的结构,将一部分请求压力分担到了新增的节点上,同时物理容量上限也会增加,主要步骤如下:
(2)增加主节点数量
不增加主节点数量的方式扩容比较简单,但是,从负载均衡的角度来看,并不是很好的选择。例如,如果主节点数量较少,那么单个节点所负责的 Slot 的数量必然较多,很容易出现大量 Key 的读写集中于少数节点的现象,而增加主节点的数量,可以更有效的分摊访问压力,充分利用资源。主要步骤如下:
作为Redis官方出品,Redis-Cluster有很多优点,但是,当集群规模超过百级别后,Gossip协议的效率将会显著下降,通信成本越来越高。此外,Redis-Cluster模式下,16384个slot中只要有任意一个slot不可用,整个集群都将不可用,换言之,任何一个被指派slot的主节点故障,在其恢复期间,集群都是不可用的。鉴于此,Redis-Cluster并不适合超大规模商用场景,国内IT巨头基本上采用的都是自研的集群方案,如阿里云ApsaraDB for Redis/ApsaraCache,腾讯的CRS;当然,Redis-Cluster也有很成功的商用案例:亚马逊采用的就是Redis-Cluster。
TIPS
通常,Redis-Cluster的规模都控制在400个节点以内,以单节点支撑8W QPS 计算,400个节点可支持 200*8=1600W QPS(每个主节点配置一个从节点),足以支撑绝大多数应用场景。此外,Redis-Cluster使用简单,无需深度定制便可实现商用,成本较低,因此,即便它不适合超大规模商用,仍然是一个非常优秀的集群方案。
1. 基于客户端的分片
如下图所示,客户端与Redis节点直连,为了提高可用性,每个主节点挂一个从节点,故障倒换可由“哨兵”系统实现(其它方案也可实现)。客户端对任何一个主节点的读写操作本质上就是单机模式下的读写操作;对于一个key-value,其读写节点完全由客户端决定。比如,采用hash算法:
hash(key)%N = 目标节点编号, 其中N为Redis主节点的数量,哈希取余的方式会将不同的 key 分发到不同的Redis主节点上。
但是,hash算法有很多缺陷:
1. 不支持动态增加节点:当业务量增加,需要增加服务器节点后,上面的计算公式变为:hash(key)%(N+1) ,那么,对于同一个key-value,增加节点前后,对应的Redis节点可能是完全不同的,可能导致大量之前存储的数据失效;为了避免解决这个问题,需要将所有数据重新计算hash值,再写入Redis 服务器。
2. 不支持动态减少节点,原理同上。
鉴于hash算法的不足,在实际应用中一般采用“一致性哈希”算法,在增删节点的时候,可以保证尽可能多的缓存数据不失效。关于一致性哈希算法,网上文章很多,读者可自行研读。
客户端分片的缺点:
采用客户端分片具有逻辑简单,性能高的优点,但缺点也很明显:业务逻辑与数据存储逻辑耦合,可运维性差;多业务各自使用 Redis,集群资源难以管理。
2. 基于代理的分片
为了克服客户端分片业务逻辑与数据存储逻辑耦合的不足,可以通过 Proxy 将业务逻辑和存储的逻辑隔离。客户端发送请求到一个代理,代理解析客户端的数据,将请求转发至正确的节点,然后将结果回复给客户端。这种架构还有一个优点就是可以把 Proxy 当成一个中间件,在这个中间件上可以做很多事情,比如可以把集群和主从的兼容性做到几乎一致、可以做无缝扩减容、安全策略等。
基于代理的分片已经有很多成熟的方案,如开源的 Codis,阿里云的 ApsaraDB for Redis/ApsaraCache,腾讯的CRS 等大企业很多也是采用 Proxy+Redis-server 的架构。
基于代理分片的缺点:
没有完美的架构,由于使用了proxy,带宽和CPU基本也是double的,对资源的消耗会大很多。
Codis 是一个分布式 Redis 解决方案,对于上层的应用来说,连接到 Codis Proxy 和连接原生的 Redis Server没有明显的区别 (参考6.1中的代理分片模式),客户端可以像使用单机 Redis 一样使用。
架构图如下:
Codis 简介
从Codis的官方架构图可以看出,Codis 主要有四部分组成:
codis-proxy
是客户端连接的 Redis 代理服务, codis-proxy 本身实现了 Redis 协议, 表现得和一个原生的 Redis 没什么区别 (就像 Twemproxy), 对于一个业务来说, 可以部署多个 codis-proxy, codis-proxy 本身是无状态的.
codis-config
是 Codis 的管理工具, 支持包括, 添加/删除 Redis 节点, 添加/删除 Proxy 节点, 发起数据迁移等操作. codis-config 本身还自带了一个 http server, 会启动一个 dashboard, 用户可以直接在浏览器上观察 Codis 集群的运行状态.
codis-server
是 Codis 项目维护的一个 Redis 分支, 基于 2.8.13 开发, 加入了 slot 的支持和原子的数据迁移指令. Codis 上层的 codis-proxy 和 codis-config 只能和这个版本的 Redis 交互才能正常运行.
ZooKeeper
Codis 依赖 ZooKeeper 来存放数据路由表和 codis-proxy 节点的元信息, codis-config 发起的命令都会通过 ZooKeeper 同步到各个存活的 codis-proxy.
Codis 特点
Codis出现在Redis-Cluster之前,事实上,正是由于Redis官方推出Redis-Cluster太晚,IT巨头们又都是急性子,只好自力更生,搞大生产运动,才诞生了众多定制化或开源的分布式Redis方案。Codis 作为其中的代表,具有以下特点:
在第二节中曾提及:实现Redis分布式的基础是分片。目前,主流的分片方案有三种:Redis-Cluster、客户端分片、代理分片。除了官方推出的Redis-Cluster,大多数IT公司采用的都是基于代理的分片模式,即:proxy + Redis-server,这与Codis的原理类似,因此也称为 “类Codis” 架构,其架构图如下。
架构特点
分片算法:基于代理的分片原理,将物理节点映射到slot (Codis slot数为1024,其它方案一般为16384),对key-value进行读写操作时,采用一致性hash算法或其它算法(如Redis-Cluster采用的CRC16),计算key对应的slot编号,根据slot编号转发到对应的物理节点;
分片实例之间相互独立,每组 一个master 实例和多个slave,其本质就是“1主N从”的单机模式;
路由信息存放依赖第三方存储组件,如 zookeeper 或etcd;
High Availability:Redis单机模式不支持自动故障倒换,为了保证高可用,需要类似“哨兵系统”的HA组件来支持高可用。
致谢与参考文献
本文的一些图片和文字引用了一些博客和论文,尊重原创是每一个写作者应坚守的底线,在此,将本文引用过的文章一一列出,以表敬意: