Golang构建DevOps工具链:SSH执行器与并发控制实战
1. 为什么我劝你用Golang来写DevOps工具链1.1 从一次线上发布事故说起去年有段时间我们团队维护着一套用Python写的发布系统大概三千多行跑在四台虚拟机上。平时用着还行直到有一次大促前的周五晚上运维同学点下全量发布按钮之后整个发布流程卡在了第三步——日志采集模块因为某个节点的SSH连接超时整个进程就挂在那里不动了。没有超时重试没有并发控制后面排队的二十多个服务全部堵死。那天晚上我们手动一台台机器登录、拉代码、重启搞到凌晨三点。事后复盘问题其实不复杂Python的GIL让并发处理变得别扭异步代码写起来心智负担重部署的时候还要在目标机器上装一堆依赖。我们当时就想有没有一种语言编译出来就是一个二进制文件扔到机器上就能跑并发模型天然适合这种同时操作几百台机器的场景答案就是Golang。这不是说Python不好Python在数据处理、脚本编写上依然是王者。但DevOps这个领域有个很鲜明的特点你要写的是长期运行的服务端程序要同时跟几十上百个目标节点打交道要处理信号、要管理子进程、要做高并发IO。这些需求恰好踩在Golang的设计甜区上。1.2 Golang在DevOps场景下的四个硬核优势先说编译部署这件事。Golang编译出来是静态链接的单一二进制文件不依赖目标机器的运行时环境。你在一台机器上go build出来的东西扔到任何同架构的Linux上都能直接跑。这意味着你的发布系统本身可以用最原始的方式部署——scp过去加个systemd服务完事。不需要在每台机器上装Python、配virtualenv、处理pip依赖冲突。我见过太多团队在部署Python服务时被libssl版本问题折磨Golang从根上绕开了这个坑。再说并发模型。Golang的goroutine和channel是语言级别的原语不是库。启动一个goroutine的成本大概几KB内存你开一万个goroutine同时去连一万台机器内存占用也就几十MB。配合context包做超时控制和取消传播写出来的并发代码既安全又直观。对比一下Java的线程池要调参数、要处理线程泄漏Python的asyncio要小心事件循环阻塞Golang在这块的心智负担低得多。第三是标准库的完备程度。net/http、os/exec、os/signal、encoding/json、crypto/ssh虽然是x/crypto下的这些DevOps天天要用的东西标准库或者官方扩展库直接就有。你不需要像Node.js那样装一堆npm包然后担心某个包作者删库跑路。标准库的稳定性意味着你的工具链可以稳定运行好几年不用大改。第四是交叉编译。GOOSlinux GOARCHamd64 go build一条命令你在Mac上就能编译出Linux的二进制。CI/CD流水线里做多平台构建特别方便不需要起Docker容器或者虚拟机。1.3 这套技术栈适合谁不适合谁如果你正在维护一套发布系统、监控采集器、配置管理工具、或者任何需要跟大量远程节点打交道的服务端程序Golang是很好的选择。如果你团队里有人写过Java或者C转Golang大概一周就能上手写生产代码。但如果你要做的是数据分析、机器学习模型训练、或者快速原型验证Python依然是更好的选择。DevOps领域里也有一些场景Golang不擅长比如复杂的文本处理、跟各种奇怪的API做适配这些用Python写脚本更灵活。我的建议是混合使用核心的常驻服务用Golang写一次性的运维脚本用Python写各取所长。2. 项目骨架怎么搭从目录结构到依赖管理2.1 目录结构设计的一个实用方案很多人写Golang项目一开始就把所有文件堆在根目录main.go、handler.go、utils.go混在一起。项目小的时候还行超过两千行就开始痛苦了。我推荐一套经过实战检验的目录结构适合中等规模的DevOps项目devops-platform/ ├── cmd/ │ └── server/ │ └── main.go ├── internal/ │ ├── api/ │ │ ├── handler/ │ │ └── middleware/ │ ├── service/ │ ├── repository/ │ └── model/ ├── pkg/ │ ├── sshclient/ │ ├── executor/ │ └── logger/ ├── configs/ │ └── config.yaml ├── scripts/ ├── go.mod └── go.sumcmd/server/main.go是程序入口只做三件事加载配置、初始化依赖、启动HTTP服务。internal目录放业务逻辑Go语言规定internal下的包只能被本项目引用这是一种编译期的访问控制。pkg目录放可复用的基础组件比如SSH客户端封装、命令执行器、日志封装。configs放配置文件。这个结构的好处是职责清晰。当你要改一个API的返回格式时你知道去internal/api/handler当你要优化SSH连接池时你知道去pkg/sshclient。新人接手项目时看一眼目录就知道代码大概怎么组织的。2.2 Go Modules的实战配置Go 1.11之后官方推荐用Go Modules管理依赖。初始化很简单go mod init github.com/yourname/devops-platform但实际用起来有几个细节要注意。首先是go.mod里的Go版本声明建议写你实际使用的最低版本比如go 1.21。写太高会导致CI环境如果版本低就编译不过写太低又用不了新特性。其次是依赖版本的选择。go get默认拉最新版本但生产项目建议锁定版本go get github.com/gin-gonic/ginv1.9.1为什么要锁版本因为Golang的依赖虽然遵循语义化版本但minor版本升级偶尔也会引入不兼容变更。我遇到过gin从1.8升到1.9时某个中间件的c.AbortWithStatusJSON行为变了导致API返回格式不对。锁版本能避免这种意外。还有一个技巧是用go mod tidy清理未使用的依赖。项目开发过程中会引入一些临时依赖后来代码删了但go.mod里还留着。定期跑一下go mod tidy保持依赖清单干净。另外建议把go.sum提交到版本控制它记录了每个依赖的哈希值能防止依赖被篡改。2.3 配置管理别把配置写死在代码里DevOps项目通常要部署到多个环境开发、测试、预发、生产。每个环境的数据库地址、SSH密钥路径、日志级别都不一样。把配置写死在代码里是灾难的开始。我习惯用viper库来管理配置支持YAML文件加环境变量覆盖。配置文件长这样server: port: 8080 mode: release database: host: 127.0.0.1 port: 3306 name: devops user: root password: ${DB_PASSWORD} ssh: key_path: /etc/devops/id_rsa timeout: 10s max_connections: 100 log: level: info path: /var/log/devops/${DB_PASSWORD}这种写法让viper从环境变量读取密码避免密码明文写在配置文件里。加载配置的代码大概长这样func LoadConfig(path string) (*Config, error) { v : viper.New() v.SetConfigFile(path) v.AutomaticEnv() v.SetEnvKeyReplacer(strings.NewReplacer(., _)) if err : v.ReadInConfig(); err ! nil { return nil, fmt.Errorf(read config failed: %w, err) } var cfg Config if err : v.Unmarshal(cfg); err ! nil { return nil, fmt.Errorf(unmarshal config failed: %w, err) } return cfg, nil }注意AutomaticEnv配合SetEnvKeyReplacer之后配置项database.password会自动映射到环境变量DATABASE_PASSWORD。这个映射规则要记牢否则环境变量覆盖不生效时会排查很久。3. 核心模块拆解SSH执行器与并发控制3.1 为什么需要自己封装SSH客户端DevOps平台的核心能力之一是在远程机器上执行命令。Golang官方扩展库golang.org/x/crypto/ssh提供了SSH协议的基础实现但直接用会比较繁琐要处理认证、要建立会话、要读取输出、要处理超时。封装一层是必要的。我设计的SSH执行器接口大概是这样type Executor interface { Execute(ctx context.Context, host string, cmd string) (*Result, error) ExecuteBatch(ctx context.Context, hosts []string, cmd string) ([]*Result, error) Close() error } type Result struct { Host string Stdout string Stderr string ExitCode int Duration time.Duration Err error }Execute执行单机命令ExecuteBatch并发执行多机命令。Result结构体记录了执行结果的所有关键信息方便上层做展示和告警。3.2 连接池的设计与参数计算每次执行命令都新建SSH连接开销很大TCP握手加SSH协议协商大概要200-500毫秒。如果一次批量操作涉及100台机器光建连接就要几十秒。所以需要连接池。连接池的核心参数是最大连接数和空闲超时。最大连接数怎么定假设你的平台要管理500台机器日常批量操作最多同时操作100台那连接池大小设100就够了。设太大浪费内存每个SSH连接大概占用几十KB到几百KB内存100个连接也就几十MB。空闲超时设多少SSH服务端通常有ClientAliveInterval配置默认可能是300秒。如果客户端连接空闲超过这个时间服务端可能主动断开。所以客户端空闲超时应该小于服务端配置我一般设240秒。代码里用time.Timer实现type pooledConn struct { conn *ssh.Client lastUsed time.Time mu sync.Mutex } func (p *Pool) get(host string) (*ssh.Client, error) { p.mu.Lock() defer p.mu.Unlock() conns, ok : p.conns[host] if !ok { return p.dial(host) } for _, c : range conns { if time.Since(c.lastUsed) p.idleTimeout { c.lastUsed time.Now() return c.conn, nil } c.conn.Close() } return p.dial(host) }实操心得连接池的清理逻辑一定要用后台goroutine定期跑不能只在get的时候顺便清理。否则某台机器长时间不用连接一直占着内存不释放。我一般起一个time.Ticker每60秒扫一遍所有连接关掉超时的。3.3 并发执行与信号量控制批量执行命令时不能无限制地开goroutine。假设你要操作1000台机器每个goroutine占几KB栈内存1000个也就几MB看起来不多。但每个SSH连接在服务端也会占资源而且网络带宽是有限的。无限制并发会导致大量连接超时反而降低成功率。用带缓冲的channel做信号量是Golang里的经典模式func (e *sshExecutor) ExecuteBatch(ctx context.Context, hosts []string, cmd string) []*Result { sem : make(chan struct{}, e.maxConcurrency) results : make([]*Result, len(hosts)) var wg sync.WaitGroup for i, host : range hosts { wg.Add(1) go func(idx int, h string) { defer wg.Done() sem - struct{}{} defer func() { -sem }() results[idx] e.executeWithRetry(ctx, h, cmd) }(i, host) } wg.Wait() return results }maxConcurrency设多少合适我的经验值是50到100之间。设50的话1000台机器分20批每批假设耗时2秒总共40秒。设100的话10批20秒。但设100时网络带宽和SSH服务端压力会大一些。具体数值要根据你的网络环境和目标机器的负载能力来调。可以先设50跑一次看成功率如果成功率100%再往上加。3.4 超时控制与重试策略远程执行命令最怕的就是卡死。某台机器网络抖动SSH连接建立了但命令执行没响应如果不设超时这个goroutine就永远挂在那里。用context.WithTimeout可以解决func (e *sshExecutor) executeWithRetry(ctx context.Context, host, cmd string) *Result { var lastErr error for attempt : 0; attempt e.maxRetries; attempt { execCtx, cancel : context.WithTimeout(ctx, e.timeout) result, err : e.doExecute(execCtx, host, cmd) cancel() if err nil { return result } lastErr err select { case -ctx.Done(): return Result{Host: host, Err: ctx.Err()} case -time.After(e.retryInterval): } } return Result{Host: host, Err: lastErr} }超时时间设多少普通命令比如systemctl restart nginx10秒够了。如果是apt-get update这种可能跑几分钟的要单独设。我的做法是给Execute方法加一个可选的超时参数默认10秒特殊命令传更大的值。重试次数建议2到3次。重试间隔用指数退避第一次等1秒第二次等2秒第三次等4秒。这样既能应对瞬时网络抖动又不会在目标机器真的挂了时浪费太多时间。4. 从零到一一个完整发布流程的实现4.1 发布流程的状态机设计一个发布任务从创建到完成中间要经过多个状态。用状态机来管理是最清晰的。我定义的状态包括状态含义可流转到pending已创建等待执行running, cancelledrunning正在执行success, failed, cancelledsuccess全部步骤成功-failed某步骤失败-cancelled被用户取消-每个发布任务包含多个步骤比如拉取代码、编译、分发二进制、重启服务、健康检查。每个步骤也有自己的状态。用数据库表来存这些状态前端轮询或者用WebSocket推送状态变化。状态机的核心是不允许非法流转。比如一个已经success的任务不能再变成running。在代码里用一个map来定义合法流转var validTransitions map[Status][]Status{ StatusPending: {StatusRunning, StatusCancelled}, StatusRunning: {StatusSuccess, StatusFailed, StatusCancelled}, StatusSuccess: {}, StatusFailed: {}, StatusCancelled: {}, } func (t *Task) TransitionTo(next Status) error { allowed, ok : validTransitions[t.Status] if !ok { return fmt.Errorf(unknown status: %s, t.Status) } for _, s : range allowed { if s next { t.Status next return nil } } return fmt.Errorf(invalid transition from %s to %s, t.Status, next) }4.2 编译与分发的关键细节编译环节有个容易踩的坑交叉编译时的CGO。如果你的代码里用了CGO比如某些数据库驱动交叉编译会失败。解决办法是设置CGO_ENABLED0CGO_ENABLED0 GOOSlinux GOARCHamd64 go build -ldflags -s -w -o app ./cmd/server-ldflags -s -w的作用是去掉符号表和调试信息能把二进制体积减小30%左右。生产环境不需要调试信息加上这个参数很划算。分发环节我推荐用SCP或者SFTP。golang.org/x/crypto/ssh库里没有直接的SCP实现但可以用github.com/pkg/sftp。分发大文件时要注意分块传输不要一次性读进内存func uploadFile(client *ssh.Client, localPath, remotePath string) error { sftpClient, err : sftp.NewClient(client) if err ! nil { return err } defer sftpClient.Close() srcFile, err : os.Open(localPath) if err ! nil { return err } defer srcFile.Close() dstFile, err : sftpClient.Create(remotePath) if err ! nil { return err } defer dstFile.Close() _, err io.Copy(dstFile, srcFile) return err }io.Copy内部用的是32KB的缓冲区不会把整个文件读进内存。对于几百MB的二进制文件这个方式很稳。4.3 健康检查与自动回滚发布完成后不能直接标记成功要做健康检查。最简单的健康检查是HTTP探针请求/health接口返回200就算健康。但要注意重试服务刚重启时可能还没准备好func healthCheck(ctx context.Context, url string, retries int) error { client : http.Client{Timeout: 5 * time.Second} for i : 0; i retries; i { req, _ : http.NewRequestWithContext(ctx, GET, url, nil) resp, err : client.Do(req) if err nil resp.StatusCode 200 { resp.Body.Close() return nil } if resp ! nil { resp.Body.Close() } select { case -ctx.Done(): return ctx.Err() case -time.After(3 * time.Second): } } return fmt.Errorf(health check failed after %d retries, retries) }重试次数设5次间隔3秒总共15秒。大部分服务15秒内都能起来。如果15秒还没起来大概率是启动失败了这时候触发自动回滚。自动回滚的逻辑是保留上一版本的二进制文件发布新版本前先备份当前版本。健康检查失败时把备份的二进制恢复回去重启服务再检查一次。如果回滚后健康检查通过任务状态标记为failed但服务可用如果回滚后还是不健康那就需要人工介入了发告警。注意自动回滚不是万能的。如果新版本改了数据库schema回滚二进制可能不兼容。所以涉及数据库变更的发布建议关闭自动回滚改为人工确认。5. 踩坑实录那些文档里不会写的问题5.1 SSH连接数暴涨导致目标机器拒绝服务项目上线第一周就出了个事故。有个批量操作要同时连200台机器每台机器开5个并发goroutine总共1000个SSH连接。结果目标机器的sshd进程因为MaxStartups限制默认10:30:100意思是超过10个未认证连接开始随机拒绝超过100个全部拒绝大量连接被拒。解决办法有两个层面。客户端层面把并发数降下来200台机器分4批每批50台每台1个连接。服务端层面如果确实需要高并发可以调大目标机器的MaxStartups但这需要改所有目标机器的配置成本高。我的建议是客户端控制把maxConcurrency设保守一点。5.2 goroutine泄漏的排查方法有次发现服务运行几天后内存持续上涨从100MB涨到2GB。用pprof一看goroutine数量从几百涨到了十几万。典型的goroutine泄漏。排查goroutine泄漏第一步是加pprofimport _ net/http/pprof go func() { http.ListenAndServe(localhost:6060, nil) }()然后访问http://localhost:6060/debug/pprof/goroutine?debug2能看到所有goroutine的调用栈。找到数量最多的那个栈基本就是泄漏点。那次泄漏的原因是ExecuteBatch里用了context.WithTimeout但超时后goroutine没有正确退出。具体来说ssh.Client.Dial在超时后返回了错误但底层的TCP连接没有关闭导致读取goroutine一直阻塞在Read上。解决办法是在dial失败时显式关闭连接conn, err : net.DialTimeout(tcp, addr, timeout) if err ! nil { return nil, err } sshConn, chans, reqs, err : ssh.NewClientConn(conn, addr, config) if err ! nil { conn.Close() // 这行很关键 return nil, err }5.3 信号处理不当导致任务中断DevOps平台经常需要优雅关闭。收到SIGTERM时应该停止接受新任务等待正在执行的任务完成然后退出。如果直接os.Exit(0)正在执行的发布任务就断了可能留下半发布状态。正确的做法是用signal.Notify监听信号配合context取消func main() { ctx, cancel : context.WithCancel(context.Background()) sigCh : make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { -sigCh log.Println(shutting down...) cancel() }() srv : http.Server{Addr: :8080} go func() { if err : srv.ListenAndServe(); err ! http.ErrServerClosed { log.Fatalf(server error: %v, err) } }() -ctx.Done() shutdownCtx, shutdownCancel : context.WithTimeout(context.Background(), 30*time.Second) defer shutdownCancel() srv.Shutdown(shutdownCtx) // 等待正在执行的任务完成 taskManager.Wait() }taskManager.Wait()内部用sync.WaitGroup等待所有任务goroutine结束。给30秒的宽限期超过就强制退出。5.4 常见问题速查表问题现象可能原因排查方法解决方案SSH连接超时目标机器sshd负载高或网络不通telnet host 22测试连通性降低并发数增加重试命令执行无响应命令本身卡住或等待输入查看目标机器进程状态加超时命令加-y等非交互参数内存持续上涨goroutine泄漏或连接未关闭pprof查看goroutine数量检查超时路径的资源释放发布后服务不健康配置错误或依赖缺失查看服务日志自动回滚人工排查二进制体积过大未strip符号表ls -lh查看大小加-ldflags -s -w交叉编译失败CGO依赖看编译错误信息设CGO_ENABLED06. 性能调优与可观测性建设6.1 用pprof定位性能瓶颈Golang自带的pprof是性能调优的利器。除了前面说的goroutine profile还有CPU profile和memory profile。CPU profile的用法f, _ : os.Create(cpu.prof) pprof.StartCPUProfile(f) defer pprof.StopCPUProfile()跑一段时间后用go tool pprof cpu.prof分析。我一般先看top命令找出占用CPU最多的函数。如果发现某个函数占用超过30%就要重点优化。有一次发现json.Marshal占用了40%的CPU。原因是每次API返回都要序列化一个很大的结构体里面包含了很多不需要返回的字段。解决办法是用json:-标签排除不需要的字段或者定义一个专门的DTO数据传输对象。优化后CPU占用降到了15%。6.2 日志规范与结构化日志DevOps平台的日志很重要出问题时全靠日志排查。我推荐用zap或者logrus做结构化日志输出JSON格式方便ELK或者Loki采集。日志级别要合理使用。Debug级别用于开发调试生产环境关掉。Info记录关键操作比如开始发布任务xxx、任务xxx完成。Warn记录可恢复的异常比如重试第2次。Error记录需要人工介入的问题。每条日志要带上足够的上下文。比如记录SSH执行失败时要带上host、command、errorlogger.Error(ssh execute failed, zap.String(host, host), zap.String(command, cmd), zap.Duration(duration, duration), zap.Error(err), )这样排查时可以直接按host过滤看某台机器的所有操作记录。6.3 监控指标暴露用prometheus/client_golang暴露指标让Prometheus采集。DevOps平台需要关注的指标包括devops_task_total{statussuccess|failed}任务总数按状态分类devops_task_duration_seconds任务执行耗时直方图devops_ssh_connections{hostxxx}当前SSH连接数devops_ssh_execute_duration_secondsSSH命令执行耗时暴露指标的代码var ( taskTotal prometheus.NewCounterVec( prometheus.CounterOpts{ Name: devops_task_total, Help: Total number of tasks, }, []string{status}, ) taskDuration prometheus.NewHistogram( prometheus.HistogramOpts{ Name: devops_task_duration_seconds, Help: Task duration in seconds, Buckets: []float64{1, 5, 10, 30, 60, 120, 300}, }, ) ) func init() { prometheus.MustRegister(taskTotal, taskDuration) }Buckets的选择要根据实际耗时分布来定。如果大部分任务在10秒内完成Buckets就要在1到30之间多设几个这样分位数计算才准确。6.4 链路追踪的轻量级实现如果平台调用链比较复杂API - Service - SSH Executor - 远程命令可以考虑加链路追踪。完整的OpenTelemetry方案比较重轻量级的做法是用context传递一个trace ID每条日志都带上这个ID。type traceKey struct{} func WithTraceID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, traceKey{}, id) } func GetTraceID(ctx context.Context) string { if id, ok : ctx.Value(traceKey{}).(string); ok { return id } return }在API入口生成trace ID中间件里塞进context后续所有日志都从context取trace ID。排查问题时用trace ID一搜整个调用链的日志都出来了。这个方案实现成本低效果却很好。7. 部署与持续集成的一些实战经验7.1 用Makefile统一构建入口项目里命令多了之后容易记混。用Makefile把常用命令封装起来.PHONY: build test lint clean BINARYdevops-server VERSION$(shell git describe --tags --always) build: CGO_ENABLED0 GOOSlinux GOARCHamd64 go build \ -ldflags -s -w -X main.Version$(VERSION) \ -o bin/$(BINARY) ./cmd/server test: go test -race -cover ./... lint: golangci-lint run ./... clean: rm -rf bin/-X main.Version$(VERSION)把版本号注入到二进制里程序启动时可以打印版本方便确认部署的是哪个版本。-race开启竞态检测测试时能发现并发问题。7.2 容器化部署的注意事项虽然Golang二进制可以直接跑但用Docker部署也有好处环境隔离、资源限制、滚动更新方便。Dockerfile用多阶段构建FROM golang:1.21-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 go build -ldflags -s -w -o server ./cmd/server FROM alpine:3.18 RUN apk add --no-cache ca-certificates tzdata WORKDIR /app COPY --frombuilder /app/server . COPY configs/ ./configs/ EXPOSE 8080 CMD [./server]最终镜像大概20MB左右。ca-certificates是必须的否则HTTPS请求会失败。tzdata是为了正确处理时区。注意容器里跑SSH客户端时要确保容器能访问目标机器的22端口。如果目标机器在内网容器网络要配置正确。另外SSH密钥文件要通过volume挂载进去不要打进镜像里。7.3 CI流水线的设计CI流水线我一般分四个阶段lint、test、build、deploy。lint阶段跑golangci-lint检查代码风格和潜在bug。test阶段跑单元测试和集成测试要求覆盖率不低于60%。build阶段编译二进制并打Docker镜像。deploy阶段推送到镜像仓库然后触发部署。每个阶段失败都要快速反馈。lint和test阶段应该控制在3分钟内build阶段5分钟deploy阶段看实际情况。如果CI跑太久开发同学就会绕过CI直接提交那就失去意义了。关于测试DevOps项目的测试有个难点很多逻辑依赖远程机器。我的做法是用接口抽象测试时用mock实现。比如Executor接口生产环境用sshExecutor测试时用mockExecutor返回预设结果。这样单元测试不需要真的连机器跑得飞快。8. 一些个人体会写了两年多的Golang DevOps项目最大的感受是这门语言的设计哲学跟DevOps的需求高度契合。简单、直接、不炫技编译出来就能跑并发写起来不费劲。当然它也有不顺手的地方比如错误处理要写很多if err ! nil泛型支持来得比较晚。但这些跟它带来的部署便利和运行稳定性比起来都是可以接受的。如果你正准备用Golang写DevOps工具我的建议是从小工具开始。先写一个批量执行命令的小程序把SSH连接池、并发控制、超时重试这些基础组件打磨好。然后逐步扩展成完整的发布平台。不要一上来就设计大而全的架构那样容易陷入过度设计的陷阱。最后分享一个我常用的调试技巧在开发阶段给SSH执行器加一个dryRun模式只打印要执行的命令不实际执行。这样调试发布流程时不会真的操作目标机器安全又高效。等流程跑通了再关掉dryRun做真实测试。这个模式在代码里就是一个bool判断实现成本极低但能省下很多误操作带来的麻烦。