目录

声明:本文同步发表于 MongoDB 中文社区,传送门:
http://www.mongoing.com/archives/26201

背景

在生产环境的部署中,由于各种不确定因素的存在(比如机器掉电、网络延迟等),各节点上的系统时间很可能会出现不一致的情况。

对于MongoDB来说,时间不一致会对数据库的运行带来一些不可预估的风险,比如主从复制、定时调度都或多或少依赖于时间的取值及判断。

因此,在MongoDB集群中保持节点间的时间同步是一项重要的任务,这通常会使用一些NTP协调服务来实现。

通过人工执行的时间设定操作,或是NTP同步触发的校准,都会使当前的系统时间发生变化,这称之为时间跳变。

时间跳变对于正在运作的流程是存在影响的,尤其是副本集的复制、心跳机制。

接下来,将针对这些影响做一些分析。

一、 对 oplog 的影响

oplog 原理

oplog 是主从数据复制的纽带,主节点负责将写入数据变更记录写入到 oplog 集合,备节点则负责从oplog 中拉取增量的记录进行回放。

一个 典型的 oplog如下所示:

{
"ts" : Timestamp(1560861342, 2),
"t" : NumberLong(12),
"h" : NumberLong("7983167552279045735"),
"v" : 2,
"op" : "d",
"ns" : "app.T_AppInfo",
"o" : {
"_id" : ObjectId("5d08da9ebe3cb8c01ea48a25")
}
}

字段说明

字段名 字段描述
ts 记录时间
h 记录的全局唯一标识
v 版本信息
op 操作类型(增删改查等)
ns 操作的集合
o 操作内容
o2 待更新的文档,仅 update 操作包含

关于 oplog 的结构可以参考这篇文章

其中,ts字段 实现日志拉取的关键,这个字段保证了 oplog是节点有序的,它的构成如下:

  • 当前的系统时间,即UNIX时间至现在的秒数,32位
  • 整数计时器,不同时间值会将计数器进行重置,32位

ts字段属于Bson的Timestamp类型,这种类型一般在 MongoDB内部使用。

既然 oplog 保证了节点有序,备节点便可以通过轮询的方式进行拉取,我们通过 db.currentOp()命令可以看到具体的实现:

db.currentOp({"ns" : "local.oplog.rs"})
>
{
"desc" : "conn611866",
"client" : "192.168.138.77:51842",
"clientMetadata" : {
"driver" : {
"name" : "NetworkInterfaceASIO-RS",
"version" : "3.4.10"
}
},
"active" : true,
"opid" : 20648187,
"secs_running" : 0,
"microsecs_running" : NumberLong(519601),
"op" : "getmore",
"ns" : "local.oplog.rs",
"query" : {
"getMore" : NumberLong("16712800432"),
"collection" : "oplog.rs",
"maxTimeMS" : NumberLong(5000),
"term" : NumberLong(2),
"lastKnownCommittedOpTime" : {
"ts" : Timestamp(1560842637, 2),
"t" : NumberLong(2)
}
},
"originatingCommand" : {
"find" : "oplog.rs",
"filter" : {
"ts" : {
"$gte" : Timestamp(1560406790, 2)
}
},
"tailable" : true,
"oplogReplay" : true,
"awaitData" : true,
"maxTimeMS" : NumberLong(60000),
"term" : NumberLong(2),
"readConcern" : {
"afterOpTime" : {
"ts" : Timestamp(1560406790, 2),
"t" : NumberLong(1)
}
}
},
"planSummary" : "COLLSCAN",
}

可见,副本集的备节点是通过 ts字段不断进行增量拉取,来达到同步的目的。

图-oplog 拉取

接下来,看一下oplog与系统时间的对应关系,先通过mongo shell 写入一条数据,查看生成的oplog

shard0:PRIMARY> db.test.insert({"justForTest": true})
shard0:PRIMARY> db.getSiblingDB("local").oplog.rs.find({ns: "test.test"}).sort({$natural: -1}).limit(1).pretty()
{
"ts" : Timestamp(1560842490, 2),
"t" : NumberLong(2),
"h" : NumberLong("-1966048951433407860"),
"v" : 2,
"op" : "i",
"ns" : "test.test",
"o" : {
"_id" : ObjectId("5d088723b0a0777f7326df57"),
"justForTest" : true
}
}

此时的 ts=Timestamp(1560842490, 2),将它转换为可读的时间格式:

shard0:PRIMARY> new Date(1560842490*1000)
ISODate("2019-06-18T07:21:30Z")

同时,我们查看系统当前的时间,可以确定 oplog的时间戳与系统时间一致。

# date '+%Y-%m-%d %H:%M:%S'
2019-06-18 07:21:26

接下来,测试时间跳变对于oplog的影响

由于 oplog 是主节点产生的,下面的测试都基于主节点进行

A. 时间向后跳变

主节点上将时间往后调整到 9:00,如下:

# date -s 09:00:00
Tue Jun 18 09:00:00 UTC 2019

写入一条测试数据,检查oplog的时间戳:

shard0:PRIMARY> db.test.insert({"justForTest": true})
shard0:PRIMARY> db.getSiblingDB("local").oplog.rs.find({ns: "test.test"}).sort({$natural: -1}).limit(1).pretty()
{
"ts" : Timestamp(1560848723, 1),
"t" : NumberLong(4),
"h" : NumberLong("-6994951573635880200"),
"v" : 2,
"op" : "i",
"ns" : "test.test",
"o" : {
"_id" : ObjectId("5d08a953b9963dbc8476d6b7"),
"justForTest" : true
}
} shard0:PRIMARY> new Date(1560848723*1000)
ISODate("2019-06-18T09:05:23Z")

可以发现,随着系统时间往后调整之后,oplog的时间戳也发生了同样的变化。

B. 时间向前跳变

继续这个测试,这次在主节点上将时间往前调整到 7:00,如下:

host-192-168-138-148:~ # date -s 07:00:00
Tue Jun 18 07:00:00 UTC 2019

写入一条测试数据,检查oplog的时间戳:

shard0:PRIMARY> db.test.insert({"justForTest": true})
shard0:PRIMARY> db.getSiblingDB("local").oplog.rs.find({ns: "test.test"}).sort({$natural: -1}).limit(1).pretty()
{
"ts" : Timestamp(1560848864, 92),
"t" : NumberLong(4),
"h" : NumberLong("3290816976088149103"),
"v" : 2,
"op" : "i",
"ns" : "test.test",
"o" : {
"_id" : ObjectId("5d088c1eb9963dbc8476d6b8"),
"justForTest" : true
}
}
shard0:PRIMARY> new Date(1560848864*1000)
ISODate("2019-06-18T09:07:44Z")

问题出现了,当时间向前跳变的时候,新产生的oplog时间戳并没有如预期一样和系统时间保持一致,而是停留在了时间跳变前的时刻!

这是为什么呢?

我们在前面提到过,oplog需要保证节点有序性,这分别是通过Unix时间戳(秒)和计数器来保证的。

因此,当系统时间值突然变小,就必须将当前时刻冻结住,通过计数器(Term)自增来保证顺序。

这样就解释了oplog时间戳停顿的问题,然而,新问题又来了:

计数器是有上限的,如果时间向前跳变太多,或者是一直向前跳变,导致计数器溢出怎么办呢?

从保证有序的角度上看,这是不被允许的,也就是当计数器(Term)溢出后将再也无法保证有序了。

从MongoDB 3.4的源码中,可以看到对应的实现如下:

global_timestamp.cpp


//获取下一个时间戳
Timestamp getNextGlobalTimestamp(unsigned count) { //系统时间值
const unsigned now = durationCount<Seconds>(
getGlobalServiceContext()->getFastClockSource()->now().toDurationSinceEpoch());
...
// 对当前上下文的Timestamp 自增计数
auto first = globalTimestamp.fetchAndAdd(count);
auto currentTimestamp = first + count; // What we just set it to.
unsigned globalSecs = Timestamp(currentTimestamp).getSecs(); // 若上下文时间大于系统时间,且同一时刻的计数器 超过2^31-1(2147483647)时,进行报错
if (MONGO_unlikely(globalSecs > now) && Timestamp(currentTimestamp).getInc() >= 1U << 31) {
mongo::severe() << "clock skew detected, prev: " << globalSecs << " now: " << now;
fassertFailed(17449);
}

从代码上看,计数器在超过21亿后会发生溢出,该时间窗口的计算参考如下:

假设数据库吞吐量是1W/s,不考虑数据均衡等其他因素的影响,每秒钟将需要产生1W次oplog,那么窗口值为:

(math.pow(2,31)-1)/10000/3600 = 59h

也就是说,我们得保证系统时间能在59个小时内追赶上最后一条oplog的时间。

二、主备倒换

在副本集的高可用架构中,提供了一种自动Failover机制,如下:

图-Failover

简单说就是节点之间通过心跳感知彼此的存在,一旦是备节点感知不到主节点,就会重新选举。

在实现上,备节点会以一定间隔(大约2s)向其他节点发送心跳,同时会启动一个选举定时器,这个定时器是实现故障转移的关键:

  • 选举定时器的预设时间被设为10s(实际值为10-12s之间),
  • 在定时器时间到达时会触发一个回调函数,这个函数中备节点会主动发起选举,并接管主节点的角色。
  • 每次向主节点心跳成功后都会取消选举定时器的执行,并重新发起新的选举定时器

因此,在正常情况下主节点一直是可用的,选举定时器回调会被一次次的取消,而只有在异常的情况下,备节点才会主动进行"夺权",进而发生主备切换。

那么,接着上面的问题,系统时间的跳变是否会影响这个机制呢?我们来做一下测试:

自动Failover的逻辑由备节点主导,因此下面的测试都基于备节点进行

A. 时间向前跳变

我们在备节点上将时间调前一个小时,如下:

# date
Tue Jun 18 09:00:12 UTC 2019
# date -s 08:00:00
Tue Jun 18 08:00:00 UTC 2019

然后通过db.isMaster()检查主备的关系:

shard0:SECONDARY> db.isMaster()
{
"hosts" : [
"192.168.138.77:30071",
"192.168.138.148:30071",
"192.168.138.55:30071"
],
"setName" : "shard0",
"setVersion" : 1,
"ismaster" : false,
"secondary" : true,
"primary" : "192.168.138.148:30071",
"me" : "192.168.138.55:30071",
...
"readOnly" : false,
"ok" : 1
} === 没有发生变化,仍然是备节点 shard0:SECONDARY>

结果是在时间往前调整后,主备关系并没有发生变化,从日志上也没有发现任何异常。

B. 时间向后跳变

接下来,在这个备节点上将时间往后调一个小时,如下:

# date
Tue Jun 18 08:02:45 UTC 2019
# date -s 09:00:00
Tue Jun 18 09:00:00 UTC 2019

这时候进行检查则发现了变化,当前的备节点成为了主节点!


shard0:SECONDARY> db.isMaster()
{
"hosts" : [
"192.168.138.77:30071",
"192.168.138.148:30071",
"192.168.138.55:30071"
],
"setName" : "shard0",
"setVersion" : 1,
"ismaster" : true,
"secondary" : false,
"primary" : "192.168.138.55:30071",
"me" : "192.168.138.55:30071",
"electionId" : ObjectId("7fffffff0000000000000008"),
...
"readOnly" : false,
"ok" : 1
} === 发生变化,切换为主节点 shard0:PRIMARY>

在数据库日志中,同样发现了发起选举的行为,如下:

I REPL     [ReplicationExecutor] Starting an election, since we've seen no PRIMARY in the past 10000ms
I REPL [ReplicationExecutor] conducting a dry run election to see if we could be elected
I REPL [ReplicationExecutor] VoteRequester(term 7 dry run) received a yes vote from 192.168.138.77:30071; response message: { term: 7, voteGranted: true, reason: "", ok: 1.0 }
I REPL [ReplicationExecutor] dry election run succeeded, running for election
I REPL [ReplicationExecutor] VoteRequester(term 8) received a yes vote from 192.168.138.77:30071; response message: { term: 8, voteGranted: true, reason: "", ok: 1.0 }
I REPL [ReplicationExecutor] election succeeded, assuming primary role in term 8
I REPL [ReplicationExecutor] transition to PRIMARY
I REPL [ReplicationExecutor] Entering primary catch-up mode.
I REPL [ReplicationExecutor] Caught up to the latest optime known via heartbeats after becoming primary.
I REPL [ReplicationExecutor] Exited primary catch-up mode.
I REPL [rsBackgroundSync] Replication producer stopped after oplog fetcher finished returning a batch from our sync source. Abandoning this batch of oplog entries and re-evaluating our sync source.
I REPL [SyncSourceFeedback] SyncSourceFeedback error sending update to 192.168.138.148:30071: InvalidSyncSource: Sync source was cleared. Was 192.168.138.148:30071
I REPL [rsSync] transition to primary complete; database writes are now permitted
I REPL [ReplicationExecutor] Member 192.168.138.148:30071 is now in state SECONDARY

确实,在备节点的系统时间往后跳变时,发生了主备切换!

那么问题出在哪里? 是不是只要是时间往后调整就一定会切换呢?

下面,我们尝试从3.4的源代码中寻求答案:

选举定时器是由 ReplicationCoordinatorImpl这个类实现的,看下面这个方法:

代码位置:db/repl/replication_coordinator_impl_heartbeat.cpp***

void ReplicationCoordinatorImpl::_cancelAndRescheduleElectionTimeout_inlock() {

    //如果上一个定时器回调存在,则直接取消
if (_handleElectionTimeoutCbh.isValid()) {
_replExecutor.cancel(_handleElectionTimeoutCbh);
..
}
... //触发调度,when时间点为 now + electionTimeout + randomOffset
//到了时间就执行_startElectSelfIfEligibleV1函数,发起选举
_handleElectionTimeoutCbh =
_scheduleWorkAt(when,
stdx::bind(&ReplicationCoordinatorImpl::_startElectSelfIfEligibleV1,this,
StartElectionV1Reason::kElectionTimeout));
}

ReplicationExecutor::_scheduleWorkAt 是定时器调度的入口,负责将定时器回调任务写入队列,如下:

代码位置:db/repl/replication_executor.cpp

StatusWith<ReplicationExecutor::CallbackHandle> ReplicationExecutor::scheduleWorkAt(
Date_t when, const CallbackFn& work) {
stdx::lock_guard<stdx::mutex> lk(_mutex);
WorkQueue temp;
StatusWith<CallbackHandle> cbHandle = enqueueWork_inlock(&temp, work);
... WorkQueue::iterator insertBefore = _sleepersQueue.begin();
//根据调度时间找到插入位置
while (insertBefore != _sleepersQueue.end() && insertBefore->readyDate <= when)
++insertBefore;
//将任务置入_sleepersQueue队列
_sleepersQueue.splice(insertBefore, temp, temp.begin());
...
return cbHandle;
}

对于队列任务的处理是在主线程实现的,通过getWork方法循环获取任务后执行:

//运行线程 -- 持续获取队列任务
void ReplicationExecutor::run() {
...
//循环获取任务执行
while ((work = getWork()).first.callback.isValid()) {
//发起任务..
}
} //获取可用的任务
ReplicationExecutor::getWork() {
stdx::unique_lock<stdx::mutex> lk(_mutex);
while (true) {
//取当前时间
const Date_t now = _networkInterface->now();
//将_sleepersQueue队列中到时间的任务置入_readyQueue队列(唤醒)
Date_t nextWakeupDate = scheduleReadySleepers_inlock(now);
//存在任务执行,跳出循环
if (!_readyQueue.empty()) {
break;
} else if (_inShutdown) {
return std::make_pair(WorkItem(), CallbackHandle());
}
lk.unlock();
//没有合适的任务,继续等待
if (nextWakeupDate == Date_t::max()) {
_networkInterface->waitForWork();
} else {
_networkInterface->waitForWorkUntil(nextWakeupDate);
}
lk.lock();
}
//返回待执行任务
const WorkItem work = *_readyQueue.begin();
return std::make_pair(work, cbHandle);
} //将到时间的任务唤醒,写入_readyQueue队列
Date_t ReplicationExecutor::scheduleReadySleepers_inlock(const Date_t now) {
WorkQueue::iterator iter = _sleepersQueue.begin();
//从头部开始,找到最后一个调度时间小于等于当前时间(需要执行)的任务
while ((iter != _sleepersQueue.end()) && (iter->readyDate <= now)) {
auto callback = ReplicationExecutor::_getCallbackFromHandle(iter->callback);
callback->_isSleeper = false;
++iter;
}
//转移队列
_readyQueue.splice(_readyQueue.end(), _sleepersQueue, _sleepersQueue.begin(), iter);
if (iter == _sleepersQueue.end()) {
// indicate no sleeper to wait for
return Date_t::max();
}
return iter->readyDate;
}

从上面的代码中,可以看到 scheduleReadySleepers_inlock 方法是关于任务执行时机判断的关键,在它的实现逻辑中,会根据任务调度时间与当前时间(now)的比对来决定是否执行

关于当前时间(now)的获取则来自于AsyncTimerFactoryASIO的一个方法,当中则是利用 asio库的system_timer获取了系统时钟。

至此,我们基本可以确定了这个情况:
由于系统时间向后跳变,会导致定时器的调度出现误判,其中选举定时器被提前执行了!

更合理的一个实现应该是采用硬件时钟的周期而不是系统时间。

那么,剩下的一个问题是,系统时间是不是一旦向后跳就会出现切换呢?

根据前面的分析,每次心跳成功后都会启用这个选举定时器,触发的时间被设定在10-12s之后,而心跳的间隔是2s,

于是我们可以估算如下:

如果系统时间往后跳的步长能控制在 8s以内则是安全的,而一旦超过12s则一定会触发切换

下面是针对步长测试的一组结果:

//往后切2s
date -s `date -d "2 second" +"%H:%M:%S"`
>> 结果:主备不切换 //往后切5s
date -s `date -d "5 second" +"%H:%M:%S"`
>> 结果:主备不切换 //往后切7s
date -s `date -d "7 second" +"%H:%M:%S"`
>> 结果:主备不切换 //往后切10s
date -s `date -d "10 second" +"%H:%M:%S"`
>> 结果:主备偶尔切换 //往后切13s
date -s `date -d "13 second" +"%H:%M:%S"`
>> 结果:主备切换 //往后切20s
date -s `date -d "20 second" +"%H:%M:%S"`
>> 结果:主备切换

小结

经过上面的一些测试和分析,我们知道了时间跳变对于副本集确实会造成一些问题:

  • 对于oplog复制的影响,时间向前跳变会导致出现"计时器堆积",如果未及时处理,将导致溢出从而引发错误;
  • 对于自动Failover的影响,时间向后跳变则会造成干扰,很可能导致主备切换及业务的抖动。

    尤其是第二点,MongoDB 3.4及以下版本都会存在该问题,而3.6/4.0 版本经验证已经解决。

那么,为了最大限度降低影响,提供几点建议:

  1. 分布式集群中务必采用可靠的NTP服务保证各节点上的时间同步,对于时间同步做好告警检测并保证能及时解决异常;
  2. 重大的时间校准,采用小步长(比如1分钟内3-5s)的方式逐步渐渐达到最终同步,这样可以避免主备切换产生的业务影响。
  3. 升级到3.6/4.0 或更新的版本来规避时间跳变导致选举的问题。

作者: 美码师(zale)

@All开发者,想获取满满的技术干货吗?想了解最前沿的技术洞察吗?想得到最权威的学习认证吗?还有多维的交流平台以及有趣的有奖互动?

2020年华为开发者大会将于2月11-12日在深圳举办,这将是华为面向开发者群体的最顶级盛会,包含但不限于华为在云计算、人工智能、5G、IoT等多个领域,特别是智能计算双引擎鲲鹏和昇腾的最新创新与最佳实践,充满期待对吧,欢迎报名预约

【华为云分享】MongoDB-系统时钟跳变引发的风波的更多相关文章

  1. MongoDB-系统时钟跳变引发的风波

    目录 背景 一. 对 oplog 的影响 oplog 原理 二.主备倒换 小结 声明:本文同步发表于 MongoDB 中文社区,传送门: http://www.mongoing.com/archive ...

  2. 【直播分享】实现LOL小地图英雄头像分析案例【华为云分享】

    直播介绍: 当今时代是人工智能高速发展的时代,深度学习已经渗透入经济.工业.军事.娱乐等各各领域的角落.近年来AlphaGo击败李世石更是使得人工智能技术家喻户晓.人工智能在游戏领域的开发依然不断进步 ...

  3. 了解 MongoDB 看这一篇就够了【华为云分享】

    目录 一.简介 二.基本模型 BSON 数据类型 分布式ID 三.操作语法 四.索引 索引特性 索引分类 索引评估.调优 五.集群 分片机制 副本集 六.事务与一致性 一致性 小结 一.简介 Mong ...

  4. MongoDB一次节点宕机引发的思考(源码剖析)【华为云分享】

    目录 简介 日志分析 副本集 如何实现 Failover 心跳的实现 electionTimeout 定时器 业务影响评估 参考链接 声明:本文同步发表于 MongoDB 中文社区,传送门:http: ...

  5. Linux系统通过FTP进行文档基本操作【华为云分享】

    [摘要] Linux系统里通过FTP可以对文档进行上传,更改权限和基本的文档管理. 获得Linux系统后,不熟悉命令操作的情况下,可以通过FTP工具进行文档操作,下面以WinSCP工具为例进行讲解: ...

  6. 【Python成长之路】python 基础篇 -- 装饰器【华为云分享】

    [写在前面] 有时候看到大神们的代码,偶尔会用到@来装饰函数.当时查了资料,大致了解装饰器一般用于在不改变原函数的基础上 ,对原函数功能进行修改/增强.使用场景是:日志级别设置.权限校验.性能测试等. ...

  7. Vue-Router中History模式【华为云分享】

    [摘要] vue-router的history模式的服务端支持 示例代码托管在:http://www.github.com/dashnowords/blogs 博客园地址:<大史住在大前端> ...

  8. Vue+ElementUI项目使用webpack输出MPA【华为云分享】

    [摘要] Vue+ElementUI多页面打包改造 示例代码托管在:http://www.github.com/dashnowords/blogs 博客园地址:<大史住在大前端>原创博文目 ...

  9. RDS关系型数据库 入门 01 创建关系型数据库实例【华为云分享】

    [摘要] 关系型数据库(Relational Database Service,简称RDS)是一种基于云计算平台的即开即用.稳定可靠.弹性伸缩.便捷管理的在线关系型数据库服务.RDS具有完善的性能监控 ...

随机推荐

  1. [考试反思]1019csp-s模拟测试80(a):天遣

    A组题,所以把榜粘全了. 第6名,被卡在刚好正中间. 我最近干什么伤天害理的事了?(例如说没有在skyh去上厕所的时候捶他) 上来看T1,非常贴心出题人直接把递推式子给你了,然后就和斐波数的递推一样了 ...

  2. [考试反思]1016csp-s模拟测试76:自知

    要打对拍. 要打对拍. 要打对拍. 要手模数据. 要手模数据. 要手模数据. 不要相信样例. 不要相信样例. 不要相信样例. 不要飘. 不要飘. 不要飘. 跟skyh学坏了.最近不打对拍. 连续十几次 ...

  3. m76 赛后总结

    这次没有炸的太厉害,只是T3崩了,而且..... 这次的心态并没有因为loj的大吉而崩,反而在经受过上一轮的打击之后变得坚强了,心态也平了,没什么可挂念的,因为我什么都没有,所以发扬光脚的不怕穿鞋的精 ...

  4. mysql批量更新写法

    mysql批量更新写法<pre> $namedmp=filter($_POST['namedmp']); $namedsp=filter($_POST['namedsp']); $name ...

  5. K8S入门系列之集群二进制部署-->master篇(二)

    组件版本和配置策略 组件版本 Kubernetes 1.16.2 Docker 19.03-ce Etcd 3.3.17 https://github.com/etcd-io/etcd/release ...

  6. 重写equals方法,也应该重写hashcode方法,反之亦然

    yls 2019年11月07日 一方面 hashcode原则:两个对象equals相等,hashcode值一定相等 默认的hashcode是Object类通过对象的内存地址得到的 若重写equals而 ...

  7. [Office] Resources for Office Development

    Office 2013 Document (.chm) download page: http://www.microsoft.com/en-us/download/details.aspx?id=4 ...

  8. SqlServer2005 查询 第七讲 order by

    今天我们来讲sql命令中的参数order by的用法 order by order by:可以理解成[以某个字段排序] order by a,b // a和b都按升序 order by a,b des ...

  9. 一.web服务机制

    web服务机制 我们先跟着**(Web服务器工作原理总体描述01)这张图,将一次Web服务的工作流程过一遍,我们假设以浏览器作为客户端(1) 用户做出了一个操作,可以是填写网址敲回车,可以是点击链接, ...

  10. python:调用bash

    利用os模块 python调用Shell脚本,有三种方法: os.system(cmd)返回值是脚本的退出状态码 os.popen(cmd)返回值是脚本执行过程中的输出内容 commands.gets ...