1,前言

Redis服务器负责与多个客户端建立网络连接,处理客户端发送的命令请求,在数据库中保存客户端执行命令所产生的数据。并通过资源管理来维持服务器自身的运转。
本文涉及到的其他知识点:
数据库与过期键
RDB持久化
AOF持久化
Redis客户端

2,命令请求的执行过程

一个命令从发送到获得回复的过程中,客户端和服务器需要完成一系列的操作,假如客户端执行以下命令:

redis> SET KEY VALUE
OK

从客户端发送SET KEY VALUE命令到获得回复期间,客户端和服务器共需要执行以下操作:

  1. 客户端向服务器发送命令请求SET KEY VALUE
  2. 服务器接收并处理客户端发来的命令请求SET KEY VALUE,在数据库中进行设置操作,并产生命令回复OK
  3. 服务器将命令回复OK发送给客户端
  4. 客户端接收服务器返回的命令回复OK,并将其打印给用户观看

2.1,发送命令请求

当用户向客户端键入命令时,客户端会将这个命令转为协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器。示意图如下所示:

例如,假设用户键入了SET KEY VALUE命令后

客户端会将这个命令转换为协议:

*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n

然后将这段内容发送给服务器

2.2,读取命令请求

当客户端与服务器之间的连接套接字因为客户端的命令输入而变得可读时,服务器将会调用命令请求处理器来执行下面的操作

  1. 读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面
  2. 对输入缓冲区的命令请求进行解析,提取出命令请求中包含的命令参数、命令参数的个数,并将其分别保存到客户端状态的argv属性和argc属性
  3. 调用命令执行器,执行命令

例如SET KEY VALUE对应的协议格式的命令请求被解析之后,在客户端状态下就会这样存储:

并将分析结果存到argv属性和argc属性:

接下来服务器将会调用命令执行器来完成执行命令所需的余下步骤:

2.3,命令执行器

2.3.1,查找命令实现

命令执行器要做的第一件事就是根据客户端状态的argv[0]参数(在上面的例子中,这个参数是SET),在命令表(command table)中查找参数指定的命令,并将找到的命令保存到客户端状态的cmd属性里面(如果没有找到,会将cmd属性指向NULL

命令表是一个字典,字典的键是一个个命令名字,比如set、get、del等等;而字典的值则是一个个redisCommand结构,每个redisCommand结构记录了一个Redis命令的实现信息,下表记录了这个结构的各个主要属性的类型和作用:

属性名 类型 作用
name char * 命令的名字,比如set
proc redisCommandProc * 函数指针,指向命令的实现函数,比如setCommandredisCommandProc类型定义为:typedef void redisCommandProc(redisClient *c) 【这代表着需要传入一个客户端状态作为参数】
arity int 命令参数的个数,用于检查命令请求的格式是否正确。如果这个值为负数,表示参数的数量大于等于N【值得注意的是:命令的名字本身也是一个参数】
sflags char * 字符串形式的标识值,这个值记录了命令的属性,比如这个命令是读命令还是写命令,这个命令是否允许在载入数据时使用,这个命令是否允许在Lua脚本中使用等等
flags int sflags标识进行分析得出的二进制标识,程序自动生成。服务器对命令标识进行检查都是使用flags属性而不是sflags属性,因为对二进制的检查可以通过&、^、~等来完成
calls long long 服务器总共执行了多少次这个命令
milliseconds long long 服务器执行这个命令所耗费的总时长

以下是sflags属性可以使用的标识值:

标识 意义 带有这个标识的命令
w 这是一个写入命令,可能会修改数据库 SET、RPUSH、DEL
r 这是一个只读命令,不会修改数据库 GET、STRLEN、EXISTS
m 这个命令可能会占用大量内存,执行之前需要检查服务器的内存使用情况,如果内存紧缺就禁止执行 SET、APPEND、RPUSH、LPUSH、SADD、SINTERSTORE
a 这是一个管理命令 SAVE、BGSAVE、SHUTDOWN
p 这是一个发布与订阅功能方面的命令 PUBLISH、SUBSCRIBE、PUBSUB
s 这个命令不能在Lua脚本中使用 BRPOP、BLPOP、BRPOPLPUSH、SPOP
R 这是一个随机命令,对于相同的数据集和相同的参数,命令返回的结果可能不同 SPOP、SRANDMEMBER、SSCAN、RANDOMKEY
S 当在Lua脚本中使用这个命令时,对这个命令的输出结果进行一次排序,使得命令的结果有序 SINTER、SUNION、SDIFF、SMEMBERS、KEYS
l 这个命令可以在服务器载入数据的过程中使用 INFO、SHUTDOWN、PUBLISH
t 这是一个允许从服务器在带有过期数据时使用的命令 SLAVEOF、PING、INFO
M 这个命令在监视器(monitor)模式下不会自动被传播 EXEC

接下来以SETGET命令举例,展示redisCommand结构:

  • SET命令的名字为set;实现函数为redisCommand;命令参数个数为-3,代表着可以接受三个或以上数量的参数;命令标识为wm,表示SET命令是一个写入命令,并且在执行前需要检查服务器内存使用情况,因为这个命令可能会占用大量内存
  • GET命令的名字为get;实现函数为getCommand;命令参数个数为2,代表着命令只接受两个参数;命令的标识为r,表示这是一个只读命令

当程序用argv[0]作为输入去命令表中进行查找(例如查找set命令),命令表将返回set对应的redisCommand结构。客户端状态的cmd指针将会指向这个redisCommand结构

值得一提的是:在命令表中查找使用的是大小写无关的算法,所以只要命令的名字是正确的,就能找到相应的redisCommand结构。这也就是Redis客户端的命令输入对大小写不敏感的原因

2.3.2,执行预备操作

到这一步为止,服务器已经将执行命令所需要的几个条件满足了:

  • 命令实现参数(保存在客户端状态的cmd属性)
  • 参数(保存在客户端状态的argv属性)
  • 参数个数(保存在客户端状态的argc属性)

但是在真正执行命令之前,程序还需要进行一些预备操作(下面列出的操作是在单机模式下的,当服务器在复制或者集群模式下执行命令时,预备操作还会更多一些),从而确保命令可以正确、顺利的执行。这些操作有:

  • 检查客户端状态的cmd属性是否指向NULL,如果是的话,说明用户输入的命令名字找不到对应的实现函数,代表着命令输入有误,服务器将不再执行后续步骤,并且会向客户端返回一个错误
  • 根据客户端cmd属性指向的redisCommand结构的arity属性,检查命令请求给定的参数个数是否正确,如果不正确,不再执行后续步骤,并向客户端返回一个错误
  • 检查客户端是否通过身份验证,未通过身份验证的客户端只能执行AUTH命令,如果未通过验证的客户端试图执行其他命令,服务器将会向客户端返回一个错误
  • 如果服务器打开了maxmemory功能,那么执行命令之前,先检查服务器的内存占用情况,并在有需要的时候进行内存回收,从而使得接下来的命令可以顺利执行。如果内存回收失败,不再执行后续步骤,并向客户端返回错误
  • 如果服务器上一次执行BGSAVE时出错,并且服务器打开了stop-writes-on-bgsave-error功能,而且服务器将要执行的命令是一个写命令,那么服务器将会拒绝执行这个命令并向客户端返回一个错误
  • 如果客户端当前正在用SUBSCRIBE命令订阅频道,或者正在用PSUBSCRIBE命令订阅模式,那么服务器只会执行客户端发来的SUBSCRIBE、PSUBSCRIBE、UNSUBSCRIBE、PUNSUBSCRIBE四个命令,其他命令都会被服务器拒绝
  • 如果服务器正在进行数据载入,那么只有带有l标识的命令才会被执行,其他命令都会被拒绝
  • 如果服务器因为执行Lua脚本而超时并进入阻塞状态,那么服务器只会执行客户端发来的SHUTDOWN nosave命令和SCRIPT KILL命令,其他命令都会被服务器拒绝
  • 如果客户端正在执行事务,那么服务器只会执行客户端发来的EXEC、DISCARD、MULTI、WATCH命令,其他命令都会被放进事务队列中
  • 如果服务器打开了监视器功能,那么服务器会将要执行的命令和参数等信息发送给监视器

当完成了以上的预备操作之后,服务器就可以开始真正执行命令

2.3.3,调用命令的实现函数

在前面的操作中,服务器已经将要执行的命令、参数、参数个数都准备好了,当服务器决定执行命令后,只需执行以下语句即可:

//client 是指向客户端状态的指针
client->cmd->proc(client);
//以上面提及的set命令为例,实际上就相当于执行:setCommand(client)

被调用的命令实现函数执行指定的操作,并产生相应的命令回复,这些命令回复被保存在客户端的输出缓冲区中,之后实现函数为客户端的套接字关联命令回复处理器,用于将命令回复返回给客户端

2.3.4,执行后续工作

执行完函数后,服务器还需执行一些后续工作:

  • 如果服务器开启了慢查询日志功能,那么慢查询日志会判断是否需要为刚才执行的命令添加一条新的慢查询日志
  • 根据命令的执行时长,更新被执行命令的redisCommand结构的milliseconds属性,并将redisCommand结构的calls计数器的值增一
  • 如果服务器开启了AOF持久化功能,那么AOF持久化模块会将刚才执行的命令请求写入到AOF缓冲区
  • 如果有其他缓冲区正在复制当前这个服务器,那么服务器就会将刚刚执行的命令传播给所有从服务器

当以上操作完成之后,服务器对于当前命令的执行就告一段落了,就可以从文件事件处理器取出并处理下一条命令请求了

2.4,将命令回复发送给客户端

前面提及的命令实现函数会将命令回复保存到客户端的输出缓冲区里面,并为客户端的套接字关联命令回复处理器,当客户端套接字变为可写状态时,服务器就会执行命令回复处理器,将保存在客户端输出缓冲区的命令回复发送给客户端

当命令回复发送完毕之后,回复处理器会清空客户端的输出缓冲区,为处理下一个命令请求做好准备

2.5,客户端接收并打印命令回复

当客户端接收到协议格式的命令回复之后,会将这些回复转换成人类可读的格式,并打印给用户看:

3,serverCron函数

RedisserverCron函数每隔100毫秒执行一次,这个函数负责管理服务器的资源,并保持服务器自身的良好运转

3.1,更新服务器时间缓存

Redis服务器中有不少功能需要获取系统的当前时间,而每次获取系统的当前时间需要执行一次系统调用,为了减少系统调用的次数,服务器状态中的unixtime属性和mstime属性被用作当前时间的缓存:

struct redisServer{//...//保存了秒级精度的系统当前UNIX时间戳time_t unixtime;//保存了毫秒级精度的系统当前UNIX时间戳long long mstime;//...};

因为serverCron函数会默认以每100毫秒的频率更新unixtime属性和mstime属性,所以这两个属性记录的时间精确度并不算高。因此只有在打印日志、更新服务器的LRU时钟、决定是否执行持久化、计算服务器上线时间等对时间精确度要求不高的操作时才会使用这两个属性。对于设置键过期时间、添加慢查询日志等需要高精度的工作,服务器会执行系统调用从而获取最准确的当前时间。

3.2,更新LRU时钟

服务器状态中的lrulock属性保存了服务器的LRU时钟,这个属性和上面介绍的unixtime属性、mstime属性一样,都是服务器时间缓存的一种,serverCron函数默认会以每10秒一次的更新频率更新lrulock属性的值,因为这个时钟的值不是实时的,所以根据这个属性计算出来的LRU时间实际上只是一个估算值:

struct redisServer{//...//默认每10秒更新一次的时钟缓存//用于计算键的空转时长unsigned lrulock:22;//...};

每个Redis对象都会有一个lru属性,这个lru属性保存了对象最后一次被命令访问的时间:

typedef struct redisObject{//...unsigned lru:22;//...}robj;

当服务器要计算一个键的空转时长,程序会用服务器的lrulock属性记录的时间减去对象的lru属性记录的时间,得出的就是键的空转时长

3.3,更新服务器每秒执行命令次数

serverCron函数中的trackOperationsPerSecond函数会以每100毫秒一次的频率执行,用抽样计算的方式估算并记录服务器在最近一秒钟处理的命令请求数量。

之所以说抽样计算,是因为服务器会先计算出平均每一毫秒处理了多少个命令请求,并将得出的结果乘以1000,这就得到了服务器每一秒钟能处理多少个命令请求的估计值。

3.4,更新服务器内存峰值记录

每次serverCron函数执行时,程序都会查看服务器当前使用的内存数量,并于stat_peak_memory保存的数值进行比较,如果当前使用的内存数量比stat_peak_memory属性记录的值要大,那么程序就将当前使用的内存数量记录到stat_peak_memory属性里面

struct redisServer{//...//已使用的内存峰值size_t stat_peak_memory;//...};

3.5,处理SIGTERM信号

【当用户在Linux服务器执行kill指令准备杀死一个进程时,会向其发送SIGTERM信号】

在启动服务器时,Redis会为服务器进程的SIGTERM信号关联处理器sigtermHandler函数,这个信号处理器负责在服务器接到SIGTERM信号时,打开服务器状态的shutdown_asap标识:

//SIGTERM信号的处理器
static void sigtermHandler(int sig){//打印日志redisLogFromHandler(REDIS_WARNING,"Received SIGTERM,scheduleing shutdown...");//打开关闭标识server.shutdown_asap=1;}

每次serverCron函数运行时,程序都会对服务器状态的shutdown_asap属性进行检查:

  • 值为1,关闭服务器
  • 值为0,不做动作

serverCron会在接收到SIGTERM信号时,进行拦截,先进行RDB持久化操作,再关闭服务器。如果一接收到SIGTERM信号就关闭服务器,那么服务器就无法进行持久化操作。

3.6,管理客户端资源

serverCron函数每次执行时都会调用clientsCron函数来管理客户端资源,这个函数会做两件事:

  • 如果客户端与服务器连接已经超时(很长一段时间里客户端和服务器都没有互动),那么程序会释放这个客户端
  • 如果客户端在上一次执行命令请求之后,输入缓冲区的大小超过了一定的长度,那么程序会释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区,从而防止客户端的输入缓冲区耗费过多的内存

3.7,管理数据库资源

serverCron函数每次执行都会调用databasesCron函数,这个函数会对服务器中的一部分数据库进行检查,删除其中的过期键,并在有需要时,对字典进行收缩操作

3.8,执行被延迟的BGREWRITEAOF

在服务器执行BGSAVE命令的期间,如果客户端向服务器发来BGREWRITEAOF命令,那么服务器会将BGREWRITEAOF命令的执行时间延迟到BGSAVE命令执行完毕之后。【这里之所以会延迟执行BGREWRITEAOF是出于性能方面的考虑】

服务器的aof_rewrite_scheduled标识记录了服务器是否延迟了BGREWRITEAOF命令,如果值为1,表示有BGREWRITEAOF命令被延迟了

struct redisServer{//...//如果值为1,那么表示有BGREWRITEAOF命令被延迟执行了int aof_rewrite_scheduled;//...
};

每次在serverCron函数执行时,都会检查BGSAVE命令或者BGREWRITEAOF命令是否在执行,如果都没有在执行,并且aof_rewrite_scheduled属性的值为1,那么服务器就会执行之前被延迟执行的BGREWRITEAOF命令

3.9,检查持久化操作的运行状态

服务器状态使用rdb_child_pid属性和aof_child_pid属性记录执行BGSAVE命令和BGREWRITEAOF命令的子进程ID,这两个属性也可以用于检查BGSAVE命令或者BGREWRITEAOF命令是否在执行。

struct redisServer{//...//记录执行BGSAVE命令的子进程ID//如果服务器没有执行BGSAVE命令,那么这个属性的值为-1pid_t rdb_child_pid;//记录执行BGREWRITEAOF命令的子进程的id//如果服务器没有在执行BGREWRITEAOF,那么这个属性的值为-1pid_t aof_child_pid;//...
};

每次serverCron函数执行时,程序都会检查rdb_child_pidaof_child_pid两个属性的值,只要其中一个属性的值不为-1(代表服务器要么执行BGSAVE命令,要么执行BGREWRITEAOF命令),程序就会执行一次wait3函数,检查子进程是否有信号发来服务器进程:

  • 如果有信号到达,那么表示新的RDB文件已经生成(对于BGSAVE命令来说)或者AOF文件已经重写完毕(对于BGREWRITEAOF命令来说),服务器需要进行相应命令的后续操作,比如用新的RDB文件替换现有的RDB文件,或者用重写后的AOF文件替换现有的AOF文件
  • 如果没有信号到达,那就代表持久化操作还未完成,程序不做动作

如果两个属性的值都为-1,那么表示服务器没有在进行持久化操作,这种情况下,程序会执行以下三个检查:

  1. 查看是否有BGREWRITEAOF被延迟了,如果有,那么开始一次新的BGREWRITEAOF操作(上文提及到的检查)
  2. 检查服务器的自动保存条件是否已经被满足,如果条件满足,并且服务器没有在进行其他持久化操作,那么服务器开始一次新的BGSAVE操作(因为条件1可能引发一次BGREWRITEAOF,所以在这个检查中会再次确认服务器是否已经在进行持久化操作了)
  3. 检查服务器设置的AOF重写条件是否满足,如果条件满足,并且服务器没有在执行其他持久化操作,那么服务器将开始一次新的BGREWRITEAOF操作(因为条件1和2都有可能引发新的持久化操作,所以在这个检查中,需要再次确认服务器是否已经在执行持久化操作)

整个流程的示意图:

3.10,将AOF缓冲区的内容写入到AOF文件

如果服务器开启了AOF持久化功能,并且AOF缓冲区里面还有待写入的数据,那么serverCron函数会调用相应的程序,将AOF缓冲区中的内容写入到AOF文件里面

3.11,关闭异步客户端

服务器会关闭那些输出缓冲区大小超出限制的客户端

3.12,增加cronloops计数器的值

服务器状态的cronloops属性记录了serverCron函数执行的次数:

struct redisServer{//...//serverCron函数的运行次数计数器//serverCron函数每执行一次,这个属性的值就增一int cronloops;//...
};

4,初始化服务器

Redis服务器从启动到能接受客户端的命令请求,需要经过一系列初始化和设置过程:比如初始化服务器状态,接受用户指定的服务器配置,创建相应的数据结构和网络连接等。详细的步骤:

  1. 先创建一个struct redisServer类型的实例变量server作为服务器的状态,并为结构中的属性设置默认值

    1. 在这一步会调用initServerConfig函数,设置服务器的运行ID、默认运行频率、默认配置文件路径、运行架构、默认端口号、默认RDB持久化条件和AOF持久化条件等;并初始化服务器的LRU时钟;创建命令表
    2. 在这一步initServerConfig设置的服务器状态属性基本都是一些整数、浮点数、字符串属性等。除了命令表之外,initServerConfig并没有创建服务器状态之外的其他数据结构
  2. 继续执行initServerConfig,载入配置选项,通过指定的配置文件中的选项为server变量设置相关的属性值
  3. 执行initServer,创建其他数据结构,并为其分配内存
    1. 例如保存连接到服务器的客户端链表:server.clients;保存了服务器中的所有数据库:server.db等都是在这一步执行的
    2. 服务器将server状态的初始化分两步进行:initServerConfig负责初始化一般属性;initServer函数主要负责初始化数据结构【之所以要这样做,是为了避免载入配置文件的配置选项时,对initServer中创建的数据结构造成影响。所以先加载配置文件,然后再进行数据结构的初始化
    3. 除了创建并初始化数据结构外,initServer还负责一些非常重要的设置操作。例如:创建共享对象、设置进程信号处理器、打开服务器的监听端口等
  4. 在完成对服务器状态server变量的初始化之后,服务器需要载入RDB文件或者AOF文件,并根据文件记录的内容来还原服务器的数据库状态
    1. 如果服务器开启了AOF持久化功能,那么将会使用AOF文件进行数据库状态还原
    2. 如果服务器未开启AOF持久化功能,那么将会使用RDB文件进行数据库状态还原
  5. 初始化的最后一步,服务器将会打印初始化完成的日志信息,并开始执行服务器的事件循环

上面的步骤可总结为:

  1. 初始化服务器状态
  2. 载入服务器配置
  3. 初始化服务器数据结构
  4. 还原数据库状态
  5. 执行事件循环

Redis(12)----Redis服务器相关推荐

  1. 部署支持php和Redis的Nginx服务器

    一.安装并配置Nginx服务器 - 在nginx1(192.168.1.10)上安装nginx # 安装编译器 [root@nginx1 ~]# yum install -y gcc pcre-dev ...

  2. Redis 作为缓存服务器的配置

    转自:http://www.open-open.com/lib/view/open1419670554109.html# 随着Redis的发展,越来越多的架构用它取代了memcached作为缓存服务器 ...

  3. HAProxy 的负载均衡服务器,Redis 的缓存服务器

    问答社区网络 StackExchange 由 100 多个网站构成,其中包括了 Alexa 排名第 54 的 StackOverflow.StackExchang 有 400 万用户,每月 5.6 亿 ...

  4. Redis缓存数据库服务器

    Redis缓存数据库服务器 Redis是一个开源的科技与内存也可持久化的日志型.Key-Value数据库 Redis的存储分为内存存储.磁盘存储和Log文件三部分,配置文件中有三个参数对其进行配置. ...

  5. c语言 连接哨兵 redis6,Redis哨兵--缓存服务器

    redis哨兵 说明:通过缓存服务器可以有效的提升用户的访问效 1.注意事项: A:缓存的数据结构应该选用K-V结构,只要K唯一那么结果必然相同 B:缓存总的数据不可能一直储存,需要定期将内存数据进行 ...

  6. redis——通过redis实现服务器崩溃等数据恢复

    由于redis存储在内存中且提供一般编程语言常用的数据结构存储类型,所以经常被用于做服务器崩溃宕机的数据恢复处理. 服务器可以在某些指定过程中将需要保存的数据以json对象等方式存储到redis中,也 ...

  7. Redis在游戏服务器中的应用

    https://www.cnblogs.com/agent-k/p/Redis.html Redis在游戏服务器中的应用 Agent`K 最近在使用Redis,忽然发现以前很多费神的事情都迎刃而解了, ...

  8. Redis配置主从服务器

    主从概念 redis主从可以在同一台电脑上配置,也可以在不同电脑商进行配置. 如果不在同一台电脑上配置,需要保证两台电脑能连接通信. 一个master可以拥有多个slave,一个slave又可以拥有多 ...

  9. Memcached Redis构建缓存服务器

    一.Memcached介绍 RDBMS即关系数据库管理系统(Relational Database Management System) 许多Web应用都将数据保存到 RDBMS中,应用服务器从中读取 ...

  10. redis 两台服务器组集群

    Redis集群的优点: 无中心架构,分布式提供服务.数据按照slot存储分布在多个redis实例上.增加slave做standby数据副本,用于failover,使集群快速恢复.实现故障auto fa ...

最新文章

  1. 资源 | Hinton、LeCun、吴恩达......不容错过的15大机器学习课程都在这儿了
  2. 2013福建高职单招计算机类专业,福建省2013高职单招计算机类试题及答案.doc
  3. linux的零碎知识
  4. 3D Reconstruction三维重建halcon算子,持续更新
  5. C#学习笔记(十四):StatusBar控件
  6. java实用教程——组件及事件处理——ItemEvent事件(设置字体类型)
  7. HTTP 视频怎么在 MIP 页面中使用?
  8. vue打包放到Java项目里_如何把vuejs打包出来的文件整合到springboot里
  9. 浦发网银安装显示连接服务器失败,上海浦东发展银行网上银行登录失败怎么办...
  10. java duplicate parameter e_传递参数[duplicate]时出现问题
  11. XYGame-AI设计3-行为树-第1版本
  12. 链表(python版)
  13. 在王者荣耀角度下分析面向对象程序设计B中23种设计模式之享元模式
  14. decent compiled words
  15. matlab中princ,基于MATLAB_SIMLINK的Turbo交织器的仿真实现
  16. 衣带渐宽终不悔,为“指针”消得人憔悴(三)
  17. spring事务管理器的源码和理解
  18. 还在用 Guava Cache?它才是 Java 本地缓存之王!
  19. 实现 | 朴素贝叶斯模型算法研究与实例分析
  20. 单总线CPU微程序控制器设计

热门文章

  1. aspx导出excel是html编码,导出Execl 系列: web页面导出到Excel乱码解决
  2. vulnhub私钥泄露实战
  3. ceph-deploy 添加/删除 mon节点
  4. 分布式理论之Raft 算法
  5. 算法竞赛中文件的输入输出
  6. 数字时代下表单产品设计中的数据研究
  7. 数据可视化、数据分析常用的图表都有哪些?(1)
  8. ueditor图片上传 flash插件初始化失败
  9. iOS10 经过yalu越狱后无法ssh登录和无法scp拷贝问题
  10. 项目七 网络打印的配置与管理