0%

nginx通过epoll等事件机制来驱动,下面分析下nginx是如何实现与后端upstream的异步连接。

首先分析下:ngx_event_connect_peer

看下这个函数在哪些地方被调用

1
2
3
4
5
6
$grep -rn 'ngx_event_connect_peer' src/
src/http/ngx_http_upstream.c:1104: rc = ngx_event_connect_peer(&u->peer);
src/event/ngx_event_connect.h:72:ngx_int_t ngx_event_connect_peer(ngx_peer_connection_t *pc);
src/event/ngx_event_connect.c:15:ngx_event_connect_peer(ngx_peer_connection_t *pc)
src/mail/ngx_mail_proxy_module.c:151: rc = ngx_event_connect_peer(&p->upstream);
src/mail/ngx_mail_auth_http_module.c:195: rc = ngx_event_connect_peer(&ctx->peer);

暂时只关注http,只在upstream模块中被调用,看下其使用方法:
调用链: ngx_http_upstream_connect(ngx_http_request_t *r, ngx_http_upstream_t *u)
-> rc = ngx_event_connect_peer(&u->peer);

下面分析ngx_event_connect_peer(ngx_peer_connection_t *pc)

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
struct ngx_peer_connection_s {
ngx_connection_t *connection;

struct sockaddr *sockaddr;
socklen_t socklen;
ngx_str_t *name;

ngx_uint_t tries;

ngx_event_get_peer_pt get;
ngx_event_free_peer_pt free;
void *data;

#if (NGX_SSL)
ngx_event_set_peer_session_pt set_session;
ngx_event_save_peer_session_pt save_session;
#endif

#if (NGX_THREADS)
ngx_atomic_t *lock;
#endif

ngx_addr_t *local;

int rcvbuf;

ngx_log_t *log;

unsigned cached:1;

/* ngx_connection_log_error_e */
unsigned log_error:2;
};

这是专门用来给后端用的connection结构体,

======================================================

在看多了代码后,有这样一个体会:即搞清楚ngx_http_request, ngx_connection_t,ngx_event_t ngx_http_upstream_t等结构体的相互的联动以及为何要这样设计联动,就可以大体上掌握nginx的http框架了、事件框架了

(由于http框架是建立在事件框架基础上的,在对比下mail等七层的框架,就可以很容易融会贯通了。)

r、c、ev间的关系:

知道r如何找其他两者
c = r->connection
rev = c->read;
wev = c->write;

知道c如何找其他两者
r = c->data;(在空闲的c中,c->data指向下一个空闲的c)
rev = c->read;
wev = c->write;

知道到ev如何找其他两者
c = ev->data;
r = c->data;

分析下,为何ev->data指向的是c:因为事件是和连接层面相关的,和七层应用层面无关,事件的data将指向该事件是发生在哪个连接上的。
为何c->data指向r,其实c的data不一定指向r,只是在http层面, c是用于http传输的,那么c的data指向r,如果是在mail传输,c的data指向的是s(ngx_mail_session_t),

为何r->connection会指向c,显而易见从名字就可以看出,且这个c是和客户端的c,不是和上有的c(如果有的化)

和上有的的c是这样:u=r->upstream, c = u->peer.connection(这个c是和上游的c)

对事件的处理的一点心得:
r->read_event_handler/r->write_event_handler为何要有这样的钩子,通过源码可以查看到是在
ngx_http_request_handler(ev)中被调用的:

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
static void
ngx_http_request_handler(ngx_event_t *ev)
{
ngx_connection_t *c;
ngx_http_request_t *r;
ngx_http_log_ctx_t *ctx;

c = ev->data;
r = c->data;

ctx = c->log->data;
ctx->current_request = r;

ngx_log_debug2(NGX_LOG_DEBUG_HTTP, c->log, 0,
"http run request: \"%V?%V\"", &r->uri, &r->args);

if (ev->write) {
r->write_event_handler(r);

} else {
r->read_event_handler(r);
}

ngx_http_run_posted_requests(c);
}

而ngx_http_request_handler又被赋为
c->read->handler = ngx_http_request_handler;
c->write->handler = ngx_http_request_handler;
连接的读写事件的handler

从这里可以看出:事件触发 -> 调用ngx_http_request_handler -> 继续调用r->read_event_handler or r->write_event_handler。

这样nginx提供了这样一种机制:它将ev的回掉设置固定,但是将真正执行的handler留给模块自己去决定,比如
你只需关注在模块中将r->read_event_handler/write_event_handler设置好就OK,不用去设置ev的回调,这算是提供的一种对外接口。

1 源起

在逛某个论坛时,看到有一个题目:redis中的zset是如何实现的?我对redis中的数据结构还算比较了解,比如sds,double-queue skiplist ziplist等,但似乎没想起来还有zset这样的数据结构。

后来去翻书,发现zset不是数据结构,而是redis抽象出来的一种数据对象: 有序集合对象,它的主要实现形式由两种方式,一是通过ziplist的方式,而是通过hashdict+skiplist的方式。

有序集合这种数据结构(这里我依然叫他为数据结构)在使用起来非常方便,它有hash表的访问速度,也有skiplist的范围查找速度(或者叫rbtree的范围查找速度),结合了树和哈希结构体的优势,在很多场景下使用都非常方便,下面分别从源码实现和使用场景方面来加以分析,相信如果掌握了zset的实现,redis中的其他数据结构也就非常容易掌握了。

2 实现形式

我们先来看它的第二种实现方式:hashdict+skiplist的方式,这也是在数据量较大的情况下的实现方式。那么必须要先介绍下这两种结构体。

1 hashdict

  • 数据结构

这个很好理解,就是hash表,不过redis在实现hash表时有自己的特点:下面是其结构体表示:

1
2
3
4
5
6
7
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;

其中存放数据的结构是dictht:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;


typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;

直观来看,大致长这样:

image.png

  • 基本操作及特征

dict的基本操作包括插入元素、查找元素,且时间复杂度为O(1),非常适合用于快速插入、查找的场景。下面给出查找的简单代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
dictEntry *dictFind(dict *d, const void *key)
{
dictEntry *he;
uint64_t h, idx, table;

if (d->ht[0].used + d->ht[1].used == 0) return NULL; /* dict is empty */
if (dictIsRehashing(d)) _dictRehashStep(d);
h = dictHashKey(d, key);
for (table = 0; table <= 1; table++) {
idx = h & d->ht[table].sizemask;
he = d->ht[table].table[idx];
while(he) {
if (key==he->key || dictCompareKeys(d, key, he->key))
return he;
he = he->next;
}
if (!dictIsRehashing(d)) return NULL;
}
return NULL;
}

通过dictHashKey(d,key)计算会落在哪个slot槽h中,与上sizemash,得到索引idx,通过idx得到槽表头,若有链冲突,依次查找即可。时间复杂度为O(1)。

另外:为何会查两张表?这是为了在进行rehash的时候,dict中的元素会分布在两张表中。

2 skiplist

hash表结构非常直观地可以理解,但skiplist(跳表)平时接触的不多,一般都是接触红黑树多一些,但为何redis里面没有使用红黑树而是跳表,我想是因为红黑树实现起来比跳表要复杂。另外红黑树在rebalance的时候需要锁住整颗树,但跳表只需要锁局部,不过对于单线程的redis来说这不是问题。skiplist在本质上也是一种查找类结构,即根据给定的key,快速找到其位置以及值。

下面介绍下跳表在redis中的实现以及使用场景。

  • 数据结构体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct zskiplistNode {
robj *obj;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;

typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;

主要有两部分,node节点和list表,先分析list结构体

  • header指向表头节点,tail指向表尾节点
  • length表示节点总数,不包括表头节点。
  • level表示层数最大的节点的层数。

下面是一个示意图:

image.png

node节点部分

  • level:是一个柔性数组,可以包含多个skiplistLevel元素,每个元素都包含一个指向其他节点的指针(forward), 可以通过这些指针加快这些层来加快访问其他节点的速度。一般来说,层数越多,访问速度越快。

image.png

上面的3个节点,层级依次为1,3,5. 每个节点在生产之初会随机指定1~32的层级,也就是节点的高度。

其中forawde前向指针是和span结合在一起:span表示了foraward指向的节点与该节点的距离。

  • backward: 指向该节点的后面节点。

  • obj、score: 指向具体的对象、以及该对象的分值。

初看起来,跳表就像是每个节点多了很多前向指针的双向链表而已,节点的排序方式按照score的大小排序。
那么为何要这样设计,它的优势在哪里。

William Pugh 在论文《Skip lists: a probabilistic alternative to balanced trees》提到,跳跃表以有序的方式在层次化的链表中保存数据,效率和平衡树媲美,插入、删除、查找都可以在O(logn)时间内完成,并且比起平衡树,实现方式要简单直观很多。下面从它的基本操作来解析。

  • 基本操作及特征

上面说过,skiplist其实也是一种list,那么它和一般的有序链表又有哪些区别,先看下有序链表。

image.png

如果要查找某个值,需要进行逐步遍历,直到找到或者找到比给定数据大的第一个节点(没找到)。当要插入一个数据时,同样要遍历。

如果我们给该链表中的节点新增一个指针,让其指针相邻的节点,这样有形成了一条链表,节点是原来的一半。
image.png

那么再进行查找,可以先在第二层链表上进行查找,如果发现遇到比查找值大的节点,然后回退到前一个节点,再在第一层链表上进行查找。

如果我们再给链表中的节点新增一个指针,让其指向与之距离为2的节点,这样形成的新链表的节点是原链表的1/3.

image.png

那么首先从第三层链表进行查找,如果遇到比查找值大的节点,回退,到第二层节点进行查找,若还是遇到比自己的节点,再回退,到第一层链表进行查找。

可以看到,如果链表足够长,这样通过高层次链表的查找方式将会跳过许多节点,大大增加查找效率(再也不是遍历操作),这也是跳表的名字由来。

如果深入来看,其实这种查找方式类似与二分查找,只不过是将二分查找的思想应用在了有序链表上。

而在实际使用中,没有这样严格的按照层次递增、节点数减半的特征,而是每个节点的层级是随机的,那么仅仅依靠随机的层级构建出来的多层级链表能够保证skiplist有良好的查找性能吗?通过数据统计,它的查找效率依然接近于O(log(n))。参考:

image.png

谈到这里应该对skiplist的工作原理清楚的了解,那么来比较下和典型的几种查找结果的差异:

1 skiplist与hashdict:hash表的查找效率是O(1),但有个缺点是hash表不是有序的,只能做单key的查找,如果要做范围查找无能为力。(STL中通过rbtree实现了有序map)

2 skiplist与rbtree:rbtree也是有序容器,但如果进行范围查找,skiplist的实现更为直观。

3 rbtree的插入删除涉及到rebalance,但skplist的插入删除只需要修改相邻节点的指针指向,更方便

4 最重要的是,skiplist的实现比rbtree更为简单。(虽然nginx实现了优雅的rbtree)。

3 zset的基本操作

zset在redis中是有序集合对象的名称(sort set),适合用在类似排行榜等应用场景。结构体如下:

1
2
3
4
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;

如果用zsert来表示某个课程的各位同学分数:

1
2
3
4
5
6
Alice 87.5
Bob 89.0
Charles 65.5
David 78.0
Emily 93.5
Fred 87.5

存放到zset中去:

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
127.0.0.1:6379> zadd math 87.5 alice
(integer) 1
127.0.0.1:6379> zadd math 89.0 bob
(integer) 1
127.0.0.1:6379> zadd math 65.5 charles
(integer) 1
127.0.0.1:6379> zadd math 78.0 david
(integer) 1
127.0.0.1:6379> zadd math 93.5 emily
(integer) 1
127.0.0.1:6379> zadd math 87.5 fred
(integer) 1
127.0.0.1:6379> zrevrank math alice
(integer) 3
127.0.0.1:6379> zscore math alice
"87.5"
127.0.0.1:6379> zrevrange math 0 3
1) "emily"
2) "bob"
3) "fred"
4) "alice"
127.0.0.1:6379> zrevrangebyscore math 90.0 80.0
1) "bob"
2) "fred"
3) "alice"
127.0.0.1:6379>

其中zadd将各个记录加入zset math中, zrevrank 逆序排序输出序号, zscore输出相应key的分数,zrevrange逆序将排序范围内的key输出, zrevragebysocre逆序将分数返回内的key输出。

如果我们结合skiplist来分析这几个操作,大致看下他们分别是如何实现的:首先要明确一点:再skiplist中查找是用score来作为key。

  • zscore: 根据key来输出score,由于skiplist是通过score来排序的,查找并不是通过key,这样zscore命令单由skiplist完成不了,实际上是通过hashdict来完成。

  • zrank(zrevrank): 输出某个key的排序:只要输入参数是key,需要先到dict中拿到相应key对应的score,然后再通过score到skiplist中去查找。所以需要dict+skiplist合力完成。如何通过skiplist查到排序:还记得之前的level数组元素中的span么?将沿途查找节点的各个span累加起来就可以得到排序了。

  • zrangebysocre(zrevrangebyscore): 输出分数范围内的key:直接在skplist查找,这是典型的范围查找。

  • zrange(zrevrange): 输出排序范围内的key: 由于span直接和排名相关,通过不断累加span以让其在给定的范围内,可以逐步找到一条路。

如果理解了skiplist+dict的工作原理,这些命令的实现你也应该清楚其逻辑了, 以后看到其他关于zset的命令基本上都可以大致清楚其实现逻辑,再结合源码可以更清楚地理解。

前言

在nginx中,有两个常用的限速模块 limit_conn 与limit_req,他们两者使用的场景各异,下面试图从源码视角来分析这两个模块的实现。这两个限速模块是典型的使用共享内存的模块,在分析这两个模块的过程中,顺便可以学习nginx是如何使用共享内存。

1 实现方式

两个模块都是在preaccess阶段插入的handler钩子,每个请求来时都会经过handler处理一遍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
limit_req

h = ngx_array_push(&cmcf->phases[NGX_HTTP_PREACCESS_PHASE].handlers);
if (h == NULL) {
return NGX_ERROR;
}
*h = ngx_http_limit_req_handler;

--------------
limit_conn

h = ngx_array_push(&cmcf->phases[NGX_HTTP_PREACCESS_PHASE].handlers);
if (h == NULL) {
return NGX_ERROR;
}
*h = ngx_http_limit_zone_handler;

2 配置解析

首先是共享内存的定义,由于两个模块都在配置上都相似,下面以limit_req的配置为切入点,观察配置与源码的关系

1
limit_req_zone  $binaray_remote_addr zone=one:10m rate=10r/s;

这一行定义了一块共享内存,其中key为ip地址(一般key都为变量方式),大小为10m,名称为one,在limit_req场景下限速为每秒允许10个key。注意这写属性都是共享内存的属性,与location以及请求都没有关系。

关键成员解析:

1
2
3
4
5
6
7
8
9
10
11
ctx = ngx_pcalloc(cf->pool, sizeof(ngx_http_limit_req_ctx_t));
if (ctx == NULL) {
return NGX_CONF_ERROR;
}

ctx->index = ngx_http_get_variable_index(cf, &value[i]);
if (ctx->index == NGX_ERROR) {
return NGX_CONF_ERROR;
}

ctx->var = value[i];

这里将变量的index存放在ctx上: ctx->index = ngx_http_get_variable_index(cf, &value[i]);

nginx中常见这种写法:如果需要使用某个变量,则将该变量的index存放在结构体中,但这里需要注意的是,ctx这个名称一般都是与请求粒度相关的,请求销毁了,ctx也将销毁,但这里将ctx存放在了全局空间,申请在ctx=palloc(cf->pool,xxx)上, 这似乎不太符合命名规范。个人认为最好取名为zone_ctx, 即共享内存的上下文属性。

另外,由于这些成员没有存放在xlcf等location相关的结构体中,在后续使用的时候如何找到这里的ctx呢?答案是通过共享内存shm。由于ctx是和某个shm强相关,理所当然需要和相应的shm bind在一起。

1
2
3
4
shm_zone = ngx_shared_memory_add(cf, &name, size,
&ngx_http_limit_req_module);
shm_zone->init = ngx_http_limit_req_init_zone;
shm_zone->data = ctx;

这里shm_zone的data将其联系在一起。那么问题是,如果找到某个指定的shm?是通过共享内存的名称即可。
ngx_shared_memory_add(cf, &name, 0, &ngx_http_limit_req_module)即可找到相应的shm。

而后续如果要使用shm,那么肯定需要提供name来找到,这样就将各个变量的衔接打通了。

3 shm初始化与使用

上面只是定义了shm的各个属性,没有具体将shm初始化以及组织,而初始化由各个使用模块自定义。下面以limit_req模块对shm的初始化为例进行分析:

初始化的钩子是ngx_http_limit_req_init_zone
整个初始化的过程实际体现在如何给相关ctx赋值(前面提到的zone_ctx),来具体看下ctx的结构体:

1
2
3
4
5
6
7
8
typedef struct {
ngx_http_limit_req_shctx_t *sh;
ngx_slab_pool_t *shpool;
/* integer value, 1 corresponds to 0.001 r/s */
ngx_uint_t rate;
ngx_int_t index;
ngx_str_t var;
} ngx_http_limit_req_ctx_t;

在申请shm的时候,rate、index、var等值已经被赋值过了,这里shpool是通过slab机制来使用shm的内存空间,是框架相关的机制,可以暂时不用关心(只需要知道使用slab机制可以高效地使用shm),后续对shm的内存分配都是通过ctx->shpool,不会直接在shm中分配,比如

1
2
3
4
ctx->sh = ngx_slab_alloc(ctx->shpool, sizeof(ngx_http_limit_req_shctx_t));
if (ctx->sh == NULL) {
return NGX_ERROR;
}

那么具体的组织细节体现在对sh的赋值上,这也是每片内存所独有的组织方式,看下sh的结构体:

1
2
3
4
5
ypedef struct {
ngx_rbtree_t rbtree;
ngx_rbtree_node_t sentinel;
ngx_queue_t queue;
} ngx_http_limit_req_shctx_t;

基本上将该片shm是通过红黑树和队列的方式组织的:

1
2
3
ngx_rbtree_init(&ctx->sh->rbtree, &ctx->sh->sentinel,
ngx_http_limit_req_rbtree_insert_value);
ngx_queue_init(&ctx->sh->queue);

这里多说一下,在limit_conn中,只有rbtree的方式,后面会分析到在limit_conn场景中,只有快速插入、查找的需求,而在limit_req中,多了queue的组织方式,这里的queue是用来做lrucache,为了避免内存溢出,将访问时间近的节点插入队头。

在初始化shm的时候还有一个地方值得注意
ctx->shpool->data = ctx->sh;

为啥要有这样的操作:主要是为了判断是否已经初始化这片shm,防止二次初始化,和业务逻辑没有关系,是shm框架使用。

  • 使用shm
    shm初始化完毕后,将通过location级别的指令指定使用那块shm,看下配置:
    1
    limit_zone=one burst=5 nodelya

通过名称指定使用哪块内存,将其保存在lcf结构中,这是location级别相关的存储结构体

1
2
3
4
5
6
typedef struct {
ngx_shm_zone_t *shm_zone;
/* integer value, 1 corresponds to 0.001 r/s */
ngx_uint_t burst;
ngx_uint_t nodelay; /* unsigned nodelay:1 */
} ngx_http_limit_req_conf_t;

其中shm_zone指向shm,burst和nodelay直接解析指令可以得到。

1
2
lrcf->shm_zone = ngx_shared_memory_add(cf, &s, 0,
&ngx_http_limit_req_module);

即通过名称找到先前初始化ok的shm。

小结:以上是配置解析过程,总的来说就是将shm初始化完毕,然后通过name找到将要使用的shm联系起来。从中可以学习到shm的一般使用流程。

4 算法流程

下面是每个请求流过handler时候的逻辑,也是限流算法的核心:针对每个请求级别生效。对于limit_conn来说比较简单,先分析其handler: ngx_http_limit_zone_handler

  • limit_conn

1 首先判断,这个r进入的location是否有定义限速:

1
2
3
4
lccf = ngx_http_get_module_loc_conf(r, ngx_http_limit_conn_module);
if (lccf->shm_zone == NULL) {
return NGX_DECLINED;
}

若该location没有设定限速,则直接跳过。(若设置了限速,lccf->shm_zone的值会指向某块内存,前面已述。

2 取出请求上变量值、查找
vv = ngx_http_get_indexed_variable(r, ctx->index);
根据值在rbtree上进行查找:

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
while (node != sentinel) {
if (hash < node->key) {
node = node->left;
continue;
}
if (hash > node->key) {
node = node->right;
continue;
}
/* hash == node->key */
lz = (ngx_http_limit_zone_node_t *) &node->color;
rc = ngx_memn2cmp(vv->data, lz->data, len, (size_t) lz->len);
if (rc == 0) {
if ((ngx_uint_t) lz->conn < lzcf->conn) {
lz->conn++;
goto done;
}
ngx_shmtx_unlock(&shpool->mutex);
ngx_log_error(lzcf->log_level, r->connection->log, 0,
"limiting connections by zone \"%V\"",
&lzcf->shm_zone->shm.name);
return NGX_HTTP_SERVICE_UNAVAILABLE;
}
node = (rc < 0) ? node->left : node->right;
}

逻辑也是十分直接:若rbtree中有相应节点,判断节点的lr->conn是否超过设定location的lzcf->conn值,若没有超过,则增加该key的lr->conn值,然后直接放过,若超过了,则直接503掉。

至于放过后的请求,需要做收尾处理:当请求处理完成后,需要将lr->conn的值恢复,cleanup适合做这件事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cln = ngx_pool_cleanup_add(r->pool, sizeof(ngx_http_limit_zone_cleanup_t));
(cln可以申请一块空间作为handler的参数:cln->data
cln->handler = ngx_http_limit_zone_cleanup;
lzcln = cln->data;
lzcln->shm_zone = lzcf->shm_zone;
lzcln->node = node;

定义如下

ngx_http_limit_zone_cleanup{
lz->conn--;
if (lz->conn == 0) {
ngx_rbtree_delete(ctx->rbtree, node);
ngx_slab_free_locked(shpool, node);
}
}

将相应节点的conn恢复,若发现为0了,则将相应节点从rbtree中摘掉。

cleanup机制在nginx中使用十分场景,它提供了相当优雅的价值,为结束请求收尾处理提供了便利。

回到上面,如果没有在rbtree中找到相应key,则申请节点空间后插入rbtree,并初始化相应节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
n = offsetof(ngx_rbtree_node_t, color)
+ offsetof(ngx_http_limit_zone_node_t, data)
+ len;
node = ngx_slab_alloc_locked(shpool, n);
if (node == NULL) {
ngx_shmtx_unlock(&shpool->mutex);
return NGX_HTTP_SERVICE_UNAVAILABLE;
}
lz = (ngx_http_limit_zone_node_t *) &node->color;
node->key = hash;
lz->len = (u_char) len;
lz->conn = 1;
ngx_memcpy(lz->data, vv->data, len);
  • 小结:limit_conn的算法就是这样,从代码中分析可以看出,limit_conn的限速是一个存量的限速状态。

limit_req

下面分析稍微复杂一些的limit_req限速:其算法和典型的漏桶算法限速有些类似。在此之前先了解下什么是漏桶算法:

image.png

一个形象的解释是

  • 水(请求)从上方倒入水桶,从水桶下方流出(被处理);
  • 来不及流出的水存在水桶中(缓冲),以固定速率流出;
  • 水桶满后水溢出(丢弃)。
    这个算法的核心是:缓存请求、均匀处理、多余请求直接丢弃。

有了对算法形象的理解,再结合代码看nginx是如何实现该算法。

1 和limit_conn类似,首先判断该location是否开启了limit_req

1
2
3
4
lrcf = ngx_http_get_module_loc_conf(r, ngx_http_limit_req_module);
if (lrcf->shm_zone == NULL) {
return NGX_DECLINED;
}

2 计算限速变量、查找

1
rc = ngx_http_limit_req_lookup(lrcf, hash, vv->data, len, &excess);

这里的返回值有几种情况:OK、BUSY、AGAIN、DECLINE
从简单的说起
DECLINE表示未查到相应节点,此时申请节点空间、初始化赋值后直接插入rbtree、queue

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
35
36
37
if (rc == NGX_DECLINED) {

n = offsetof(ngx_rbtree_node_t, color)
+ offsetof(ngx_http_limit_req_node_t, data)
+ len;

node = ngx_slab_alloc_locked(ctx->shpool, n);
if (node == NULL) {

ngx_http_limit_req_expire(ctx, 0);

node = ngx_slab_alloc_locked(ctx->shpool, n);
if (node == NULL) {
ngx_shmtx_unlock(&ctx->shpool->mutex);
return NGX_HTTP_SERVICE_UNAVAILABLE;
}
}

lr = (ngx_http_limit_req_node_t *) &node->color;

node->key = hash;
lr->len = (u_char) len;

tp = ngx_timeofday();
lr->last = (ngx_msec_t) (tp->sec * 1000 + tp->msec);

lr->excess = 0;
ngx_memcpy(lr->data, vv->data, len);

ngx_rbtree_insert(&ctx->sh->rbtree, node);

ngx_queue_insert_head(&ctx->sh->queue, &lr->queue);

ngx_shmtx_unlock(&ctx->shpool->mutex);

return NGX_DECLINED;
}

逻辑很清晰:先申请空间,若空间不够,则从queue中进行lru淘汰一些节点(其实是直接从队尾删除,访问时间最久的点放在队尾)。 初始化节点,插入tree,插入queue首。直接返回,将请求交给下一个handler处理。

返回OK: 说明没有超过相应限速值,直接放过;
返回BUSY: 超过限速值,且漏桶容量不够,直接503掉;
返回AGAIN:超过限速值,但漏桶容量够,进一步看是否需要delay/delay处理,如果设置了nodelay,那么效果和返回OK一样,立即放行,如果没有设置,那么需要delay处理,nginx实现dealy处理的方法是,将该请求放入timer中,将该请求可写事件加入timer树。

如果结合漏桶算法的场景,这里的桶是指每个key都会有自己的桶。

回到lookup中去,漏桶算法的思想在其中:(截取)

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
if (rc == 0) {
ngx_queue_remove(&lr->queue);
ngx_queue_insert_head(&ctx->sh->queue, &lr->queue);

tp = ngx_timeofday();

now = (ngx_msec_t) (tp->sec * 1000 + tp->msec);
ms = (ngx_msec_int_t) (now - lr->last);

excess = lr->excess - ctx->rate * ngx_abs(ms) / 1000 + 1000;

if (excess < 0) {
excess = 0;
}

*ep = excess;

if ((ngx_uint_t) excess > lrcf->burst) {
return NGX_BUSY;
}

lr->excess = excess;
lr->last = now;

if (excess) {
return NGX_AGAIN;
}

return NGX_OK;
}

如果查找到了相应key,先将其插入queue头,更新访问lr->last。漏洞算法的核心在这一行:

1
excess = lr->excess - ctx->rate * ngx_abs(ms) / 1000 + 1000;

其中lr->excess表示该key还剩多少请求未被处理,其更新方式为 : 上一次遗留 - 在此段时间已经处理的个数(如果按照限定的速度来估算)。

若遗留的数据以及超过了桶的大小(lrcf->burst),那么返回busy,将拒掉请求。

如果excess为0,则表示可以放行请求。(桶内没有数据)

如果不为0,但也没超过桶大小,则会视nodelay配置情况进入延迟处理。

而在delay情况下,nginx如何结合timer进行延迟处理的?

1 首先计算需要delay该请求多久:excess * 1000 / ctx->rate

2 将可写时间加入timer,同时将请求的可读事件加入epoll

1
2
3
4
5
6
7
if (ngx_handle_read_event(r->connection->read, 0) != NGX_OK) {
return NGX_HTTP_INTERNAL_SERVER_ERROR;
}
r->read_event_handler = ngx_http_test_reading;
r->write_event_handler = ngx_http_limit_req_delay;
ngx_add_timer(r->connection->write,
(ngx_msec_t) excess * 1000 / ctx->rate);

解析:将可写时间加入timer好理解,当timer触发式将继续执行该请求。这里首先将可读事件加入epoll中,原因是若在此期间收到了客户端的fin包,需要将该请求终止掉。

在可写事件的handler中: 下面贴出完整的代码

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
static void
ngx_http_limit_req_delay(ngx_http_request_t *r)
{
ngx_event_t *wev;

ngx_log_debug0(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
"limit_req delay");

wev = r->connection->write;

if (!wev->timedout) {

if (ngx_handle_write_event(wev, 0) != NGX_OK) {
ngx_http_finalize_request(r, NGX_HTTP_INTERNAL_SERVER_ERROR);
}

return;
}

wev->timedout = 0;

if (ngx_handle_read_event(r->connection->read, 0) != NGX_OK) {
ngx_http_finalize_request(r, NGX_HTTP_INTERNAL_SERVER_ERROR);
return;
}

r->read_event_handler = ngx_http_block_reading;
r->write_event_handler = ngx_http_core_run_phases;

ngx_http_core_run_phases(r);
}

这种延迟处理请求的做法值得学习,忽略可读事件,调用ngx_http_core_run_phases再次进去请求的处理阶段。

总结

整个limit_conn/limit_req算法从源码上就分析完毕了,从中至少可以学习到以下知识:

1 shm的使用、组织、初始化

2 如何延迟处理请求(在C层面的实现)

3 漏桶算法在nginx中的实现

4 更加优雅地组织nginx模块。

谢谢

=====
仅是个人理解

第一章 基础前言

  • 本书将通过考察金融市场(例如债券市场、股票市场、外汇市场)和金融机构(也叫金融中介,如银行、保险公司、共同基金和其它金融机构)的运作,来探寻货币在经济中的角色。

  • 企业在高利率的经济环境中,可能会推迟新建工厂,从而对就业产生不利影响。

  • 债券市场可以帮助政府和企业筹集到所需要的资金,是决定利率的场所。

  • 金融市场和金融机构的关系:金融机构是金融尺长能够运行的关键所在,没有金融机构,金融市场久无法实现资金由储蓄者相有生产性投资机会的人的转移。

  • 银行:是(吸收存款和发放贷款的)金融机构,具体而言,又可以分为商业银行、储蓄、信贷协会、互助储蓄、信用社等。银行是经济规模中最大的金融机构。

  • 货币与经济周期:每次经济衰退前,都伴随着货币增长率的下降,说明货币供给的变动是经济周期波动的推动力之一。然而并非每次货币增长率下降都会出现经济衰退。

  • 货币与通货膨胀:物价水平和货币供给的走势都相当一致,着表妹,货币供给的持续增加是物价水平持续上升(即通货膨胀)的一个重要原因。

  • 货币政策:即对货币和利率的管理,中央银行负责货币政策的实施。

  • 财政政策:指政府对政府支出和税收的决策。预算赤字:在一年中,政府支出超过税收,预算盈余:在一年中政府支出小于税收。

(预算赤字可能导致较高的货币增长率、较高的通货膨胀和较高的利率 )
(即货币政策由央行实施,财政政策由政府实施)(本国嘛,你懂的)

  • 外汇市场: 跨国转移的资金必须由流出国的货币兑换为流入国的货币。

  • 汇率的变动会影响进口成本,对消费者的影响是直接的.

  • 本国汇率下跌,意味着外国商品变贵,出国度假(服务)变贵,会减少本国消费外国商品和服务,增加对本国消费商品和服务,提振本国就业市场。

  • 本国汇率上升:意味着外国商品变便宜,会增加本国消费外国商品和服务,减少对本国消费商品和服务,冲击本国就业市场。

第二章 金融机构和金融市场

  • 苹果公司发明了更好的ipod,需要资金将其投放市场,此时金融市场和金融中介就登场了。同样,地方政府,相当于一个苹果公司,需要资金修建公路和建设学校,但地方税收无法
    满足,于是向金融市场和金融机构融资,即向银行融资,等收过路费再还给银行。中国的债务问题就是,地方政府借了银行的钱建桥修路后,却收不回过路费,换不了银行的钱。

  • 融资由两种主要手段: 通过金融市场直接融资,通过金融中介机构间接融资。
    福特公司通过发行债券或者股票进行直接融资。也可以向银行贷款进行间接融资。

  • 资本:可以用来创造财富的财富,他可以是金融财富也可以是物质财富。金融市场有助于资本的合理配置,从而增加生产和提高效率。
    在经济危机期间,金融市场遭受严重破坏,经济发展受阻。

  • 一级市场:公司或者政府机构将其新发行的股票货债券等销售给最初购买者的金融市场。
    二级市场:交易已经发行的证券的金融市场
    一即市场并不为公众熟知,因为将证券销售给最初购买者的过程并不是公开进行的。
    投资银行是一级市场上协助首次出售的重要金融机构:承销这些证券。
    纽交所、纳斯达克都是著名的二级市场。介绍金融市场的书籍会把重点放在二级市场,也应该放在二级市场。

  • 货币市场:根据交易证券的期限长短来区分,货币市场是交易短期债务工具的金融市场 。(如国库券,可转让存单等)
    资本市场:交易长期债务工具的金融市场 (如股票,抵押贷款,企业债券,地方政府债券

  • 金融中介机构:间接融资。
    当企业试图为其业务活动寻求资金来源时,它们通常会求助于金融中介机构,而非证券市场。 原因可以从三个方面来回答:交易成本,风险分担,信息成本。

  • 金融中介机构分类:
    存款机构(主要指银行,包括商业银行、储蓄和贷款协会,信用社)
    契约型储蓄机构(包括人寿保险,意外保险,养老基金)
    投资中介机构(包括财务公司,共同基金)
    需要了解它们的资产和负债类型。

第三章 什么是货币

  • 本书所指的货币:在产品和服务支付以及债务偿还中被普遍接受的东西,它与收入和财富是有区别的。

  • 货币的交易媒介功能:货币是经济社会中至关重要的东西:由于它可以降低交易成本,鼓励专业户和劳动分工,因而是经济顺利运行的润滑剂。

  • 货币的记账单位功能:我们不会说一件衣服值3股,而会说一件衣服300元

  • 货币的价值存储功能:货币作为价值储藏手段并非独一无二的,任何资产包括货币、股票、债券、土地、房屋等都可以用来储藏餐饭,许多资产比货币更有价值储藏功能。
    但为啥人们还愿意持有货币呢?流动性:即某一资产转为交易媒介的便利层度和速度。由于货币本身就是交易媒介,所以具有最高的流动性,而其他资产转为交易媒介(即货币)都需要支付交易成本,(比如佣金啥的)。
    货币作为价值储藏手段的优劣要取决于物价水平。

  • 为何电子货币没能够彻底取代纸质货币:1 首先,需要花费加高的成购置所需的计算机、读卡器、通信网络等,2 电子支付带了安全性和私密性的问题,未经授权的黑客闯入了某个计算机数据库,并更改了其中存储的信息,要防止这种犯罪行为并非易事,需要开发一个全新的对付安全问题的计算机科学领域。3 利用电子支付方式还有一个后果,会留下有关购买习惯的大量个人信息,人们担心政府、雇主和商户会得到这些数据,从而入侵我们的私人领域。

  • M1 : 最狭义的货币指标,包括流动性最强的资产,即通货、活期存款等。且其通货中之爆款非银行公众持有的纸币和硬币,不包括ATM和隐患金库中的现金。
    M2: 在M1的基础上增加了一些流动性不及M1的资产,如定期存款等,。
    在2008年,美国M2的数量是M1的数量的4倍左右。

  • M1种的通货:人均持有的现金达2000美元。这个数目是很惊人的,通货体积大,易于被窃,并且不支付任何利息,因而大部分人不可能持有这么多美元通货。但这些美元都在什么地方呢?谁在持有这些美元呢?
    1 犯罪分子持有大量美元,因为现金交易不易被追踪。
    2 企业愿意持有大量美元,因为现金交易很难被追踪,可以避免申报需要交税的收入
    3 外国人由于担心本币价值贬值,不愿意相信本币,为了规避通货膨胀风险。例如俄罗斯人不信任卢布,大量持有美元。一半以上的美元都在海外

第四章 理解利率

  • 你决定要购买房子,想银行借入10W的抵押贷款,你从银行的贷款利率为7%,要想在20年换完,每年还多少钱?
    10W=(x/1+7%)+(x/(1+7%)^2) + (x /(1+7%)^3) +….+(x/(1+7%)^20))
    解答得x=9439.29 元。

  • 利率最精准的定义:资产的到期收益率。 (以后说道利率,都是说的是市场利率)

第五章 利率行为

  • 通过债券的需求供给曲线和水果市场的需求供给曲线,感觉到债券也是一种类似于水果的商品。
    只不过水果市场的均衡是水果的需求和供给数量的均衡,而均衡点的位置就是水果的价格。即买房需要多少数量的水果,卖房需要卖出多少数量的水果。
    而债券市场的均衡是债券的价格的均衡,而均衡点的位置就是利率。
    感觉利率就是债券的真实价值,和价格代表水果的真实价值一样。(中间绕了一层)
    当债券价格高,就代表水果供给多,需求相对过多,会导致债券价格下跌(水果供给变少),利率下跌(水果价格下降)
    当债券价格低,就代表水果供给少,需求相对过少,会导致债券价格上升(水果供给变多),利率上升(水果价格上升)
    债券价格的高低,相当于水果的供给多少。(价格高代表水果供给多,那么利率下跌)
    反过来:
    利率低,代表水果价格低,水果供给多,即债券价格高。
    利率高,代表水果价格高,说过供给少,即债券价格低。

  • 上面是债券和利率的关系,使用债券的供给和需求来讨论利率(市场利率)的变化情况和变化关系)
    下面是货币的供给和需求来说明货币和利率的关系:利用凯恩斯的流动性偏好理论来解释

ls->handler钩子

ls结构体代表的是一个监听结构体,它的handler成员会随着nginx在哪一层来解释tcp流有所不不同

  • http流
    如果作为http来解释tcp流的话,其ls->handler为ngx_http_init_connection
    (注:ls->handler的设置在解析http{}块的最后几步ngx_http_optimize_servers中设置的回调,包括创建ls结构体).
    每个listenfd都会有一个ls结构体,其ls->handler为其建立连接后初始化的钩子,即在accept后被调用,具体是在ngx_event_accept中.
    每个lisenfd也会有一个c,rev,wev结构体与之对应,而其对应的rev的handler:rev->handler被设置为ngx_event_accept.这个过程在ngx_event_process_init中可以看到.
    在epoll_wait的时候,由event_list[i].data.ptr指向了c,继而会找到rev、wev,然后待用rev->handler,这里就是ngx_event_accept了.
    在ngx_event_accept中只是传递了ev结构体,那么如何找到了ls呢?答案就是ev->data会指向c,而在ngx_event_process_init中,每个listenfd对应的c的c->listening都会指向ls结构体,这样就找到了ls.
    实际上,在ngx_event_accept中,新生产的c的c->listening也指向了被其accept的ls结构体.:)

  • stream流
    对于四层流而言,在解析stream块时,ngx_stream_optimize_servers将ls->handler设置为乐ls->handler=ngx_stream_init_connection.

事件handler

今天看了下框架层面的代码

  • 模块的init_process是在worker中执行的,所以是每个worker执行,而init_module是master的时候执行的,注意区分。
  • 在Nginx中,连接c才有读写事件,c->rev/wev,请求没有这个概念,我们说读事件/写事件,都是说在该连接上发生的事件,而请求只是在连接之上的抽象,它没有读写事件的资格
  • 在ngx_conf_parse中发现,cycle是main中的一个局部变量,同时注意区分cycle,cycle->conf_ctx(就是那个著名的void*),conf (ngx_conf_t,经常看到的cf)。
  • 所有的事件钩子的参数都只有一个ev,而ev的data字段会指向其c字段,而c字段的data字段会指向r字段,这样就把c、r都找到了.(这里注意空闲的c的data字段是指向下一个空闲的c的)
  • 注意区分事件handler和IO-handler的区别:事件处理handler是ev->handler,而IO-handler是存在c上的c->read/write,c->read=ngx_unix_recv,c->write=ngx_uint_send等等。而在ev->handler中可以使用它们来做IO。这中做法好棒,可扩展性强.

代码层面

  • 最近写创建共享内存时候发现的,ngx_shared_memory_add,如果未赋值shm->init,shm->data,会导致-t检查不通过:
1
2
3
4
5
6
sudo ./nginx-1.6.2/obj/nginx -p `pwd` -c conf/nginx.conf -t
nginx: the configuration of file /home/ke/test/web_server/nginx/try/conf/nginx.conf syntax is ok
```
/*后面一句successfule居然就没有啦,加上后就可以了*/
shm_zone->init=ngx_http_xxx_init_zone;
shm_zone->data=shmctx;
  • 添加一种和event、http同等级的模块,写完后,发现配置指令错误:
1
2
3
4
5
6
7
8
9
10
11
sudo ./nginx-1.6.2/obj/nginx -p `pwd` -c conf/nginx.conf -t
nginx:[emerg] "xxx" directive is not allowed here in /home/ke/test/web_server/nginx/try/conf/nginx.conf:17
nginx:configuration file /home/ke/test/web_server/nginx/try/conf/nginx.conf test failed
配置示例如下:
events {
worker_connection 1024;
}
mylevel {
xxx;
}
后来发现问题是block钩子没有解析完导致的,把ngx_xxx_block解析写完成即可

cmd中的offset

以前在写cmd时候,之前以为offsetof(xxx,xx) 写了之后,就会对最后调用set函数时的conf有影响,即已经帮你找到了最终需要操作的字段。
现在经过验证,其实offsetof字段没有参与conf计算,你需要根据offsetof来自己确定最终的字段。如下:

最后经过debug调试:

打印出来的conf都是同一个位置,即loc_conf结构体,而并没有去在帮你找到具体的字段,需要你自己根据offsetof来找。parse_conf函数也证明了这点,即在拿到conf的时候并没有offset的参与:

那么既然是这样,为何还要设置offsetof字段呢?反正可以通过conf来得到结构体,然后利用结构体就可以取得各个字段了。
其实,如果是自己写的set函数,这个offsetof是没有啥作用的,但是在采用预定义的那些set(如ngx_conf_set_flag_slot等),这时候就必须指定offsetof了,因为预定义的set使用了offsetof直接拿到具体的字段。

content模块发送数据

在调用ngx_http_output_filter发送数据时,有时候会发现请求命令(如curl等客户端)迟迟不返回,这时候可用检查下buf的标志为buf->last_buf是否置位了。

http模块的几个钩子的执行顺序

在阅读C模块时,老喜欢忘记ctx中的几个钩子执行顺序,这里做个记录:
|
create_main
|
create_srv
|
create_loc
|
preconfig – 一般在此处设置模块需要暴漏的变量
|
ngx_conf_parse – 解析http{},这样cmd中的钩子会全部执行
|
init_main
|
merge_srv
|
merge_loc

init_locations –创建location的三叉树
|
init_phase – 创建phase表
|
post_config –一般这里会嵌入模块在各个阶段的钩子,或者filter钩子
|
optimze_server –初始化所有的http listenfd,包括挂接ls->handler

其实所有的流程都在ngx_http_block中一目了然,这里方便做个记录

阅读dyups模块的笔记

周末抽空阅读了下dyups早期的代码,这时候还只能在单worker中工作,所以以下分析主要是以这个版本的代码为基础的)

https://github.com/yzprofile/ngx_http_dyups_module/tree/0c169da7dceeeecf956a84aa25ba1dcd108ec98e
dyups模块与upstream模块结合得很紧密,主要是通过dyups暴漏出的几个API来操纵upstream模块里的数据结构体。关于获得操作比较易懂,通过获取upstream模块的数据然后展示出来,重点是DELETE和UPDATE的逻辑。

1 DELETE

  • 删除一个upstream块的核心逻辑是:将upstream模块的某个ngx_htt_upstream_srv_conf_t结构体下的所有server的状态都置为down us[i].down=1,然后再次初始化调用了uscf->peer.init_upstream(默认情况下该钩子为ngx_http_upstream_init_round_robin).
    (其实这里不太理解为啥要做这个调用,我实践了下,感觉不做的话也没啥影响) 这里需要注意的是,uscf->peer有两个与初始化相关的钩子 init_upstream和init:
  • 前一个钩子是在upstream模块的init_main_conf中配置文件解析后被调用的,和dyups模块这里调用的效果一样,初始化upstream模块里的数据
  • 后一个钩子init是在每个请求(那些需要xxx_pass的)将要转发时被调用的,之所以是在这里而不是在初始化阶段,在《Nginx开发从入门到精通》里面解释的很好:
    “nginx收到一个请求后,发现如果要访问upstream,就会执行对应的peer.init函数,这是在初始化配置时设置的回调函数(在调用init_upstream也即ngx_http_upstream_init_round_robin中时被设置)。这个函数的作用时构造一张表,当前请求可以使用的upstream服务器被依次添加到这张表中。之所以需要这张表,最重要的原因是如果upstream服务器出现异常,不能提供服务时,可以从这张表中取得其它服务器进行重试操作,此外这张表也可以用于进行负载均衡的计算。之所以构造这张表的行为放在这里而不是在前面初始化配置阶段,时因为upstream需要为每个请求提供独立隔离的环境。”
  • 这样就可以解释为何dyups的DELETE仅仅将其us[i].down置位就能到达“删除”某个upstream块的效果了,而每个请求在prox时调用init来构造可用节点表时,发现每个节点都down,所以这个请求就502了.

2 UPDATE(POST)

  • 增添

背景介绍

细化分流和降级的项目依赖redis存放分流和降级规则,为了保证redis的高可用,需避免其单点故障和数据丢失。整个系统的架构图如下:

在管理端,通过多个管理实例来避免管理端的单点问题,将策略规则写入redis。在业务端,多个Nginx代理从redis中读出策略规则并缓存在本地的lua缓存中,结合用户请求,最终将其分流到不同的上游集群。

尝试解决

1 当某个redis挂掉后,其上面的数据需要有备份,不能丢失。
2 整个redis集群须提供对外统一的访问接口。
为此考虑了以下几种解决方案:
1 主从模式
2 代理模式
3 集群模式

主从模式

若用redis主从模式,其接入架构图可以为这样:

1 在所有redis中有一个主库,其余均为从库,所有从库始终保持和主库数据同步。
2 所有的管理机均将策略写入主库,各个业务机将从各自的从库中读人。可以将从库于业务机部署在同一主机提高读取速度。
该方案的特点:
1 实现简单。redis主从关系设置方便,且有现成的接入单个redis的lua接口。
2 容灾性差。所有管理机都依赖于唯一的主库,造成单点问题(虽说系统依然可以正常处理业务),且业务机访问单个redis从库,也造成单点问题。
3 。。。

代理模式

为了让多个redis对外提供统一的访问接口,尝试采用redis的代理服务。根据调研,目前业界有两种成熟的解决方案:twemproxy和codis。其架构图如下:

通过代理,所有接入redis的请求都由代理来处理。
该方案的特点:
1 统一接口。代理对外提供了所有访问的统一接口,且可以兼容访问单个redis实例的lua接口
2 容灾较差。代理将数据写入或从redis集群中读出是采用分片的方式,从中选择某个redis来进行写或读。
如果某个redis挂了,其上面的数据依然会丢失。
针对2进行的改进是:对每个redis做主备,其架构如下

这样虽然保证了数据不丢失,但主库挂掉时,从库需要手动升级为主库,运维麻烦。

集群模式

集群模式是redis在3.0后的分布式解决方案,可以很好地满足上面的两个要求:1 对外提供统一接口 2 数据一致性且不丢失。其接入架构图如下:

集群中的所有redis都互相传递消息,且按照分片的方式进行存储数据。在本节介绍redis集群的部署方式后,在下一节介绍redis集群原理。

集群部署

1 运行多个处于集群模式的redis实例
这里以6个为例。要使redis处于集群模式,需在配置文件里面添加:

cluster-enabled表示是否以集群模式运行。其它字段意义可参考官网:http://redis.io/topics/cluster-tutorial

然后运行6个实例

其客户端监听端口以此为6380,6381,6382,6383,6384,6385。

2 安装依赖

上面的6个实例虽然处于集群模式,但是各自为战,并没有构成真正意义上的集群,为此需要将其构成集群。
在redis包中有将redis实例构成集群的管理包,但运行其管理包需要安装依赖环境,包括ruby,gem等


3 构成集群

其中redis-trib.rb是构成集群的命令,create表示创建集群,–replicas 1表示集群中的每个主节点都将附带一个从节点。

4 安装完成

在集群中的6个节点中有3个主节点是负责真正写入和读出数据的,三个从节点将为其主节点备份,并在必要自动时候升级为主节点(没错,是自动升级,下一节会介绍其原理)

PS:
1 红色的为主节点,该集群也就表示由这三个主节点构成,其从节点只是为其备份数据,并不参与集群的数据的读写。
2 每个节点(包括从节点)都会感知集群中的每个节点的状态,且每个主节点都可以设置多个从节点。
3 主节点间的数据不冗余(即每个主节点间的数据都不是备份关系),而是经过分片处理数据。每个节点处理一部分“槽位”,redis集群方案共设置了16384个槽位。

redis集群

节点

开始时,集群中的每个节点都是单个的集群,它们之间通过“握手”来构建整个系统集群,以上面的三个主节点组成的系统为例,构建集群的过程如下:

首先6380节点和6381节点相会握手成功后,其会构件为一个集群,然后6381和6382握手成功后,所以的节点都加入了同一个集群。
在集群中的每个节点和在普通模式下运行的节点所代表的数据结构不同:clusterNode.

每个节点中都有感知集群中其它节点的信息。

槽指派

Redis集群是通过分片方式来保存数据的:整个集群的实现一共分为16384个槽(slot),这些槽在空间上组成环状。集群中的每个KV对都属于这16384个槽中的一个,且每个集群中的主节点可以处理0个或者多个槽,每个槽只属于一个主节点。
当16384个槽都被处理时,我们称集群处于上线状态(OK),可以通过每个主节点的clusterstatus结构体的state来标示。否则,任意一个槽没有被集群节点覆盖到,那么集群将处于线下状态(Fail)。
处于上线状态的集群中的每个主节点都感知每一个槽分别被哪个主节点处理的:

如上所示:槽0-5000被节点6380处理,5001-10000被节点6381处理,10000-16383被节点6382处理。
这样每个节点都能迅速掌握哪个槽被哪个节点处理。

MOVED错误

当集群接受到redis命令时,首先计算该键值对应的槽位,然后判断该槽位是否被当前节点所处理,所是,则直接处理,否则将返回MOVED错误给客户端,客户端会通过MOVED错误的信息转向正确的主节点进行处理。其答大体步骤如下:

PS
1 键值与槽位的关系:
通过键值来计算槽位:CRC16(key)&16383 。即先计算键值的CRC16校验和,然后得出一个介于0~16383间的整数做为key的槽位。
2 MOVED错误只有支持集群模式的客户段才能够正确处理(继续访问正确的主节点),否则将打印错误。

复制故障转移

如果集群中只包含主节点,那就大大降低了它的高可靠性,一般集群中的主节点都会有从节点。从节点主要用于在相应的主节点发生故障后代替成为主节点,实现故障转移,达到高可靠的特点。而这个过程在集群中是全部自动完成的。
1 集群中的每个主节点都感知各个主节点的从节点。
2 若集群中超半数的主节点认为某个主节点故障,则该主节点被下线。
3 某个主节点被下线的消息会被广播到所有节点(包括从节点)
4 当从节点(们)发现自己的主已经被判定为下线,那么这些从节点会向各个主节点发送选举请求,请求各个主节点支持自己成为主节点。
4 只有每个主节点有投票选举权,且只有一票。
5 若某个从节点已经得到过半主节点的投票,那么会将自己升级为主节点,同时接管原来主节点所处理的槽位,并向集群广播自己被选举成功的消息。

接入细节

想让上面的Redis集群接入到分流和降级系统中,需要有专门的Lua接口来接入redis集群。根据支持集群模式的C客户端和支持普通模式lua接口,可以构造支持集群模式的lua接口。

推荐:https://github.com/cuiweixie/lua-resty-redis-cluster
PS:这个客户端有少许bug:
1 需要把local关键字加在redis实例前面,参考其PR
2 在高流量情况下,由于超时重试机制,导致一个请求创建了最多18个redis实例和超时timer,把LUA虚拟栈跑爆了,这个需要改造下

前言

Nginx的高性能得益于其优秀的网络架构模型。为了充分发挥多核CPU的优势,让每个进程都“亲缘”一个CPU,减少上下文切换带来的损耗,同时也需要解决多个进程监听同一个端口带来的各种问题,本文将简要描述下Nginx的网络架构的演变过程。

网络模型

模型一

Nginx是单主(master)多从(worker)模式的进程架构,Master进程在解析配置文件的时候,对于每个监听的端口,将创建一个监听socket套接字,为了描述,这里假设在配置文件中开启了80和443两个端口。其网络模型如下:

在解析完配置文件后,master进程开始创建子进程,这些子进程会进程master的资源,当然包括套接字等,如果开启4个worker,则其网络模型如下:

这时候,如果有请求来临,内核会唤醒所有的进程,这就是惊群现象。(先不谈Linux内核是否解决了惊群现象,Nginx作为一个跨平台的服务,必须从自身解决这个问题)
传统的网络服务(如apache等)会采用每个请求分派一个线程来处理,而Nginx采用的IO多路复用机制(有时候也叫做事件驱动),如在Linux平台的epoll,BSD平台的kqueue等,配合非阻塞的soket API,充分利用了CPU,提升了网络服务质量。下面会以Linux平台下的epoll为例,说明Nginx是如何解决惊群问题的。

1 各个worker进程会创建自己的epoll句柄(不包括master)
2 接着会判读,管理员是否开启了惊群锁且worker进程的数量大于1。
a,惊群锁默认是关闭的,如果管理员显示开启了accept_mutux off,则会禁用,Nginx就不会解决惊群问题。
b,如果worker的进程数量小于2,那么不存在惊群问题,Nginx也会不解决惊群问题。

3 如果惊群生效,则每个worker首先不会去把监听套接字描述符加入自己的epoll系统,而是先去抢一把自旋锁,即对所有监听套接字的“控制权”。

4 抢到锁的worker进程,会将所有的监听套接字都加入自己的epoll中,而没有抢到worker会首先删除自己epoll中监听的监听套接字(如果有的话)。这样当有新请求来到时,只会有一个worker被唤醒,从而解决了惊群的问题。

其流程图大约如下:

小结:
1,在请求量不是特别大的情况下,Nginx这种解决惊群现象的手段提升了网络服务质量,避免多个进程无谓的被“唤醒”去accept请求失败而导致的损耗。
2,然后现在在很多情况下证明:当并发请求量过大时,这种依靠抢锁机制解决惊群的手段,会导致处理请求的效率下降,所以现在较多的建议是关闭accept_mutux锁,让Nginx不解决惊群。

模型二

在第一种模型下,通过抢锁来保障每次新请求到来时都只会有一个worker去执行accept,避免其它worker的无谓消耗。这种情况在高并发场景下受到了质疑,事实上,在高并发情况下,关闭惊群锁而不让Nginx处理“惊群”反而会提升处理效率。下面举一个例子说明:

试想,有一群小鸡,你撒谷粒给这群鸡吃。
a,一粒粒撒的时候,如果不加处理,每个鸡都会跳起来,但最终只有一只鸡能够吃到这粒米。
所以在一粒粒撒的时候,需要有锁,不能让每个鸡都跳起来,这样浪费它们的精力,必须要让它们遵守秩序,一个个来(加锁)

b,然而,如果你撒了一大把谷粒,这时候还让它们一个个来,这样是很不合理的,所以,在撒大把谷粒的情况下,这些鸡全部跳起来抢食才是科学的,这样才能更加快速地消耗掉这些谷粒。(不加锁)。
上面的小鸡就代表worker进程,谷粒代表高并发的请求,虽然比喻有些粗糙,但也能够说明问题。

所以,在高并发场景下,关闭惊群锁,每个worker都把所有的监听套接字加入到自己的epoll中去,让它们都试图去accept新的请求,这样做能够提升处理请求的效率。
而且,从高版本的Nginx开始,惊群锁也默认置为了关闭状态。
官方对此作了压力测试的对比,具体可见如下:https://www.nginx.com/blog/socket-sharding-nginx-release-1-9-1/

(先忽略reuseport,比对default和accept_mutux off的两种情况)

模型三

在Nginx1.9.1版本中,引入了新的socket网络选项,SO_REUSEPORT.
首先解释下这个选项:当socket设置了这个选项,可以让多个设置了这个选项的socket绑定和监听同一个端口(准确滴说是一个ip:port对)

1 default时候的网络模型:

在原始模型中,所有的worker通过master的fork调用,都公用一个socket来监听80端口(或其它端口)

如果开启SO_REUSEPORT特性,如下:

1
2
3
4
5
	server {
listen 80 reuseport;
server_name localhost;
..
}// reuseport是listen指令后的选项。这和Tengine实现的在event块中的开启这个选项有所不一样。

则master在解析配置文件的时候创建监听套接字会有不同的动作:
它会按照worker的数目 n,对于每个ip:port对,都会克隆出多个监听socket,如下:(以4个worker为例)

可以看到,对于同一个端口,master建立了4个监听套接字,这四个监听套接字都bind到80端口。(需要注意的是,如果套接字不设置SO_REUSEPORT属性,那么当多个套接字bind到同一个端口时,会报错失败。当然这里每个套接字都设置了SO_REUSEPORT属性)

然后经过fork出worker进程:

然后,每个worker会根据自己的ID顺序号,将“属于自己”的监听套接字加入到自己的epoll中。(这段是翻看源码才能获知的,一开始怎么都想不明白~~)
以监听80端口的那四个套接字来说,worker1进程只会把套接字1加入自己的epoll中。如下图所示:

这样,每个worker都再也不用去抢监听锁讲监听套接字加入自己的epoll系统中了,现在每个worker的epoll中始终都有着属于自己的监听套接字。可以对比一下:

注意:在这种模式下,若某个端口有请求到来,是内核来决定将请求分发到那个监听套接字上,而且这种分发一般都是较为均衡的。

再来比对一下官方的测试数据:

其QPS和平均延时以及延时标准差都明显降低了。

总结

1 第一种默认的抢锁模式:
在大多数请求量适中的环境中都表现很优秀,而且从Nginx内部做到了解决惊群问题,具有跨平台的优势。而且由于解决了惊群问题,极大降低了CPU的负荷。

2 第二种“惊群”模式:
在第二种模式下,Nginx任由“惊群”现象的产生,让每个worker都尽自己的力量去抢到更多的请求。第二种模式在高并发场景下非常有效,但由于每次请求都会让所有worker去争抢请求,必然增大了CPU的负荷。

3 第三种reuseport模式:
在这种情况下,每个worker都有”专属于“自己的监听套接字,不用去挣抢锁,完全由内核将请求分发到各个套接字上面,做到了高效且降低CPU的负荷的特定。

下面是官方的对比:

模块钩子嵌入

在处理请求时,Nginx是分为11个不同的阶段来完成的。在Nginx中,模块对请求进行操作的唯一途径是在这11个阶段中嵌入自己的钩子函数。

数据结构支持

嵌入范例

HTTP类型的模块嵌入钩子的过程是在解析配置文件的过程中完成的。通常会在模块上下文的ngx_http_post_config的地方插入自己的钩子,如ngx_http_access_module这个模块:

这个模块就在ACCESS阶段嵌入了一个自己的钩子:ngx_http_access_handler. (注意:也可以有模块在不同的阶段嵌入多个钩子或者同一阶段嵌入多个钩子,这都是可以的)
这些钩子的存放位置就在cmcf->phases这个数组中。

可以看到,这个结构实际上是一个二维数组的形式,即不同阶段的钩子都是分开存放在不同的一维动态数组中的。

钩子布局

为了对HTTP的钩子嵌入有直观的认识,下面是一个常规配置中其钩子的情景图:

上图显示了常规情况下的钩子布局情况:
1,一共分为了11个阶段,“理论上”请求的处理过程是严格按照这个顺序来执行的。
2,并不是每个阶段都必须要有钩子,如上面的几个阶段是没有嵌入钩子的
3,每个阶段理论上可以嵌入任意多的钩子数量
4,第三方模块能够嵌入的阶段有限:0,1,3,5,6,9,10。而其它阶段(2,4,7,8)的钩子是由HTTP框架来嵌入的。

运行时“变身”

一维钩子数组

上面的钩子布局是由配置文件直接解析后生成的,但在处理http请求时,并不是按照上面的二维钩子数组来处理的,而是将其变成了一维数组。即运行时是以一维数组的形式来调用各个钩子的。
首先在cmcf结构体中除了cmcf→phases数组(即上面的那个二维数组)外,还有一个结构体cmcf->pahse_engine也和钩子函数的布局有关:

cf->phase_engine的handlers字段是一个一维数组,它里面的内容由cmcf→phases二维钩子数组转换而来,它的存放的元素类型为ngx_http_phase_handlers_t结构体。
对于每个在二维数组中的钩子,都会在这个一维的handlers数组中对应着ngx_http_phases_handlers结构体(也即每个钩子都会有check,handler和next字段对应)。
在将二维钩子数组转换为一维钩子数组之前,需要对这个结构体的三个字段做简单的描述:
check: 一个包裹函数,每个钩子都会有一个check,且同一个阶段的所有钩子其check都是一样的,最重要的是,nginx从不直接调用钩子,而是调用其check,然后由check来调用钩子。
handler: 包裹的钩子函数,也即上面的钩子。
next:代表的含义相当于index,一维钩子数组下标。next表示从当前钩子所处阶段的下一个阶段中的第一个钩子在这个一维钩子数组中的下标,常用来快速跳到下一个阶段。
如果从上面的二维钩子数组转换为一维钩子数组来看,情景图如下:

二维钩子数组中,每个阶段的钩子都按顺序被放在了相邻的一维钩子数组中.
补充说明:
1,r寻找到正确的server块(即r->srv_conf的正确指向)是在是这十一个阶段之前(在处理头部ngx_http_process_request_header的ngx_http_find_virtual_server中。也即在十一个处理阶段的前面。)
2,r寻找大正确的location块(即r→loc_conf的正确指向)是在FIND_CONFIG阶段。
3,r→main_conf肯定是唯一的(:))
4,在POST_REWRITE阶段(该阶段不能挂接自己的钩子,只会执行check函数)的next为1,暗示如果进行了rewrite跳转,那么下一个阶段会跳到FIND_CONF阶段去再次找寻到正确的location块。(通常如果没有rewrite的话,那么即phase++,会进入PRE_ACCESS阶段。)

请求处理过程

在请求r的结构体中有一个字段为phase_handler,其类型为整型,这个整型为被赋值为一维钩子数组中的下标,由它来决定了请求在各个阶段的执行顺序或者跳转顺序。

前面说过,在处理请求时,并不是直接调用各个钩子,而是调用了每个钩子的包裹函数-check函数:

上面这段代码就是钩子函数被调用的核心逻辑:
以r->phase_handler为下标,调用相应的check函数,具体的check函数实现逻辑决定了r→phase_handler的变化,以及check函数的返回值决定了是否将控制流程交付给事件处理模块
即如果某个check函数返回了 NGX_OK,那么http模块就将控制流交付给了事件处理模块。
而check函数的返回值又和具体的钩子返回值有关,所以为了能够了解请求的执行顺序或跳转顺序,需要知道check函数对r→phase_handler的影响以及各个check函数的返回值。

各阶段顺序详解

check包裹函数

下图总结了各个阶段的不同check包裹函数,其中有些阶段共用了一种check函数:

有三个不同的阶段(POST_READ,PREACCESS,LOG)共用了check函数:ngx_http_core_generic_phase. 两个不同阶段(SERVER_REWRTIE,REWRITE)共用了ngx_http_core_rewrite_phase.其余都是各自有check函数。
而开发者需要关注的check只有4个(因为只可以嵌入的7个阶段中):

下面小节会逐步介绍它们中实现的逻辑是如何影响钩子的执行顺序的。

ngx_http_core_generic_phase

有三个阶段都共用了此check方法,如果要在post_read,preaccess,log阶段嵌入自己的钩子,那么必须对这个check有了解。
1 首先会调用模块嵌入的钩子,即handler. (当然第三方模块实现的钩子函数必须是非阻塞的),根据handler的返回值,它会有4中不同的逻辑。
2 若handler返回NGX_OK, 意味着当前阶段以及执行完毕,那么需要跳转到下一阶段的第一个钩子,即将r→phase_handler赋值为next,即使该阶段还有其它钩子,那么也将忽略不执行。同时check方法返回NGX_AGAIN.(返回AGAIN是保留了HTTP框架的控制流的)
3 若handler返回NGX_DECLINED,则会执行下一个钩子(举例来说,如果当前阶段有多个钩子,那么会继续在当前阶段执行下一个钩子,若该阶段只有这一个钩子,那么会流转到下一个阶段执行钩子),它将r→phase_handler++。同时check返回AGAIN(保留控制流权)
4 若handler返回NGX_DONE/NGX_AGAIN,那么表示该handler没有处理完,需要多次调度才能完成(例如遇到了阻塞条件或者超时),这时候需要将控制权交出去,且r→phase_handler保持不变以便epoll在此触发时会继续调用此钩子。这时候check返回OK,交付控制流权给事件模块。
5 若handler返回其它值,表示执行遇到错误,需要结束这个请求,调用ngx_http_finalize_request.
故实现自己的钩子时需要根据逻辑确定返回值,进而影响到check的动作。

ngx_http_core_rewrite_phase

两个重写URL的阶段(server_rewrite,rewrite)共用了这个check。其逻辑和generic很相似
1 若handler返回NGX_DECLINED,则会执行下一个钩子(举例来说,如果当前阶段有多个钩子,那么会继续在当前阶段执行下一个钩子,若该阶段只有这一个钩子,那么会流转到下一个阶段执行钩子),它将r→phase_handler++。同时check返回AGAIN(保留控制流权)
2 若handler返回NGX_DONE,那么表示该handler没有处理完,需要多次调度才能完成(例如遇到了阻塞条件或者超时),这时候需要将控制权交出去,且r→phase_handler保持不变以便epoll在此触发时会继续调用此钩子。这时候check返回OK,交付控制流权给事件模块。
3 若handler返回其它值(除DECLINED和DONE的其它值),表示执行遇到错误,需要结束这个请求,调用ngx_http_finalize_request.

PS:和上一个check包裹不同的是,这个check不会试图跳转到下一个阶段,即handler没有机会返回NGX_OK而得到正确处理。原因是,NGINX认为在重写URL这个点上,所有模块的优先级都是一样的,不应该存在先被调用的钩子会将其它钩子的执行权限“剥夺”的逻辑。

ngx_http_core_access_phase

(暂无)

ngx_http_core_content_phase(实际上的最后一阶段)

该阶段有两个特点:
a 这是第三方模块最经常嵌入的阶段
b 嵌入这个阶段的方式有两种(其它阶段都只有唯一的一种)
该阶段的作用是真正处理请求内容。
1 实际上该阶段是请求处理的最后一个阶段(LOG阶段是在请求结束的时候被执行的),那么就不会有跳转到下一个阶段的逻辑
2 其余阶段均为对所有的请求都有作用,而在CONTENT阶段,应该有这样的逻辑:即只对匹配了某个location的请求进行处理,这是该阶段的第二种嵌入方式
其实现方式如下:
1 在该location下的ngx_http_core_loc_conf_t结构体中赋值clcf→handler.
2 在请求r的字段中r→content_handler 中,将其赋值为的clcf→handler.
3 包裹函数的处理逻辑:

PS:
1 若clcf的handler被调用,则这一阶段的其它钩子将被忽略。
2 若content钩子返回非DECLINED,则意味着该请求被处理完成,结束。
3 由于该阶段是实际处理请求的最后一阶段,所以需要对下一个钩子是否存在做有效性检查。
4 一般在content阶段的钩子会构造响应头部和响应体,然后发送出去。(如常见的static_handler获取静态文件然后发送的module)