一、为什么“能跑起来”不等于“生产就绪”?——我们被凌晨三点的告警教做人

还记得上线首周那个周三凌晨2:47吗?我正裹着毯子刷手机,钉钉弹出第7条红色告警:“ad-bidding-service-03 连接池满,Connection refused”。三分钟后,运维老张发来一张图:黑底白字的终端日志叠着半干的咖啡渍,工牌一角被浸得发软——那张图后来成了我们团队的“耻辱墙”头图。

故障链很短,但后果极重:单点MySQL连接池(HikariCP max=20)被广告计划轮询任务打穿 → 连接堆积阻塞线程池 → Spring Boot Actuator健康检查失败 → K8s liveness probe连续失败 → Pod被批量驱逐 → 新Pod启动又抢连接 → 雪崩。37%的广告计划在11分钟内停投,客户侧CTR数据断崖式下跌。

我们当时最大的错觉,是把“Postman能调通”当成了交付终点。直到被现实按在地上摩擦才明白:“能跑起来”只证明代码没语法错误;“生产就绪”是系统在CPU飙到95%、磁盘IO堵死、网络延迟跳变200ms时,依然能呼吸、能喊疼、能自己止血。

这不是上线后补监控、加熔断的“优化项”,而是架构设计第一天就必须画进图里的生存底线。我们后来用故障时间线倒逼出三大支柱的落地节奏:

  • T+0部署:服务启动成功(/actuator/health 返回UP)
  • T+12h首次OOM:JVM堆外内存泄漏,Prometheus发现process_resident_memory_bytes突增 → 引入-XX:NativeMemoryTracking=detail + jcmd <pid> VM.native_memory summary 定期巡检
  • T+48h灰度中断:因未配置@SentinelResource(fallback = "defaultBid"),下游画像服务超时直接抛异常 → 全链路强制 fallback 合规检查纳入CI流水线

💡 行动建议:下次写完第一个接口,别急着提测。打开终端,执行这三行:

# 模拟连接池耗尽(HikariCP)
curl -X POST http://localhost:8080/actuator/health
# 查看当前活跃连接数
echo "show status like 'Threads_connected';" | mysql -u root -p -h 127.0.0.1 | grep Threads_connected
# 触发一次OOM(仅测试环境!)
curl -X POST http://localhost:8080/debug/oom-trigger

如果这三步里有任何一步让你手心冒汗——恭喜,你刚摸到了生产就绪的门槛。

广告出价服务雪崩故障时间线示意图

二、高可用不是堆机器,是“主动防崩”——我们的多活+降级实战手记

曾天真地以为:K8s集群跨3个AZ部署 + etcd集群3节点 + Redis哨兵模式 = 高可用。结果云厂商华南区网络抖动持续47秒,etcd leader频繁切换,/healthz 探针间歇性失败 → K8s调度器误判节点失联 → 所有广告策略服务Pod被驱逐重启 → 本地缓存清空 → 流量直击MySQL → 慢查询堆积 → 连锁崩溃。

最讽刺的是,我们花了2周配好“多活”,却没给它装“刹车片”。

真正的高可用,是提前想好:当某一层塌了,系统如何用更廉价的代价,维持核心功能不瘫痪? 我们用三个月打磨出三道防线:

数据层兜底:Caffeine本地缓存 + 策略降级

// 出价策略主逻辑
public BidResult calculateBid(AdRequest request) {
    try {
        return redisCache.get(request.getAdId(), () -> 
            dbQuery.fetchStrategy(request.getAdId()));
    } catch (Exception e) {
        // 降级:返回预设默认策略(非0值!)
        log.warn("Cache/DB fail, fallback to default strategy", e);
        return DefaultBidStrategy.INSTANCE.apply(request); // 例如:base_bid * 1.15
    }
}

服务层熔断:Resilience4j 配置压到毫秒级

# application.yml
resilience4j.circuitbreaker:
  instances:
    userProfileApi:
      failure-rate-threshold: 50
      wait-duration-in-open-state: 60s
      permitted-number-of-calls-in-half-open-state: 10
      # 关键!超时从5s砍到800ms
resilience4j.timelimiter:
  instances:
    userProfileApi:
      timeout-duration: 800ms

流量层智能调度:Nginx+Lua 实现地域级切流

# nginx.conf
lua_shared_dict region_health 10m;
server {
    location /bid {
        access_by_lua_block {
            local health = ngx.shared.region_health:get("south_china")
            if health and tonumber(health) > 200 then -- 延迟>200ms
                ngx.exec("@east_china") -- 转发至华东节点
            end
        }
    }
}

上线前必查清单(贴在团队Wiki首页):

  1. 5个降级开关是否全部可手动触发?(/api/v1/feature-toggle/bid-fallback
  2. etcd健康检查是否包含 curl -L http://etcd:2379/health + etcdctl endpoint status 双校验?
  3. 所有熔断器是否配置 onFailure 回调记录trace_id?
  4. 本地缓存是否设置 maximumSize(10000) + expireAfterWrite(10m)
  5. 流量调度规则是否通过 ab -n 1000 -c 100 http://test-region-switch 验证过?

三、监控不是看Grafana大屏,是让系统自己会“喊疼”

初期我们建了200+告警规则,其中186条是“CPU>80%”“内存>90%”。结果某次Redis集群因持久化阻塞,redis_latency_ms{job="redis"} > 500 持续12分钟——而真正致命的指标 ad_recall_rate{region="shanghai"} < 0.85 却安静如鸡。等销售总监电话打进来:“用户搜不到我们广告了”,才惊觉已损失2小时黄金流量。

监控的本质,是把业务语言翻译成机器能听懂的求救信号。 我们重构为三层指标体系:

层级指标示例告警动作
基础设施node_cpu_seconds_total{mode="idle"}P2,钉钉群通知
中间件redis_commands_total{cmd="get",status="error"}P1,企微@oncall
业务黄金ad_serving_success_rate{advertiser="abc"} < 0.99P0,电话+短信双触达

最关键的突破,是定义了三个“必埋业务黄金指标”:

  • ad_serving_success_rate(广告成功曝光率)
  • ctr_prediction_error_rate(CTR预测偏差率:|pred_ctr - actual_ctr| / actual_ctr
  • bid_strategy_version_mismatch(新旧策略版本不一致次数)

配合结构化日志快速下钻:

{
  "level": "INFO",
  "trace_id": "a1b2c3d4e5f6",
  "ad_id": "AD-7890",
  "strategy_version": "v2.3.1",
  "bid_amount": 1.25,
  "recall_source": "realtime_feature_v3",
  "event": "bid_decision_complete"
}

ad_serving_success_rate 报警时,ELK中输入:

trace_id: "a1b2c3d4e5f6" AND event: "bid_decision_complete" | sort @timestamp

30秒定位到是特征服务返回了空人群包——而非猜测“是不是Redis挂了”。

监控分层告警示意图

四、灰度发布不是“切10%流量”,是“让业务敢改、敢试、敢回滚”

第一次灰度,我们用Nginx按IP哈希分流。结果某电商客户所有员工IP段(202.96.0.0/16)全进了灰度池。新算法对长尾商品过度保守,导致其ROI暴跌40%。销售团队凌晨三点订机票飞往客户现场,用Excel手工调价补救……

灰度的核心矛盾从来不是技术,而是信任。 业务方怕的不是技术故障,是“我的客户被当成小白鼠”。

我们迭代出三板斧:

  • 流量标识:放弃IP/UA,改用 advertiser_id % 100 哈希,确保同一客户100%走同一条路径;
  • 双写验证:灰度服务同时调用 old-strategy.jarnew-strategy.jar,对比结果:
    if (Math.abs(oldBid - newBid) / oldBid > 0.05) { // 偏差>5%
        alert("BID_DEVIATION_HIGH", traceId, oldBid, newBid);
        disableNewStrategy(); // 自动熔断
    }
    
  • 一键回滚:K8s ConfigMap热更新 + 预置旧版Deployment YAML,脚本化:
    # rollback.sh
    kubectl apply -f deploy-v2.2.0.yaml && \
    kubectl rollout restart deployment/ad-bidding-service
    # 实测耗时:23秒
    

📋 灰度Checklist(每次上线前全员签字):

  • 验证3类异常case:① 广告主ID为NULL ② 特征服务超时 ③ 用户画像返回空JSON
  • 回滚失败兜底方案:手动执行 kubectl set image deployment/ad-bidding-service *:v2.2.0
  • 销售侧已同步灰度范围及预期影响(附客户名单Excel)

五、那些没人告诉你的“隐形地雷”——配置中心、数据一致性、权限治理

最痛的不是宕机,是“静默失效”:

  • Apollo里误删bid_floor字段,全量出价归零11分钟——因为监控只看/health,不校验/config/validity
  • 离线特征任务因YARN资源争抢延迟22分钟,实时服务取到过期人群包,凌晨批量封禁优质客户;
  • 市场部实习生用同事账号登录策略平台,把female_premium_ratio1.2改成12,引发27起投诉。

这些地雷不炸则已,一炸就是P0。 解法必须务实:

  • 配置中心:所有关键配置强制双人复核 + 沙箱环境回放(用昨日10%真实流量请求验证);
  • 数据新鲜度:特征服务增加探针,每5分钟检查HDFS文件:
    hdfs dfs -ls /data/features/user_profile/ | tail -1 | awk '{print $6}' | \
    xargs -I {} date -d "{}" +%s | xargs -I {} expr $(date +%s) - {}
    # > 900秒(15分钟)则告警
    
  • 权限治理:RBAC绑定AD组 + 二次确认:
    @PreAuthorize("hasRole('STRATEGY_EDITOR')")
    public void updateBidStrategy(@RequestBody StrategyDto dto) {
        if (requiresSmsConfirm(dto)) {
            sendSmsCode(dto.getUserId()); // 发送短信验证码
        }
    }
    

🔍 上线前夜5件事自查表

  1. 所有配置项是否设置defaultValue(避免null导致空指针)?
  2. 最近3次特征任务延迟水位是否<5min?(查Airflow DAG历史)
  3. 策略编辑权限是否已从“所有人”收回至ad-strategy-editors AD组?
  4. Apollo配置变更审计日志是否开启?
  5. bid_floor等兜底值是否在数据库和配置中心双重校验?

灰度发布与配置治理对比图

六、给后来者的真心话:别迷信架构图,先搞定这三件事

最后说点掏心窝的话。我们花半年画了17版微服务架构图,但真正救了命的,是这三件小事:

第一件事:把故障复盘会变成每周雷打不动的仪式。
哪怕当周零故障,也模拟一次DB宕机:

  • 主角:DBA(扮演MySQL)
  • 规则:不准说话,只能用SELECT 1响应
  • 目标:SRE在5分钟内完成读库切换+缓存预热

第二件事:给每个核心接口写“死亡测试”脚本。

# death_test_bid_api.py
def test_network_partition():
    # 拦截所有出站请求
    with patch('requests.post') as mock_post:
        mock_post.side_effect = requests.exceptions.ConnectionError
        assert bid_api.calculate() == DEFAULT_BID  # 必须降级!

def test_disk_full():
    # 注入磁盘满错误
    with patch('os.statvfs') as mock_stat:
        mock_stat.return_value = Mock(f_bavail=0)
        assert bid_api.cache_warmup() == "SKIPPED"  # 不该崩溃

第三件事:让业务方定义“可用性”。
我问业务总监:“如果今天灰度出问题,你最怕什么?”
他脱口而出:“怕用户搜不到我们的产品。”
——于是我们把search_page_ad_exposure_success_rate设为P0指标,阈值99.99%,比SLA还严苛。

💬 最后一句真心话
架构图是静态的,故障是动态的;文档是理想的,线上是混沌的。
别等告警响了才想起加监控,别等客户投诉才想起埋日志。
生产就绪的起点,永远是你第一次认真读完那行报错日志的凌晨。

团队故障复盘会现场照片