1. 项目概述:为什么健康检查不是“配个探针就完事”的小事
gRPC 自定义健康检查,这六个字背后藏着的是微服务架构落地过程中最常被轻视、却最容易在凌晨三点把你从床上拽起来的硬骨头。我见过太多团队,在服务刚上线时用默认的health.proto生成一个简单接口,配上 Kubernetes 的grpcHealthProbe就以为万事大吉;结果在灰度发布时,新版本服务明明已启动,但流量却迟迟不切过去——因为健康检查返回了SERVING,可实际业务逻辑里有个依赖的 Redis 连接池还没初始化完成;也见过某次数据库主从切换后,服务端口通、gRPC 健康接口返回SERVING,但所有下游调用都卡在UNAVAILABLE,查了两小时才发现健康检查压根没校验数据库连接状态。这些都不是理论风险,是我在三个不同规模系统里亲手踩过的坑。
所谓“自定义”,核心就两点:检查什么,和怎么判断。默认的 gRPC 健康服务只回答“我这个进程活着吗”,而真实生产环境需要回答的是“我这个服务能正确处理业务请求吗”。它要检查的远不止端口是否监听、进程是否存活,而是数据库连接是否可用、缓存集群是否响应正常、下游关键依赖是否在线、本地配置加载是否完整、甚至某个核心内存队列是否未满载。gRPC 健康检查协议本身是轻量级的(基于 unary RPC,无流式),但它承载的语义必须足够重——重到能真实反映服务的业务就绪状态。
这个内容适合三类人:第一类是正在将单体应用拆分为 gRPC 微服务的后端工程师,你马上会遇到服务发现与流量调度的精准性问题;第二类是负责 SRE 或平台基建的同事,你需要为整个团队提供一套可复用、可观测、可告警的健康检查基线;第三类是刚接触云原生可观测性的初中级开发者,它是一扇极佳的窗口——通过一个看似简单的接口,你能串起协议设计、服务生命周期、依赖治理、K8s 集成、指标埋点等一整套工程实践。它不炫技,但极其务实;不复杂,但容错率极低。一次错误的健康状态返回,可能直接导致雪崩。
2. 核心设计思路:从“进程存活”到“业务就绪”的三层跃迁
2.1 默认健康检查的局限性:为什么不能只靠 /healthz
gRPC 官方推荐的健康检查方案基于 grpc-health-probe 工具,其底层调用的是grpc.health.v1.Health/Check方法。这个方法的请求体是一个空消息,响应体包含一个status字段(SERVING/NOT_SERVING/UNKNOWN)和一个可选的status_details字符串。它的设计哲学非常朴素:只要服务进程能响应这个 RPC 调用,就认为它“可服务”。
但这种朴素在生产环境里很快就会碰壁。我们来拆解它的三个硬伤:
零上下文感知:它不关心当前服务是否已完成初始化。比如一个 gRPC Server 启动后立即注册了 HealthService,但此时数据库连接池还在
Connecting...状态,Redis 客户端尚未完成集群拓扑拉取,配置中心的动态配置也还没监听成功。此时/healthz返回SERVING,K8s 却已把流量导过来,首请求必然失败。无依赖穿透能力:它无法表达“我依赖的 A 服务 OK,但 B 服务暂时不可用,因此我当前只能降级提供部分功能”。默认协议里没有字段支持返回多级依赖状态,更不支持返回具体哪个依赖出问题、错误码是什么、重试建议如何。
无状态快照机制:它每次调用都是实时执行检查逻辑。如果检查逻辑本身耗时较长(比如真去连一次数据库),高频探测(如 K8s liveness 探针每 5 秒一次)会成为服务负担;如果检查逻辑做了缓存,又面临状态过期问题——上一秒缓存说 DB OK,下一秒主库宕机,缓存还没刷新。
所以,“自定义”的第一步,不是写代码,而是明确设计边界:我们要构建的不是一个“开关”,而是一个带上下文、可分级、有缓存策略的健康状态快照系统。
2.2 三层健康状态模型:Liveness、Readiness、Business Readiness
我们最终落地的模型,是将健康状态拆解为三个正交维度,每个维度解决不同问题,且可独立配置探测策略:
| 维度 | 检查目标 | 触发时机 | K8s 探针类型 | 典型检查项 |
|---|---|---|---|---|
| Liveness | 进程是否卡死、是否陷入死循环、是否 OOM | 定期强制检测(如每 10 秒) | livenessProbe | Go runtime goroutine 数是否暴增、heap alloc 是否持续增长、关键 goroutine 是否 hang 住 |
| Readiness | 服务是否完成初始化、是否准备好接收流量 | 启动后首次检查 + 定期轮询(如每 5 秒) | readinessProbe | gRPC Server 是否已 Start、所有 gRPC Service 是否已 Register、本地配置是否加载完毕、gRPC Client 连接池是否 Ready |
| Business Readiness | 核心业务链路是否真正可用 | 按需触发(如每次流量接入前、或定时 30 秒) | 不直接映射 K8s 探针,需通过 sidecar 或自定义 Operator | 主库连接是否可用、缓存集群是否响应 < 50ms、下游关键 gRPC 服务Check是否返回SERVING、本地限流器是否未触发熔断 |
这个分层不是拍脑袋定的。它直接对应 K8s 的两个原生探针语义,并额外扩展出业务层——因为 K8s 的readinessProbe本质仍是基础设施视角(端口通、进程活),而业务视角的“就绪”,必须由业务代码自己定义。
提示:很多团队把所有检查都塞进
readinessProbe,导致 K8s 在服务启动初期反复重启(因为 DB 连接慢,probe 失败)。正确做法是:livenessProbe只做最轻量的进程心跳;readinessProbe做服务框架层就绪;真正的业务依赖检查,放在Business Readiness层,由服务网关或流量管理组件在路由前调用,而非让 K8s 直接承担。
2.3 自定义协议设计:为什么不用原生 health.proto,而要重定义
官方health.proto的CheckResponse只有一个status字段,这在工程实践中严重不足。我们基于此扩展了一个v1alpha1.HealthCheckResponse,核心新增字段如下:
message HealthCheckResponse { // 原生 status,保持兼容 Status status = 1; // 新增:详细状态摘要,供人眼快速定位 string summary = 2; // 新增:结构化依赖状态列表,支持嵌套 repeated DependencyStatus dependencies = 3; // 新增:检查耗时,用于性能分析 int64 check_duration_ms = 4; // 新增:时间戳,用于判断状态新鲜度 int64 timestamp_unix_ms = 5; // 新增:自定义业务指标快照(可选) map<string, string> business_metrics = 6; } message DependencyStatus { string name = 1; // 依赖名,如 "mysql-primary", "redis-cluster" Status status = 2; // 该依赖自身状态 string error_message = 3; // 错误详情,仅当 status != SERVING 时填充 int64 latency_ms = 4; // 本次探测延迟 string version = 5; // 依赖版本(如 MySQL 8.0.33) }这个设计解决了三个关键问题:
可读性:
summary字段用一句话概括整体状态,比如 “DB: OK (23ms), Redis: TIMEOUT (3200ms), Config: OK —— 降级模式启用”,运维看一眼日志就能知道发生了什么。可追溯性:
dependencies列表明确列出每个依赖的状态、延迟、错误,排查时无需再翻 N 个日志文件。可观测性:
check_duration_ms和timestamp_unix_ms让我们可以绘制健康检查延迟曲线,及时发现 DB 连接池耗尽、DNS 解析变慢等底层问题。
注意:不要为了“看起来高级”而堆砌字段。我们曾在一个早期版本里加了
suggestion字段(自动给出修复建议),结果发现建议往往过于笼统(如“请检查数据库连接”),反而干扰判断,上线两周后就删掉了。工程决策的核心是:这个字段是否在 90% 的故障现场,能帮值班同学节省超过 30 秒的排查时间?如果不是,就砍掉。
3. 核心实现细节:从代码到部署的全链路实操
3.1 Go 语言实现:一个可嵌入、可热更新的健康检查引擎
我们使用 Go 实现健康检查服务,核心在于解耦检查逻辑与 HTTP/gRPC 协议层。以下是关键结构体设计:
// HealthChecker 是检查器的统一接口 type HealthChecker interface { // Name 返回检查器唯一标识,用于 dependencies.name Name() string // Check 执行一次检查,返回状态、错误、延迟 Check(ctx context.Context) (Status, error, time.Duration) // IsCritical 表示该检查是否为关键依赖(影响 overall status) IsCritical() bool } // CompositeChecker 是组合检查器,聚合多个子检查器 type CompositeChecker struct { checkers []HealthChecker // 缓存策略:TTL(毫秒),0 表示不缓存 cacheTTL time.Duration // 缓存状态 mu sync.RWMutex cache map[string]cacheItem } type cacheItem struct { status Status err error latency time.Duration timestamp time.Time }为什么用组合模式而非继承?
因为业务依赖千差万别:有的需要连 DB(耗时 10~200ms),有的只需读本地文件(< 1ms),有的要调用下游 gRPC(网络抖动敏感)。组合模式允许我们为每个依赖创建独立的HealthChecker实现,互不影响,且可单独启停、配置超时、设置缓存 TTL。
一个典型的 MySQL 检查器实现如下:
type MySQLChecker struct { db *sql.DB name string // 可配置:是否执行 SELECT 1,还是只 Ping doQuery bool // 可配置:查询超时 timeout time.Duration } func (c *MySQLChecker) Name() string { return c.name } func (c *MySQLChecker) IsCritical() bool { return true } func (c *MySQLChecker) Check(ctx context.Context) (Status, error, time.Duration) { start := time.Now() ctx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() var err error if c.doQuery { // 执行轻量查询 err = c.db.QueryRowContext(ctx, "SELECT 1").Scan(new(int)) } else { // 仅 Ping 连接池 err = c.db.PingContext(ctx) } latency := time.Since(start) if err != nil { return NOT_SERVING, fmt.Errorf("mysql ping failed: %w", err), latency } return SERVING, nil, latency }关键技巧:缓存策略的实战选择
- 对于
liveness类检查(如 goroutine 数),绝不缓存,必须实时。 - 对于
readiness类检查(如配置加载),缓存 30 秒,避免启动时高频重复加载。 - 对于
business readiness类检查(如 DB 连接),缓存 5 秒,平衡新鲜度与性能。我们实测过:DB 连接中断后,5 秒内发现并切走流量,对用户几乎无感;而 1 秒缓存会导致 probe 高频打满 DB 连接池。
3.2 gRPC 服务端集成:如何让 Check 方法真正“懂业务”
在 gRPC Server 中注册自定义健康服务,关键不是“暴露接口”,而是让 Check 方法能拿到所有已注册的 HealthChecker 实例。我们采用依赖注入方式:
// 在服务启动时,构建 CompositeChecker 并注入 func NewGRPCServer() *grpc.Server { checker := &CompositeChecker{ cacheTTL: 5 * time.Second, } // 注册所有检查器 checker.Register(&MySQLChecker{db: myDB, name: "mysql-primary", doQuery: true}) checker.Register(&RedisChecker{client: redisClient, name: "redis-cluster"}) checker.Register(&ConfigChecker{loader: configLoader}) server := grpc.NewServer() // 注册自定义健康服务 healthv1alpha1.RegisterHealthCheckServiceServer(server, &HealthCheckServer{ checker: checker, }) return server } // HealthCheckServer 实现 gRPC 接口 type HealthCheckServer struct { checker *CompositeChecker } func (s *HealthCheckServer) Check(ctx context.Context, req *healthv1alpha1.CheckRequest) (*healthv1alpha1.HealthCheckResponse, error) { // 1. 执行所有检查器 results := s.checker.RunAll(ctx) // 2. 计算 overall status:只要一个 critical 为 NOT_SERVING,则 overall 为 NOT_SERVING overallStatus := SERVING var summaryParts []string for _, r := range results { if r.Checker.IsCritical() && r.Status == NOT_SERVING { overallStatus = NOT_SERVING } summaryParts = append(summaryParts, fmt.Sprintf("%s: %s (%dms)", r.Checker.Name(), r.Status.String(), r.Latency.Milliseconds())) } // 3. 构建响应 return &healthv1alpha1.HealthCheckResponse{ Status: overallStatus, Summary: strings.Join(summaryParts, ", "), Dependencies: convertToPB(results), CheckDurationMs: time.Since(start).Milliseconds(), TimestampUnixMs: time.Now().UnixMilli(), }, nil }这里有个极易被忽略的细节:CheckRequest的service字段。官方协议中,service字段用于指定检查某个子服务(如"/myservice.UserService"),但我们的业务服务是单体 gRPC Server,不按 service 分片。于是我们重载了这个字段语义:service: "liveness"表示只运行 liveness 类检查器;service: "readiness"表示只运行 readiness 类;空字符串则运行全部。这样,K8s 的livenessProbe就可以精准调用?service=liveness,避免不必要的 DB 探测。
3.3 Kubernetes 集成:让 K8s 真正理解你的健康语义
K8s 1.23+ 原生支持 gRPC 探针,但配置远比 HTTP 复杂。以下是生产环境验证过的 YAML 片段:
livenessProbe: grpc: # 必须指定 port,且该 port 必须在 containerPorts 中声明 port: 9000 # 传递 service 字段,触发 liveness 模式 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 readinessProbe: grpc: port: 9000 # 传递 readiness 模式 initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 2 # 关键:failureThreshold 设为 1,确保首次失败就摘流量 failureThreshold: 1为什么livenessProbe的failureThreshold是 3,而readinessProbe是 1?
liveness失败意味着容器要重启,过于敏感会导致频繁震荡。设为 3,表示连续 3 次失败(30 秒)才重启,给 GC、慢查询等临时卡顿留出空间。readiness失败意味着立刻摘流量,必须零容忍。设为 1,第一次探测失败就从 Endpoints 中移除,避免流量打到半死不活的服务上。
注意:
timeoutSeconds必须严格大于检查逻辑的最大预期耗时。我们 MySQL 检查器设置了 2 秒超时,K8s probe 的timeoutSeconds就必须 ≥ 3 秒,否则 probe 自身超时会先于业务检查返回,导致误判。
3.4 指标与告警:把健康状态变成可行动的数据
光有接口不够,必须让健康状态进入监控体系。我们在健康检查响应中埋点,并同步上报 Prometheus:
// 在 Check 方法末尾添加 func (s *HealthCheckServer) Check(...) { // ... 执行检查 ... // 上报指标 for _, r := range results { healthCheckLatency.WithLabelValues( r.Checker.Name(), r.Status.String(), ).Observe(r.Latency.Seconds()) healthCheckSuccess.WithLabelValues( r.Checker.Name(), r.Status.String(), ).Inc() } // 整体状态也上报 healthOverallStatus.WithLabelValues(overallStatus.String()).Set(1) }对应的 Prometheus 告警规则(Alertmanager):
- alert: ServiceHealthCriticalDown expr: sum by (job) (health_overall_status{status="NOT_SERVING"}) > 0 for: 1m labels: severity: critical annotations: summary: "Critical service health down" description: "{{ $labels.job }} has been in NOT_SERVING state for more than 1 minute" - alert: DependencyLatencyHigh expr: histogram_quantile(0.95, sum(rate(health_check_latency_seconds_bucket[5m])) by (le, dependency)) > 2 for: 5m labels: severity: warning annotations: summary: "Dependency {{ $labels.dependency }} latency high" description: "95th percentile latency of {{ $labels.dependency }} is {{ $value }}s, above 2s threshold"这套告警的价值在于:它不再告诉你“服务挂了”,而是告诉你“MySQL 主库延迟飙升,已持续 5 分钟”,运维同学收到告警,第一反应是去看数据库监控,而不是先 ssh 登服务器。
4. 实战问题排查:那些文档里不会写的血泪教训
4.1 问题速查表:高频故障现象与根因定位
| 现象 | 可能根因 | 快速验证命令 | 解决方案 |
|---|---|---|---|
| K8s 不断重启 Pod,但 /healthz 手动 curl 返回 SERVING | livenessProbe的timeoutSeconds小于健康检查实际耗时,导致 probe 超时判定失败 | kubectl logs <pod> -c <container> | grep "health check"查看日志中检查耗时;kubectl describe pod <pod>查看最后 probe 失败原因 | 增加timeoutSeconds,或优化检查逻辑(如 MySQL 检查改用Ping而非Query) |
| 服务启动后长时间处于 Pending 状态,Endpoints 无 IP | readinessProbe首次探测失败,且initialDelaySeconds设置过短,服务尚未完成初始化 | kubectl get events --field-selector involvedObject.name=<pod>查看 probe failed 事件;kubectl exec -it <pod> -- /bin/sh -c "grpc_health_probe -addr=:9000 -rpc-timeout=5s"手动探测 | 增加initialDelaySeconds至服务完全启动所需时间(如 30 秒),或在检查器中加入isInitialized()判断 |
| 健康检查返回 SERVING,但业务请求大量 UNAVAILABLE | 健康检查未覆盖关键依赖,如 gRPC Client 连接池未初始化,或下游服务虽在线但 TLS 证书过期 | kubectl exec -it <pod> -- /bin/sh -c "grpcurl -plaintext -d '{}' <svc>:9000 health.v1alpha1.HealthCheck/Check"查看详细 dependencies 输出 | 在CompositeChecker中注册GRPCClientChecker,检查下游连接池Get()是否返回有效连接 |
| 健康检查延迟忽高忽低,无规律波动 | 检查逻辑中存在未加 context 控制的阻塞操作,如未设超时的time.Sleep、未 context 包裹的http.Get | kubectl top pod <pod>查看 CPU/Memory;kubectl exec -it <pod> -- go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30分析 CPU 热点 | 所有 I/O 操作必须包裹ctx,并设置合理timeout;避免在 Check 中做任何非必要计算 |
4.2 一个真实案例:Redis 连接池“假活”导致的雪崩
现象:某支付服务在大促期间,健康检查始终返回SERVING,但支付成功率从 99.9% 骤降至 82%,错误日志全是redis: connection pool exhausted。
排查过程:
- 第一步:手动调用健康检查,
dependencies.redis-cluster.status确实是SERVING,latency_ms为 12。 - 第二步:登录容器,
redis-cli -h redis-cluster -p 6379 PING返回PONG,证明网络和 Redis 服务本身 OK。 - 第三步:查看应用日志,发现大量
redis: connection pool exhausted,但健康检查日志里没有相关错误。 - 第四步:深入检查
RedisChecker代码,发现它只做了client.PingContext(ctx),而Ping方法只会从连接池取一个连接发送 PING 命令,成功即返回。但连接池耗尽时,Ping仍能成功(因为池里还有少量连接),而业务请求需要从池里Get连接,此时才真正失败。
根因:健康检查的Ping与业务请求的Get使用的是同一连接池,但Ping是轻量探测,Get是重负载操作。连接池在高并发下出现“饥饿”,Ping成功掩盖了Get失败。
解决方案:
- 增强检查逻辑:
RedisChecker改为执行一次真实的SET health:check "ok" EX 10,模拟业务写操作; - 增加连接池水位监控:在
Check中统计client.PoolStats().IdleConns和TotalConns,当IdleConns < 5且TotalConns == MaxConns时,标记为DEGRADED(新增状态); - 调整 K8s 配置:将
readinessProbe的periodSeconds从 5 秒改为 15 秒,避免高频探测加剧连接池压力。
这个案例教会我们:健康检查不是“能不能连上”,而是“有没有资源处理请求”。它必须模拟真实业务路径的最小闭环。
4.3 避坑清单:那些让你少加班 3 小时的经验
永远不要在健康检查中写日志到磁盘:我们曾在一个服务里,每次
Check都log.Printf("health check ok")到文件,结果在高并发 probe 下,I/O Wait 占用 CPU 40%,健康检查自身超时。解决方案:健康检查日志只输出到 stdout/stderr,由日志采集 agent 统一处理;或使用内存缓冲日志(如log.SetOutput(ioutil.Discard)在 probe 中)。Critical 依赖必须有 fallback 机制:比如 MySQL 不可用时,是否能切到只读缓存?Redis 不可用时,是否能降级为本地内存缓存?健康检查发现 Critical 依赖失败时,不应直接返回
NOT_SERVING,而应尝试降级,并返回DEGRADED状态 +summary说明当前能力范围。这需要业务代码与健康检查深度协同。缓存失效必须用“双检锁”:
CompositeChecker的缓存更新不能简单if time.Now().After(cache.expire) { refresh() },因为高并发下多个 goroutine 会同时进入 refresh,造成 DB/Redis 被打爆。必须用sync.Once或 channel 控制,确保同一时刻只有一个 goroutine 刷新缓存。K8s probe 的
periodSeconds必须是cacheTTL的整数倍:比如缓存 TTL 是 5 秒,probe 周期是 7 秒,那么 probe 会以 7 秒为周期访问缓存,但缓存每 5 秒刷新一次,导致 probe 总是拿到“过期 2 秒”的状态。最佳实践:periodSeconds = cacheTTL,或periodSeconds = cacheTTL * 2。健康检查接口必须独立于业务 gRPC Server 的拦截器链:我们曾把健康检查注册在同一个 gRPC Server,并启用了 auth 拦截器,结果 K8s probe 因缺少 token 被拦截,一直返回
UNAUTHENTICATED。正确做法:健康检查服务要么走独立端口(如 9000),要么在拦截器中明确放行/health.*路径。
5. 进阶扩展:从单体健康到服务网格的健康治理
5.1 与 Istio 的深度集成:让 Sidecar 成为健康检查的“守门人”
当服务接入 Istio 时,健康检查的职责可以进一步下沉。Istio 的DestinationRule支持outlierDetection(异常点检测),它能自动将连续失败的实例从负载均衡池中剔除。但它的默认行为是基于 5xx 错误码,对 gRPC 的UNAVAILABLE状态识别不完善。
我们通过 EnvoyFilter 扩展了这一能力:
apiVersion: networking.istio.io/v1alpha3 kind: EnvoyFilter metadata: name: grpc-health-outlier-detection spec: configPatches: - applyTo: CLUSTER match: cluster: service: "*" patch: operation: MERGE value: outlier_detection: consecutive_5xx: 3 interval: 10s base_ejection_time: 30s max_ejection_percent: 50 # 关键:添加对 gRPC status code 的检测 consecutive_gateway_failure: 3 # 检测 gRPC status code 14 (UNAVAILABLE) consecutive_local_origin_failure: 3同时,在服务端健康检查中,当检测到关键依赖失败时,我们不仅返回DEGRADED,还会主动向 Envoy 发送HTTP/2 RST_STREAM,触发上游快速失败。这比等待 K8s probe 摘流量快一个数量级。
5.2 多集群健康状态聚合:跨 AZ 的智能流量调度
在多可用区部署中,我们构建了一个HealthAggregator服务,它定期调用各集群内所有服务的健康检查接口,聚合状态并生成全局视图:
{ "global_status": "SERVING", "clusters": [ { "name": "cn-hangzhou-a", "status": "SERVING", "services": [ {"name": "payment", "status": "SERVING", "latency_ms": 15}, {"name": "user", "status": "DEGRADED", "latency_ms": 120} ] }, { "name": "cn-hangzhou-b", "status": "SERVING", "services": [ {"name": "payment", "status": "SERVING", "latency_ms": 18}, {"name": "user", "status": "SERVING", "latency_ms": 22} ] } ] }这个聚合结果被推送到服务网格的控制平面,当杭州 A 区的user服务进入DEGRADED,流量管理组件会自动将 30% 的user请求路由到杭州 B 区,实现分钟级的故障隔离。这已经超越了传统健康检查的范畴,进入了“健康驱动的智能路由”领域。
5.3 未来演进:健康状态作为服务契约的一部分
我们正在推动将健康检查协议纳入服务的 OpenAPI/Swagger 文档,并生成客户端 SDK。下游服务在调用前,可先调用Check获取business_metrics,决定是否启用重试、降级或熔断:
resp, _ := client.Check(ctx, &CheckRequest{}) if resp.Status == DEGRADED { // 启用本地缓存降级 return localCache.Get(key) } else if resp.Status == NOT_SERVING { // 熔断,返回兜底数据 return defaultPaymentResult() } // 正常调用 return realPaymentService.Process(ctx, req)健康检查,正从一个运维工具,演变为服务间协作的契约语言。它不再只是“我好不好”,而是“我能为你做什么,以及在什么条件下能做到”。
我在实际推进这个项目时最大的体会是:健康检查的代码量可能只有 200 行,但它迫使你直面服务的所有脆弱点——数据库、缓存、配置、网络、下游依赖。写好它,不是为了应付 K8s 的 probe,而是为了让自己在每一次发布前,都能真正看清服务的“健康画像”。那种“心里有底”的感觉,是任何监控大盘都给不了的踏实。