MongoDB03 - MongoDB索引,事务和安全

MongoDB索引,事务和安全

文章目录

一:事务和锁

1:MongoDB事务机制:不建议使用

MongoDB作为数据库家族的一员,自然也支持事务机制,只不过相较于InnoDB的事务机制而言,MongoDB事务方面并没有那么强大

这倒不是因为官方技术欠缺,而是由于MongoDB的定位是:大数据、高拓展、高可用、分布式

因此在实现事务时,不仅仅要考虑单机事务,而且需要考虑分布式事务,复杂度上来之后,自然无法做到MySQL-InnoDB那种单机事务的强大性。

这里也列出MongoDB事务方面的改进过程,如下:

  • 3.0版本中,引入WiredTiger存储引擎,开始支持单文档事务;
  • 4.0版本中,开始支持多文档事务,以及副本集(主从复制)架构下的事务;
  • 4.2版本中,开始支持分片集群、分片式多副本集架构下的事务。
js 复制代码
// 开启一个会话
var session = db.getMongo().startSession({
    // readPreference:定义读操作的节点优先级和模式
    // mode:指定读取模式
    // -> primary:只从主节点读取数据;
    // -> secondary:只在从节点上读取数据;
    // -> primaryPreferred:优先从主节点读取,主节点不可用,转到从节点读取;
    // -> secondaryPreferred:优先在从节点读取,从节点不可用,转到主节点读取;
    // -> nearest:从可用节点中选择最近的节点进行读取;
    readPreference:{mode: "primary"}
});
// 开启事务
session.startTransaction(
    {
        // readConcern:指定事务的读取模式
        // level:指定一致性级别
        // --> available:读取已提交的数据,可能包含尚未持久化的事务更改;
        // --> snapshot:读取事务开始时的一致快照,不包含未提交的事务更改;
        readConcern: {level:"snapshot"},  // 指定读模式为快照读
        // writeConcern:指定事务的写入模式
        // w:指定写操作的确认级别(同步模式)
        // --> [number]:写操作在写入指定数量的节点后,返回写入成功;
       	// --> majority: 写操作在写入大多数节点(半数以上)后,返回写入成功;
        // --> tagSetName:写操作在写入指定标签的节点后,返回写入成功
        // j:写入是否应被持久化到磁盘
        // --> wtimeout:指定写入确认的超时时间;
        writeConcern:{w: "majority"} // 写模式的同步模式级别为半同步,即写入半数以上节点后再返回成功
    }
);
// 获取要操作的集合对象
var trx_coll = session.getDatabase("库名").getCollection("集合名");

// 要在事务里执行的CRUD操作
// ......

// 回滚事务命令
session.abortTransaction();
// 提交事务命令
session.commitTransaction();
// 关闭会话命令
session.endSession();

上述命令了解即可,毕竟MongoDB本身就不适用于强事务的场景,原因如下:

  • MongoDB的事务必须在60s内完成,超时将自动取消(因为要考虑分布式环境);
  • 涉及到事务的分片集群中,不能有仲裁节点;
  • 事务会影响集群数据同步效率、节点数据迁移效率;
  • 多文档事务的所有操作,必须在主节点上完成,包括读操作;

综上所述,就算MongoDB支持事务,可实际使用起来也会有诸多限制,因此在不必要的情况下,不建议使用其事务机制。

2:MongoDB的锁机制

MongoDB 锁机制是其并发控制的重要组成部分,目的是为了确保多线程多用户访问下数据的完整性和一致性,主要分类两大类:MMAPv1 引擎的锁机制和 WiredTiger 引擎的锁机制。

在使用 MMAPv1 存储引擎的 MongoDB 版本中,全局锁时其主要的并发控制手段。全局锁有两种模式:

  • 读锁:允许多个读操作共享,但组织任何写操作
  • 写锁:独占锁,一旦获取将组织其他读写操作,直至锁释放

WiredTiger 引擎使用了更细粒度的锁机制,主要为:

  • 文档锁:锁定单个文档,允许多个并发读操作,但写操作会互斥。这大大减少了锁竞争,提高了并发写入能力,从而使得在高并发场景下也能保持较好的性能。
  • 多版本并发控制(MVCC):WiredTiger 实现了一种 MVCC 机制,为每个事务创建数据的多个版本。这样,读操作可以不受写操作的影响,看到事务开始时的一致性视图,而写操作则在新版本上进行,直到事务提交后才会对外可见。这增强了系统的并发能力,同时保证了事务的隔离性。
  • 范围锁:在某些情况下,为了保持数据一致性,WiredTiger可能会锁定一个文档范围,防止其他操作修改该范围内的数据。
  • 乐观锁:除了传统的锁机制,WiredTiger还采用了乐观锁策略,尤其在处理读写操作时。乐观锁依赖于文档版本控制,每个文档都有一个内部版本号。写操作前先读取版本号,写入时检查版本号是否改变,若未变则成功,否则重试。这种方式减少了锁的使用,提高了并发效率

非常复杂:有时间研究下大佬的笔记

js 复制代码
// 这里简单的列出手动操作锁的命令:
// 获取锁
db.collection.fsyncLock();
// 释放锁
db.collection.fsyncUnlock();

二:MongoDB的索引机制

任何数据库都有索引这一核心功能,MongoDB自然不例外,而且MongoDB在索引方面特别完善,毕竟是新的数据库,肯定汇集百家之长

Mongo索引官方文档

早版本的MongoDB中,索引底层默认使用B-Tree结构

4.x版本后,MongoDB推出了V2版索引,默认使用变种B+Tree来作为索引的数据结构(和MySQL索引的数据结构相同)

1:初始索引

MongoDB会为每个集合生成一个默认的_id字段,该字段在每个文档中必须存在,可以手动赋值

如果不赋值则会默认生成一个ObjectId, 该字段则是集合的主键,MongoDB会基于该字段创建一个默认的主键索引

后续基于_id字段查询数据时,会走索引来提升查询效率。

当咱们基于其他字段查询时,由于未使用_id作为条件,这会导致find语句走全表查询,即从第一条数据开始,遍历完整个集合,从而检索到目标数据。

当集合中的数据量,达到百万、千万、甚至更高时,意味着效率会直线下滑,在这种情况下,必须得由我们手动为频繁作为查询条件的字段建立索引。

Mongo中的索引分类

  • 从字段数量的维度划分:单列,组合,多键,部分
  • 从排序的维度划分:升序,降序,多序
  • 从功能的维度划分:主键,普通,唯一,全文,空间
  • 从数据结构的维度划分:B+Tree,Hash
  • 从存储方式的维度划分:聚簇,非聚簇
  • 从索引性质的维度划分:稀疏,TTL,隐藏,通配符

2:索引详解

MongoDB中创建索引的命令:

javascript 复制代码
db.collection.createIndex(<key and index type specification>, <options>);

前面提到的所有索引,都是通过这一个方法创建,不同类型的索引,通过里面的参数和选项来区分,下面说明一下参数和可选项。

第一个参数主要是传字段,以及索引类型,这里可以传一或多个字段,用于表示单列/复合索引。

第二个参数表示可选项,如下:

  • background:是否以后台形式创建索引,因为创建索引会导致其他操作阻塞;
  • unique:是否创建成唯一索引;
  • name:指定索引的名称;
  • sparse:是否对集合中不存在的索引字段的文档不启用索引;
  • expireAfterSeconds:指定存活时间,超时后会自动删除文档;
  • v:指定索引的版本号;
  • weights:指定索引的权重值,权值范围是1~99999,当一条语句命中多个索引时,会根据该值来选择;

以下述集合为例,演示各种索引的创建过程

js 复制代码
db.animals.insert([
    {_id:1, name:"肥肥", age:3, hobby:"竹子", color:"黑白色"},
    {_id:2, name:"花花", color:"黑白色"},
    {_id:4, name:"黑熊", age:3, food:{name:"黄金竹", grade:"S"}},
    {_id:5, name:"白熊", age:4, food:{name:"翠绿竹", grade:"B"}},
    {_id:6, name:"棕熊", age:3, food:{name:"明月竹", grade:"A"}},
    {_id:7, name:"红熊", age:2, food:{name:"白玉竹", grade:"S"}},
    {_id:8, name:"粉熊", age:6, food:{name:"翡翠竹", grade:"A"}},
    {_id:9, name:"紫熊", age:3, food:{name:"烈日竹", grade:"S"}},
    {_id:10, name:"金熊", age:6, food:{name:"黄金竹", grade:"S"}}
]);
2.1:单列索引
js 复制代码
// 基于name字段,创建一个名为idx_name的单列普通索引,排序方式为降序
db.animals.createIndex(
    {name: -1}, 
    {name: "idx_name"}
);

🎉 对于单字段的索引而言,排序方式并不重要,因为索引底层默认是B+Tree,每个文档之间会有双向指针,为此,MongoDB基于单字段索引查询时,既可以向前、也可以向后查找数据

创建完成后,可以通过db.animals.getIndexes()命令查询索引

多键索引

例如现在将集合中的爱好字段,变为一个数组:

Json 复制代码
{
    _id:1, 
    name:"肥肥", 
    age:3, 
    hobby:[
        "竹子", "睡觉"
    ], 
    color:"黑白色"
}

现在给hobby字段创建一个索引,这时叫啥索引?多键索引!

因为这里是基于单个数组类型的字段在建立索引,所以MongoDB会为数组中的每个元素,都生成索引的条目(即索引键)

由于一个文档的数组字段,拥有多个元素,因此会创建多个索引键,这也是"多键索引"的名字由来。

2.2:复合索引

复合索引是指基于多个字段创建的索引,例如:

js 复制代码
db.animals.createIndex(
    {name:-1, age:1}, // 依据name降序,age升序创建一个聚合索引
    {name:"idx_name_age"} // 复合索引的名称
);

🎉 这个排序就有意义了,MongoDB生成索引键时,会按照指定的顺序,来将索引键插入到树中。

索引键=索引字段的值,比如现在一个文档的name=张三、age=3,索引键为张三3

注意:由于这里的顺序是{name:-1, age:1},所以当排序查询时,支持sort({name:-1,age:1})、sort({name:1,age:-1}),因为这两个顺序和树的组成顺序要么完全相同、相反

而当执行sort({name:-1,age:-1})、sort({name:1,age:1})排序查询时,将不会使用索引,因为这时和树的顺序冲突。

2.3:唯一索引

必须创建在不会出现重复值的字段上,基于唯一索引查找数据时,找到第一个满足条件的数据,就会立马停止匹配,毕竟该字段的值在集合中是唯一的

js 复制代码
db.animals.createIndex(
    {name:1}, // 在name字段上正序创建索引
    {unique: true} // 声明是唯一索引
);

只需要将unique设置为true即可,如果尝试插入已有的name,将会触发报错

两个都是空也认为是冲突

js 复制代码
db.animals.insertOne(
    {_id: 66, age: 12}
)

db.animals.insertOne(
    {_id: 77, age: 13}
)

这种情况怎么解决呢,声明name索引是稀疏索引即可

js 复制代码
// 将刚才的给删除了
db.animals.dropIndex("name_1");

// 在创建一个,这次指明这个索引不但是唯一的,还是稀疏的,允许重复的null值
db.animals.createIndex(
    {name: 1},
    {unique: true, sparse: true}
)
2.4:部分索引

部分索引即使用字段的一部分开创建索引,但必须要结合partialFilterExpression选项来实现,

js 复制代码
db.animals.createIndex(
  {hobby: 1}, // 给 hobby 字段创建索引
  {partialFilterExpression: {
      hobby: {
        // 只为存在hobby字段的文档创建索引
        $exists: true,
        // 通过$substr操作符,截取前3个字节作为索引键
        $expr: {$eq: [{$substr:["$hobby", 0, 3] },"prefix"]}
      }
    }}
);

其实这就类似于MySQL中的前缀索引,不过MongoDB的中的部分索引功能更强大,还可以只为集合中的一部分文档创建索引

js 复制代码
db.animal.createIndex(
   {age: -1},
   // 只为集合中年龄大于2岁的文档创建索引
   {partialFilterExpression: {
       age: {$gt: 2}
   }}
);
2.5:TTL索引

可以基于它实现过期自动删除的效果,主要依靠expireAfterSeconds选项来创建

只能在Date、ISODate类型的字段上建立TTL索引,在其他类型的字段上建立TTL索引,文档永远不会过期。

js 复制代码
db.test_ttl.insertMany([
    // new Date()表示插入当前时间
    {_id:1, time:new Date()},
    {_id:2, time:new Date()},
    {_id:3, time:new Date()},
    {_id:4, time:new Date()},
    {_id:5, notes:"这条数据用于观察TTL删除特性"} // 10s之后,将只剩这条数据
]);

db.test_ttl.find();

db.test_ttl.createIndex(
    {time: 1},  // 依据时间字段创建索引
    {expireAfterSeconds: 10} // 给定的过期时间为10s
);
2.6:全文索引

MySQL中想实现模糊查询,一般会采用like关键字;而在MongoDB中想实现模糊查询,官方并没有提供相关方法与操作符,只能通过自己写正则的形式,实现模糊查找的功能

那有没有更好的方法呢?

答案是有,为相应字段创建全文索引即可。

在数据量不大不小(几百万左右)、查询又不是特别复杂的情况下,直接上ElasticSearch、Solr等中间件,显得有点大材小用

此时全文索引就是这类搜索引擎的平替。相较于MySQLMongoDB提供的全文索引,功能方面会更加强大。

js 复制代码
db.animals.createIndex(
    {name: "text"},
    {name: "full_text_index"}
)

这里对name字段建立了一个全文索引,和创建普通索引的区别在于:在字段后面加了一个text

不过要注意,MongoDB全文索引停用词、词干和词器的规则,默认为英语,想要更改,这里涉及到创建索引时的两个可选项:

  • default_language:指定全文索引停用词、词干和词器的规则,默认为english
  • language_override:指定全文索引语言覆盖的范围,默认为language

不过注意,不管任何技术栈的全文索引,对中文的支持都不太友好,分词方面总会有点不完善

所以MongoDB全文索引直接不支持中文,当你试图通过default_language:"chinese"时,会直接给你返回报错

当然,正是由于MongoDB的全文索引不支持中文,因此就算你给一个字符串字段,建立了全文索引后,也无法实现全文搜索,如下:

js 复制代码
db.animals.find(
    {$text: {$search: "熊"}}
);

⚠️ 在前面给出的集合数据中,name包含"熊"的数据有好几条,但这条语句执行之后的结果为null。想要解决这个问题,必须要手动安装第三方的中文分词插件,如mmseg、jieba等。当然,如果你字段中的值是英文,这自然是支持的,什么都不需要。

2.7:通配符索引

在前面提到过"内嵌文档"这个概念,这是指将另一个文档,以字段值的形式嵌入到一个文档中。

结合MongoDB可以动态插入各种字段的特性,每个内嵌文档的字段,也可以灵活变化,例如前面给出的数据:

js 复制代码
{_id:4, name:"黑熊", age:3, food:{name:"黄金竹", grade:"S"}},
    
{_id:5, name:"白熊", age:4, food:{name:"翠绿竹", grade:"B"}},
......

这些数据中都内嵌了一个food文档,虽然现在插入的都是固定的name、grade字段,但我们可以随时插入新的字段,例如:

js 复制代码
db.animals.insertOne(
    {
    	_id:99, 
        name:"星熊", 
        age: 1,
        food: {
            name:"星光竹", 
            grade:"S", 
            quality_inspector: ["竹大","竹二"]
        }
    }
);

这时新插入的文档,其food字段又多了一个quality_inspector质检员的属性

对于这种动态变化的字段,可不可以建立索引呢?MongoDB4.2中引入了"通配符索引"来支持对未知或任意字段的查询操作

创建的语法如下:

js 复制代码
db.animals.createIndex({"food.$**": 1});

3:explain执行计划

通过该命令,能有效帮咱们分析语句的执行情况,和MySQL的explain作用一致

js 复制代码
db.<collection>.find().explain(<verbose>);

explain方法同样有三个模式可选,这里简单列出来:

  • queryPlanner:返回执行计划的详细信息,包括查询计划、集合信息、查询条件、最佳计划、查询方式、服务信息等(默认模式);
  • exectionStats:列出最佳执行计划的执行情况和被拒绝的计划等信息(即语句最终执行的方案);
  • allPlansExecution:选择并执行最佳执行计划,同时输出其他所有执行计划的信息;

一般排查find()查询缓慢问题时,可以先指定第二个模式,查看最佳执行计划的信息;

如果怀疑MongoDB没选择好索引,则可以再指定第三个模式,查看其他执行计划

如果的确是因为走错了索引,这时你可以通过hint强制指定要使用的索引,如下:

js 复制代码
db.collection_name.find(查询条件).hint(索引名);

执行explain之后,可以发现输出了很多信息,主要关注的就是这个stage这个值:是最重要的字段,相当于mysql explain中的type

带包这本次查询的类型,该字段可能出现的值以及含义如下:

含义
COLLSCAN 扫描整个集合进行查询;
IXSCAN 通过索引进行查询;
COUNT_SCAN 使用索引在进行count操作;
COUNTSCAN 没使用索引在进行count操作;
FETCH 根据索引键去磁盘拿具体的数据
SORT 执行了sort排序查询;
LIMIT 使用了limit限制返回行数;
SKIP 使用了skip跳过了某些数据;
IDHACK 通过_id主键查询数据;
SHARD_MERGE 从多个分片中查询、合并数据;
SHARDING_FILTER 通过mongos对分片集群执行查询操作;
SUBPLA 未使用索引的$or查询;
TEXT 使用全文索引进行查询;
PROJECTION 本次查询指定了返回的结果集字段(投影查询)
js 复制代码
// 在age上创建一个索引
db.animals.createIndex({age: -1}, {name: "idx_age"})
// 假设执行的语句如下
db.animals.find({age: {$gt: 6}}).limit(1).skip(1).explain();

这里咱们只需要带SCAN后缀的,因为其他都属于命令执行的"阶段",并不属于具体的类型

explain会将一条语句执行的每个阶段,都详细列出来,每个阶段都会有stage字段

我们要做的,就是确保每个阶段都能用上索引即可

如果某一阶段出现COLLSCAN,在数据量较大的情况下,都有可能导致查询缓慢。

其次,咱们需要关心keysExamined、docsExamined两个字段的值(exectionStats模式下才能看到)

  • 前者代表扫描的索引键数量,后者代表扫描的文档数量,前者越大,代表索引字段值的离散性太差
  • 后者的值越大,一般代表着没建立索引。

三:安全与权限管理

1:简单的用户创建和使用

通常为了保证数据安全性,Redis、MySQL、MQ、ES......,通常都会配置账号/密码,一来可以提高安全等级,二来还可以针对不同库、操作设置权限,极大程度上降低了数据的安全风险。

同样,在MongoDB中也支持创建账号、密码,以及分配权限,并且还支持角色的概念,可以先为角色分配权限,再为用户绑定角色,从而节省大量重复的权限分配工作。

同时,MongoDB中还内置了大量常用角色,方便于咱们快速分配权限,不过并没有默认的账号

所以想要启用MongoDB的访问控制,还需要先创建一个账号:

js 复制代码
// 1:切换到admin
use admin;

// 2:创建一个用户,并且赋予root角色(root角色只能分配给admin库)
db.createUser(
	{"user": "cui", "pwd": "123456", "roles": ["root"]}
)
// 3:退出客户端
quit;

接着再关闭MongoDB服务,重新启动时开启访问控制,必须使用账号密码连接才允许操作:

shell 复制代码
[root@~]# /soft/mongodb/bin/mongod -shutdown -f /soft/mongodb/conf/standalone/mongodb.conf
[root@~]# /soft/mongodb/bin/mongod -auth -f /soft/mongodb/conf/standalone/mongodb.conf
[root@~]# /soft/mongodb/mongosh/bin/mongosh 192.168.229.135:27017

或者这里也可以直接修改配置文件:

shell 复制代码
[root@~]# vi /soft/mongodb/conf/standalone/mongodb.conf

# 在配置文件结尾加上这两行
security:
    authorization: enabled

然后通过不带-auth的命令启动,效果同样是相同的。

接着先切换到咱们前面创建的cui库,查询一下animals集合试试看:

js 复制代码
use cui;
db.animals.find({_id:1});
MongoServerError: command find requires authentication

此时就会看到对应报错,提示目前未授权,所以无法执行命令,因此这里需要登录一下,不过登录必须要切换到admin库下才可以,否则会提示认证失败:

js 复制代码
db.auth("cui", "123456");
{ ok: 1 }

use cui;
db.animals.find({_id:1});
[ { _id: 1, name: '肥肥', age: 3, hobby: '竹子', color: '黑白色' } ]

认证成功后,再次切回cui库查询,此时会发现数据依旧可以查询出来。

当然,如果不想每次连接时都切换到admin库下登录,然后再切换回来,此时可以在cui库下再创建一个用户,如下:

js 复制代码
// 先切换到cui库
use cui;

// 再在cui库下创建一个cui用户,并分配dbOwner角色
db.createUser(
    {
        "user":"cui", 
     	"pwd":"123456", 
        // 这个dbOwner是啥,会在下面的内置角色中说明
     	"roles":[{"role":"dbOwner", "db":"cui"}]
    }
);

// 退出连接
quit;

然后可以再次连接MongoDB服务,这时直接切换到cui库下登录后,也照样可以读写数据

2:Mongo中的内置角色

可以通过下述命令来查询MongoDB所有内置角色:

JavaScript 复制代码
use admin;

db.runCommand({rolesInfo: 1, showBuiltinRoles: true});
角色 含义
root 超级管理员权限,可以执行任何操作;
read 只读用户,不允许对数据库执行写入操作;
readWrite 读写用户,允许对数据执行读写操作;
dbAdmin 数据库管理员(如创建和删除数据库),不允许读写数据;
userAdmin 用户管理员(如创建和删除用户),不允许读写数据;
dbOwner 同时拥有dbAdmin、userAdmin两个角色的权限,且允许读写数据;
backup 具有备份和恢复权限,不允许读写数据;
restore 只具有数据恢复权限,不允许读写数据;
clusterAdmin 集群超级管理员,可以执行集群中任意操作,允许读写数据;
clusterManager 集群管理员,只可以管理集群节点、配置等;
clusterMonitor 集群监视员,允许监控集群的状态和性能,不允许读写数据;

这些内置角色,可以在创建用户的时候分配,一个用户同时可以绑定多个角色。但如果你想要的权限,内置角色并不提供,也可以自定义角色

3:Mongo自定义角色

自定义角色的语法

js 复制代码
use cui;

db.createRole(
    {
    // 自定义角色的名称
    role:"xxxRole",
    // 自定义角色拥有的权限集
    privileges: [
        {
           // 自定义角色可操作的资源 
           resource:
            {
                // 当前角色可操作cui库
                db:"cui",
                // 当前角色具体可操作的集合(多个传数组,所有写"")
                collection:""
            },
            // 当前角色拥有的权限
            actions: ["find", "update", "insert", "remove"]
        }
        ],
        // 当前角色是否继承其他角色,如果指定了其他角色,当前角色自动继承父亲的所有权限
        roles: []
    }
);

通过该方式,诸位可以灵活的创建出各种适用于业务的角色,最后再附上一些相关命令:

js 复制代码
// 给指定角色增加权限 ---------> grantPrivilegesToRole
db.grantPrivilegesToRole(
    "角色名称",
    [{
        resource: {
          db: "库名",
          collection: ""
        }, 
        actions: ["权限1","......"]
    }]
);

// 回收指定角色的权限 ----------> revokePrivilegesFromRole
db.revokePrivilegesFromRole(
    "角色名称",
    [{
        resource: {
          db: "库名",
          collection: ""
        }, 
        actions: ["权限1","......"]
    }]
);

// 删除角色(要先进入角色所在的库)
use cui;
db.dropRole("角色名称");


// 查看当前库的所有角色
show roles;

// 查看当前库中所有用户
show users;
相关推荐
IvorySQL30 分钟前
PostgreSQL 分区表的 ALTER TABLE 语句执行机制解析
数据库·postgresql·开源
·云扬·40 分钟前
MySQL 8.0 Redo Log 归档与禁用实战指南
android·数据库·mysql
IT邦德43 分钟前
Oracle 26ai DataGuard 搭建(RAC到单机)
数据库·oracle
惊讶的猫1 小时前
redis分片集群
数据库·redis·缓存·分片集群·海量数据存储·高并发写
不爱缺氧i1 小时前
完全卸载MariaDB
数据库·mariadb
纤纡.1 小时前
Linux中SQL 从基础到进阶:五大分类详解与表结构操作(ALTER/DROP)全攻略
linux·数据库·sql
jiunian_cn2 小时前
【Redis】渐进式遍历
数据库·redis·缓存
橙露2 小时前
Spring Boot 核心原理:自动配置机制与自定义 Starter 开发
java·数据库·spring boot
冰暮流星2 小时前
sql语言之分组语句group by
java·数据库·sql
符哥20082 小时前
Ubuntu 常用指令集大全(附实操实例)
数据库·ubuntu·postgresql