BerkeleyDB库简介

BerkeleyDB(简称为BDB)是一种以key-value为结构的嵌入式数据库引擎:

  • 嵌入式:bdb提供了一系列应用程序接口(API),调用这些接口很简单,应用程序和bdb所提供的库一起编译/链接成为可执行程序;
  • NOSQL:bdb不支持SQL语言,它对数据的管理很简单,bdb数据库包含若干条记录,每条记录由关键字和数据(key-value)两部分构成。数据可以是简单的数据类型,也可以是复杂的数据类型,例如C语言的结构体,bdb对数据类型不做任何解释,完全由程序员自行处理,典型的C语言指针的自由风格;

DB的设计思想是简单、小巧、可靠、高性能。如果说一些主流数据库系统是大而全的话,那么DB就可称为小而精。DB提供了一系列应用程序接口(API),调用本身很简单,应用程序和DB所提供的库在一起编译成为可执行程序。这种方式从两方面极大提高了DB的效率。第一:DB库和应用程序运行在同一个地址空间,没有客户端程序和数据库服务器之间昂贵的网络通讯开销,也没有本地主机进程之间的通讯;第二:不需要对SQL代码解码,对数据的访问直截了当。

DB对需要管理的数据看法很简单,DB数据库包含若干条记录,每一个记录由关键字和数据(KEY/VALUE)构成。数据可以是简单的数据类型,也可以是复杂的数据类型,例如C语言中结构。DB对数据类型不做任何解释, 完全由程序员自行处理,典型的C语言指针的"自由"风格。如果把记录看成一个有n个字段的表,那么第1个字段为表的主键,第2--n个字段对应了其它数据。DB应用程序通常使用多个DB数据库,从某种意义上看,也就是关系数据库中的多个表。DB库非常紧凑,不超过500K,但可以管理大至256T的数据量。

DB的设计充分体现了UNIX的基于工具的哲学,即若干简单工具的组合可以实现强大的功能。DB的每一个基础功能模块都被设计为独立的,也即意味着其使用领域并不局限于DB本身。例如加锁子系统可以用于非DB应用程序的通用操作,内存共享缓冲池子系统可以用于在内存中基于页面的文件缓冲。

BDB可以分为几个子系统:

  • 存储管理子系统 (Storage Subsystem)
  • 内存池管理子系统 (Memory Pool Subsystem)
  • 事务子系统 (Transaction Subsystem)
  • 锁子系统 (Locking Subsystem)
  • 日志子系统 (Logging Subsystem)

BDB的每一个基础功能模块都被设计为独立的,也即意味着其使用领域并不局限于BDB本身,例如加锁子系统可以用于非BDB应用程序的通用操作,内存共享缓冲池子系统可以用于在内存中基于页面的文件缓冲。

BDB库的安装方法:从官网下载、解压后执行下面的命令

cd build_unix
../dist/configure
make
make install

DB缺省把库和头文件安装在目录 /usr/local/BerkeleyDB.6.1/ 下,使用下面的命令就可正确编译程序:

gcc test.c  -I/usr/local/BerkeleyDB.6.1/include/ -L/usr/local/BerkeleyDB.6.1/lib/ -ldb -lpthread

下面是一个BDB API使用的例子:

复制代码

#include <db.h> 
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <pthread.h>

typedef struct customer {
    int  c_id;
    char name[10];
    char address[20];
    int  age;
} CUSTOMER;


/* 数据结构DBT在使用前,应首先初始化,否则编译可通过但运行时报参数错误  */
void init_DBT(DBT * key, DBT * data)
{
    memset(key, 0, sizeof(DBT));
    memset(data, 0, sizeof(DBT));
}

int main(void)
{
    DB_ENV *dbenv;
    DB *dbp;    
    DBT key, data;

    int ret = 0;
    int key_cust_c_id = 1;
    CUSTOMER cust = {1, "chenqi", "beijing", 30}; 


    /* initialize env handler */
    if (ret = db_env_create(&dbenv, 0)) { 
        printf("db_env_create ERROR: %s\n", db_strerror(ret));
        goto failed;
    }   

    u_int32_t flags = DB_CREATE | DB_INIT_MPOOL | DB_INIT_CDB | DB_THREAD;;  

    if (ret = dbenv->open(dbenv, "/data0/bdb_test", flags, 0)) {
        printf("dbenv->open ERROR: %s\n", db_strerror(ret));
        goto failed;
    }   

    /* initialize db handler */
    if (ret = db_create(&dbp, dbenv, 0)) {
        printf("db_create ERROR: %s\n", db_strerror(ret));
        goto failed;
    }   

    flags = DB_CREATE | DB_THREAD;

    if (ret = dbp->open(dbp, NULL, "single.db", NULL, DB_BTREE, flags, 0664)) {
        printf("dbp->open ERROR: %s\n", db_strerror(ret));
        goto failed;
    }


    /* write record */
    /* initialize DBT */
    init_DBT(&key, &data);
    key.data = &key_cust_c_id;
    key.size = sizeof(key_cust_c_id);
    data.data = &cust;
    data.size = sizeof(CUSTOMER);

    if (ret = dbp->put(dbp, NULL, &key, &data, DB_NOOVERWRITE)) {
        printf("dbp->put ERROR: %s\n", db_strerror(ret));
        goto failed;
    }

    /* flush to disk */
    dbp->sync(dbp, 0);

    /* get record */
    init_DBT(&key, &data);
    key.data = &key_cust_c_id;
    key.size = sizeof(key_cust_c_id);
    data.flags = DB_DBT_MALLOC;

    if (ret = dbp->get(dbp, NULL, &key, &data, 0)) {
        printf("dbp->get ERROR: %s\n", db_strerror(ret));
        goto failed;
    }

    CUSTOMER *info = data.data;

    printf("id = %d\nname=%s\naddress=%s\nage=%d\n",
            info->c_id,
            info->name,
            info->address,
            info->age);

    /* free */
    free(data.data);

    if(dbp) {
        dbp->close(dbp, 0);
    }

    if (dbenv) {
    dbenv->close(dbenv, 0);
    }

    return 0;


failed:

    if(dbp) {
        dbp->close(dbp, 0);
    }

    if (dbenv) {
        dbenv->close(dbenv, 0);
    }

    return -1;
}

复制代码

 上面的例子中使用了很多BDB库中的API,在下面会再具体介绍它们。 


访问方法

访问方法对应了数据在硬盘上的存储格式和操作方法。在编写应用程序时,选择合适的算法可能会在运算速度上提高1个甚至多个数量级。大多数数据库都选用B+树算法,DB也不例外,同时还支持HASH算法、Recno算法和Queue算法。接下来,我们将讨论这些算法的特点以及如何根据需要存储数据的特点进行选择。

  1. BTree:有序平衡树结构;
  2. Hash:扩展线性哈希表结构(extended linear hashing);
  3. Queue:由有固定长度的记录组成的队列结构,每个记录使用一个逻辑序列号作为键值,逻辑纪录号由算法本身生成,这和关系型数据库中逻辑主键通常定义为int AUTO型是同一个概念;支持在队尾快速插入,和从队首取出(或删除)记录;并提供记录级别的加锁操作,从而支持对队列的并发访问。
  4. Recno:同时支持固定长度的记录和变长记录,并且提供支持flat text file的永久存储和数据在读时提供一个快速的临时存储空间;

说明:

BTree和Hash的key和value都支持任意复杂类型,并且也允许存在key重复的记录;

Queue和Recno的key只能是逻辑序列号,两者基本上都是建立在Btree算法之上,提供存储有序数据的接口。前者的序列号是不可变的,后者的序列号可以是可变,也可以是不变;

可变,指的是当记录被删除或者插入时,编号改变;不变,指的是不管数据库如何操作,编号都不改变。在Queue算法中编号总被不变的。在Recno算法中编号是可变的,即当记录被删除或者插入时,数据库里的其他记录的编号也可能会改变。

另外,Queue的value为定长结构,而Recno的value可以为定长,也可以为变长结构;

对算法的选择首先要看关键字的类型,如果为复杂类型,则只能选择BTree或HASH算法,如果关键字为逻辑记录号,则应该选择Recno或Queue算法。

当工作集key有序时,BTree算法比较合适;如果工作集比较大且基本上关键字为随机分布时,选择HASH算法。

Queue算法只能存储定长的记录,在高的并发处理情况下,Queue算法效率较高;如果是其它情况,则选择Recno算法,Recno算法把数据存储为flat text file。

Access Method

Description

Choosing Occasion

BTree

关键字有序存储,并且其结构能随数据的插入和删除进行动态调整。为了代码的简单,Berkeley DB没有实现对关键字的前缀码压缩。B+树支持对数据查询、插入、删除的常数级速度。关键字可以为任意的数据结构。

1、 当Key为复杂类型时。

2、 当Key有序时。

Hash

DB中实际使用的是扩展线性HASH算法(extended linear hashing),可以根据HASH表的增长进行适当的调整。关键字可以为任意的数据结构。

1、 当Key为复杂类型。

2、 当数据较大且key随机分布时。

Recno

要求每一个记录都有一个逻辑纪录号,逻辑纪录号由算法本身生成。相当于关系数据库中的自动增长字段。Recho建立在B+树算法之上,提供了一个存储有序数据的接口。记录的长度可以为定长或不定长。

1、 当key为逻辑记录号时。

2、 当非高并发的情况下。

Queue

和Recno方式接近, 只不过记录的长度为定长。数据以定长记录方式存储在队列中,插入操作把记录插入到队列的尾部,相比之下插入速度是最快的。

1、当key为逻辑记录号时。

2、定长记录。

3、 高并发的情况下。


数据结构

数据库环境句柄结构DB_ENV:环境在DB中属于高级特性,本质上看,环境是多个数据库的包装器。当一个或多个数据库在环境中打开后,环境可以为这些数据库提供多种子系统服务,例如多线/进程处理支持、事务处理支持、高性能支持、日志恢复支持等。

数据库句柄结构DB:包含了若干描述数据库属性的参数,如数据库访问方法类型、逻辑页面大小、数据库名称等;同时,DB结构中包含了大量的数据库处理函数指针,大多数形式为 (*dosomething)(DB *, arg1, arg2, …),其中最重要的有open、close、put、get等函数。

数据库记录结构DBT:DB中的记录由关键字和数据构成,关键字和数据都用结构DBT表示。实际上完全可以把关键字看成特殊的数据。结构中最重要的两个字段是 void * data和u_int32_t size,分别对应数据本身和数据的长度。

数据库游标结构DBC:游标(cursor)是数据库应用中常见概念,其本质上就是一个关于特定记录的遍历器。注意到DB支持多重记录(duplicate records),即多条记录有相同关键字,在对多重记录的处理中,使用游标是最容易的方式。

DB中核心数据结构在使用前都要初始化,随后可以调用结构中的函数(指针)完成各种操作,最后必须关闭数据结构。从设计思想的层面上看,这种设计方法是利用面向过程语言实现面对对象编程的一个典范。

DB_ENV    *dbenv;           // 环境句柄
DB      *dbp;             // 数据库句柄
DBT     key, value;       // 纪录结构
DBC     *cur;            // 游标结构

数据库每条记录包含两个DBT结构,一个是key,一个是value。

复制代码

typedef struct {
     void *data;          // 数据buf
     u_int32_t size;     // 数据大小
     u_int32_t ulen;     //
     u_int32_t dlen;     // 数据长度
     u_int32_t doff;     // 数据开始处
     u_int32_t flags;     
} DBT;

复制代码

数据库环境

BDB环境是对一个或多个数据库的封装,环境对应一个目录,数据库对应该目录下面的一个文件。

环境支持:

  • 在一个磁盘文件中包含多个数据库;
  • 多进程和多线程支持;
  • 事务处理;
  • 高可用支持(主从库复制);
  • 日志系统(可用于数据库异常恢复);

与环境相关的API:

DB_ENV *dbenv;
db_env_create(&dbenv, 0);                     // 创建数据库环境句柄
dbenv->open(dbenv, path, flags, 0);          // 打开数据库环境, path是环境的目录路径, flag参数参考下面介绍
dbenv->close(dbenv, 0);                      // 关闭数据库环境
dbenv->err(dbenv, ret, formart, ...);        // 错误调试

打开环境时的flags标志位:

DB_CREATE               // 打开的环境不存在的话就创建它
DB_THREAD               // 支持线程
DB_INIT_MPOOL         // 初始化内存中的cache
DB_INIT_CDB

BDB 环境的使用例子:

复制代码

/* 定义一个环境变量,并创建 */
DB_ENV *dbenv;
db_env_create(&dbenv, 0);

/* 在环境打开之前,可调用形式为dbenv->set_XXX()的若干函数设置环境 */

/* 通知DB使用Rijndael加密算法(参考资料>)对数据进行处理 */
dbenv->set_encrypt(dbenv, "encrypt_string", DB_ENCRYPT_AES);

/* 设置DB的缓存为5M */
dbenv->set_cachesize(dbenv, 0, 5 * 1024 * 1024, 0);

/* 设置DB查找数据库文件的目录 */
dbenv->set_data_dir(dbenv, "/usr/javer/work_db");

/* 设置出错时的回调函数 */
dbenv->set_errcall(dbenv, callback);

/* 将错误信息写到指定文件 */
dbenv->set_errfile(dbenv, file);

/* 打开数据库环境,注意后四个标志分别指示DB启动日志、加锁、缓存、事务处理子系统 */
dbenv->open(dbenv,home,DB_CREATE|DB_INIT_LOG|DB_INIT_LOCK| DB_INIT_MPOOL |DB_INIT_TXN, 0);

/* 在环境打开后,则可以打开若干个数据库,所有数据库的处理都在环境的控制和保护中。
   注意db_create函数的第二个参数是环境变量 */
db_create(&dbp1, dbenv, 0);
dbp1->open(dbp1, ……);
db_create(&dbp2, dbenv, 0);
dbp1->open(dbp2, ……);

/* do something with the database */
/* 最后首先关闭打开的数据库,再关闭环境 */
dbp2->close(dbp2, 0);
dbp1->close(dbp1, 0);
dbenv->close(dbenv, 0);

复制代码

数据库操作

DB数据库是一组K-V记录的集合,key和value都是DBT结构存储的,与数据库操作有关的API:

复制代码

DB* dbp; 
db_create(&dbp, dbenv, 0);                                            // 获取数据库句柄
dbp->open(dbp, NULL, filename, NULL, DB_BTREE, flags, 0);        
dbp->close(&dbp, 0);                                                  // 在关闭数据库前,先关闭所有打开的游标
dbp->sync(dbp, 0)                                                     // 刷新cache,同步到磁盘,close操作会隐含调用该过程
dbp->remove(dbp, filename, NULL, 0)                                 // 移除数据库,不要移除已打开的数据库
dbp->rename(dbp, oldname, NULL, newname, 0)                      // 数据库重命名,不要重命名已打开的数据库
 
dbp->put(dbp, NULL, &key, &data, DB_NOOVERWRITE);              // DB_NOOVERWRITE不允许重写已存在的key
dbp->get(dbp, NULL, &key, &data, flags);                             // 如果存在key重复的记录,只返回第一个,或者使用游标
dbp->del(dbp, NULL, &key, 0);                                        // 删除指定key的记录
dbp->truncate(dbp, NULL, u_int32_t* count, 0);                      // 删除所有记录,count中返回被删除的记录个数
dbp->get_open_flags(dbp, &open_flags);                             // 获取打开的flags,仅对已打开的数据库才有意义
dbp->set_flags(dbp, flags);                                          // 设置打开的flags

复制代码

 打开数据库时的flags标志位

DB_CREATE  //如果打开的数据库不存在,就创建它;不指定这个标志,如果数据库不存在,打开失败!
DB_EXC    //与DB_CREATE一起使用,如果打开的数据库已经存在,则打开失败;不存在,则创建它;
DB_RDONLY  //只读的方式打开,随后的任何写操作都会失败;
DB_TRUNCATE  //清空对应的数据库磁盘文件;
DB_DUPSORT   //

  

get方法返回DB_NOTFOUND时表示没有匹配记录,其最后一个参数flags:

DB_GET_BOTH   // get方法默认只匹配key,该flag将返回key和data都匹配的第一条记录
DB_MULTIPLE   // get方法默认只返回匹配的第一条记录,该flag返回所有匹配记录

使用get方法时,data参数是DBT结构,该DBT的flags参数可以定义为:

DB_DBT_USERMEM       // 使用自己的内存存储检索的data
DB_DBT_MALLOC        // 使用DB分配的内存,用完后要手动free

DB提供的内存对齐方式可能不符合用户数据结构的需求,所以尽量使用我们自己的内存。

用DB_DBT_USERMEM方式改写前面的例子:

复制代码

   /* get record */
    CUSTOMER info;
    init_DBT(&key, &data);

    key.data = &key_cust_c_id;
    key.size = sizeof(key_cust_c_id);

    data.data = &info;
    data.ulen = sizeof(CUSTOMER);
    data.flags = DB_DBT_USERMEM;

    if (ret = dbp->get(dbp, NULL, &key, &data, 0)) {
        printf("dbp->get ERROR: %s\n", db_strerror(ret));
        goto failed;
    }   

    printf("id = %d\nname=%s\naddress=%s\nage=%d\n", 
            info.c_id,
            info.name,
            info.address,
            info.age);

复制代码

错误处理

DB接口调用成功通常返回0,失败返回非0值,此时可以检查错误码errno;

由系统调用失败引起的错误errno>0,否则errno<0。

db_strerror(errno)                     // 将错误编码映射成一个字符串
dbp->set_errfile(dbp, FILE*)           // 设置错误文件
dbp->set_errcall(dbp, void(*)(const DB_ENV *dbenv, const char* err_pfx, const char* msg))           // 定义错误处理的回调函数
dbp->set_errpfx(dbp, format...)       // 加上错误消息前缀
dbp->err(dbp, ret, format...)        // 生成错误消息,并按优先级发给set_errcall定义的错误处理回调函数、set_errfile定义的文件、stderr;
dbp->errx(dbp, format...)            // 与dbp->err类似,但没有返回值ret这个额外参数

错误消息由一个前缀(由set_errpfx定义)、消息本身(由err或errx定义)和一个换行符组成。


游标

如果DB数据库中存在键值相同的多条记录,使用dbp->get()方法默认只能获取一条记录(除非打开DB_MULTIPLE标志),这个时候有必要使用游标(cursor),游标可以迭代DB数据库中的记录。

DBC  *cur;
dbp->cursor(dbp, NULL, &cur, 0);              // 初始化游标对象
cur->close(cur);                              // 关闭游标
cur->get(cur, &key, &data, flags);            // 迭代记录,当没有可迭代的记录时,返回DB_NOTFOUND
cur->put(cur, &key, &data, flags);
cur->del(cur, 0);                             // 删除游标指向的记录

cur->get()方法中flags参数的取值:

1、迭代整个数据库中的纪录集合:

DB_NEXT    // 从第一条纪录遍历到最后一条纪录;
DB_PREV    // 逆序遍历,从最后一条纪录开始;

2、查找符合条件的记录集:

复制代码

DB_SET          // 移动游标到键值等于给定值的第一条纪录;
DB_SET_RANGE      // 如果数据库使用BTREE的算法,移动游标到键值大于或等于给定值的纪录集合;
DB_GET_BOTH      // 移动游标到键值和数据项均等于给定值的第一条记录;
DB_GET_BOTH_RAGNE   // 移动游标到键值等于给定值,数据项大于或等于给定值的纪录集合;
 
DB_NEXT_DUP      // 获取下一个key重复的记录;
DB_PREV_DUP      // 获取上一个key重复的记录;
DB_NEXT_NODUP     // 获取下一个key不重复的记录;
DB_PREV_NODUP    // 获取上一个key不重复的记录;

复制代码

cur->put()方法中flags参数的取值:

DB_NODUPDATA   // 如果插入的key已存在,返回DB_KEYEXIST,如果不存在,则记录的插入顺序由其在数据库的插入顺序决定;
DB_KEYFIRST    // 在key重复的集合里面放在第一个位置;
DB_KEYLAST    // 在key重复的集合里面放在最后一个位置;
DB_CURRENT    // replace

secondary数据库

通常,对DB数据库的检索都是基于key的,如果想通过value(或value的部分信息)来检索数据可以通过secondary database来实现。

如果我们把存放K-V记录的数据库称为 primary database,那么secondary database索引的是其它字段,而对应的value就是primary database的key。从secondary database中读取记录时,DB自动返回primary database中对应的value;

在创建secondary database时需要提供一个callback函数,用来创建key,这个key是根据primary database的key或value生成的。这个callback函数返回0时才允许索引到secondary中,我们可以在callback中返回DB_DONOTINDEX或其它错误码告诉secondary不要索引该记录。

建立secondary database之后,如果在primary database中新增或删除记录,会触发对secondary database的更新。
注意:我们不能直接更新secondary database,任何写secondary database的操作都会失败,secondary database的变更需要通过修改primary database实现。但这里有一个例外,允许在secondary database中删除记录。

将secondary绑定到primary database的接口:

primary_dbp->associate(primary_dbp, NULL, second_dbp, key_creator, 0);
int key_creator(DB* dbp, const DBT* pkey, const DBT* pdata, DBT* skey);

 一个例子:

复制代码

DB *dbp, *sdbp;     /* Primary and secondary DB handles */
u_int32_t flags;    /* Primary database open flags */
int ret;            /* Function return value */

typedef struct vendor {
    char name[MAXFIELD];         /* Vendor name */
    char street[MAXFIELD];        /* Street name and number */
    char city[MAXFIELD];         /* City */
    char state[3];            /* Two-digit US state code */
    char zipcode[6];           /* US zipcode */
    char phone_number[13];       /* Vendor phone number */
    char sales_rep[MAXFIELD];      /* Name of sales representative */
    char sales_rep_phone[MAXFIELD];  /* Sales rep's phone number */
} VENDOR;

/* Primary */
ret = db_create(&dbp, NULL, 0); 
if (ret != 0) {
    /* Error handling goes here */
}

/* Secondary */
ret = db_create(&sdbp, NULL, 0); 
if (ret != 0) {
    /* Error handling goes here */
}

/* Usually we want to support duplicates for secondary databases */
ret = sdbp->set_flags(sdbp, DB_DUPSORT);
if (ret != 0) {
    /* Error handling goes here */
}

/* Database open flags */
flags = DB_CREATE; /* If the database does not exist, create it.*/

/* open the primary database */
ret = dbp->open(dbp, NULL, "my_db.db", NULL, DB_BTREE, flags, 0); 
if (ret != 0) {
    /* Error handling goes here */
}

/* open the secondary database */
ret = sdbp->open(sdbp, NULL, "my_secdb.db", NULL, DB_BTREE, flags, 0); 
if (ret != 0) {
    /* Error handling goes here */
}

/* Callback used for key creation. Not defined in this example. See the next section. */
int get_sales_rep(DB *sdbp,   /* secondary db handle */
        const DBT *pkey,    /* primary db record's key */
        const DBT *pdata,   /* primary db record's data */
        DBT *skey)       /* secondary db record's key */
{
    VENDOR *vendor;

    /* First, extract the structure contained in the primary's data */
    vendor = pdata->data;

    /* Now set the secondary key's data to be the representative's name */
    memset(skey, 0, sizeof(DBT));
    skey->data = vendor->sales_rep;
    skey->size = strlen(vendor->sales_rep) + 1;

    /* Return 0 to indicate that the record can be created/updated. */
    return (0);
}


/* Now associate the secondary to the primary */
dbp->associate(dbp, NULL, sdbp, get_sales_rep, 0);

复制代码


页面大小

BDB记录的key和value都是存放在内存页(page)里面,所以页面大小(page size)对数据库性能有很大影响。

对BTree库,page size的理想大小至少是记录大小的4倍。

DB* dbp; 
dbp->set_pagesize()          // 设置page size
dbp->stat()                  // 查看page size

page size的影响:

1、overflow pages

溢出页(overflow pages)是用来存放那些单个page无法存放的kye或value的page。

如果page size设置过低,会产生溢出页,从而影响数据库性能。

2、locking

page size对多线程(或多进程)的BDB应用也会产生影响,这是因为BDB使用了page级的加锁(Queue除外)。

通常一个page包含多条记录,如果某个线程正在访问一条记录,则该记录所在的page会被锁住,导致同一个page下面的其他记录也无法被其他线程访问。

如果page size设置过高,会加大锁发生的概率,但page size过小,会导致BTree的深大变大,同样损失性能。

3、I/O

DB数据库的页面大小要和文件系统的block size一致;


缓存

DB可以将那些经常访问到记录cache 到内存里面,从而加快读写速度。

dbp->set_cachesize(dbp, gbytes, bytes, ncache);         // 通过数据库句柄设置cache大小
dbenv->set_cachesize(dbp, gbytes, bytes, ncache);      // 通过环境句柄设置cache大小(全局)

======专注高性能web服务器架构和开发=====

转自https://www.cnblogs.com/chenny7/p/4864547.html

猜你喜欢

转载自blog.csdn.net/weixin_41649106/article/details/87884408
今日推荐