ARTICLE · 1011008
这样的公司,你去吗? 只要离开工位就要说明理由,包括上厕所,吃饭
某网友吐槽,公司要求员工只要离开工位就说明理由,上厕所、吃饭都不例外。这样的管理方式到底有没有必要,我不讨论。但这个场景让我想到一个线上问题:有些系统监控也喜欢盯着“进程还在不在”,就像盯着员工有没有坐在工位上一样。

进程活着,不代表服务真的能干活。
之前维护过一个 Python 服务,监控显示进程一直在线,CPU 不高,内存也正常,但业务请求已经大量超时。最麻烦的是,第一轮排查我们还判断错了方向。
这个服务负责调用下游接口,然后做一层数据聚合。早期业务量不大,为了避免每个请求都创建连接,代码里用了 ThreadPoolExecutor:
from concurrent.futures import ThreadPoolExecutorexecutor = ThreadPoolExecutor(max_workers=20)defquery_detail(order_id):return remote_client.query(order_id)defbatch_query(order_ids): futures = [ executor.submit(query_detail, order_id)for order_id in order_ids ]return [f.result() for f in futures]这段代码运行了很长时间。20 个线程,对当时每秒几十个请求的流量完全够用,所以一直没人觉得有问题。
直到某次下游服务抖动,接口 RT 从正常的 100ms 左右突然涨到十几秒。应用日志开始出现:
10:21:03 INFO request batch_query size=3010:21:08 WARN request cost=5012ms10:21:13 WARN request cost=10017ms10:21:18 ERROR upstream timeout10:21:18 INFO process alive pid=18432我们第一反应是 CPU 或数据库顶不住了,但监控里 CPU 只有 30% 左右,数据库连接数和慢 SQL 也没有异常。继续看 Python 进程,内存甚至比平时还稳定。
这时候问题就比较反常:机器没满,进程没挂,为什么请求处理能力几乎没了?
看到 Python 多线程卡住,很容易先想到 GIL。但这里主要是 HTTP I/O,线程等待网络响应时并不是持续占着 GIL 做 CPU 计算,所以 GIL 解释不了十几秒的大面积排队。
真正的问题藏在这一句:
result = future.result()result() 默认没有超时时间。只要下游迟迟不返回,当前请求就一直等。而线程池只有 20 个 worker,一旦这 20 个线程全部卡在下游 I/O 上,新任务只能进入线程池内部队列等待。
调用链实际上变成了:
HTTP Request | vbatch_query() | vThreadPoolExecutor(20) | +--> worker-1 --> remote API --> blocked +--> worker-2 --> remote API --> blocked +--> ... +--> worker-20 --> remote API --> blocked | vpending tasks keep growing所以进程状态完全正常,真正耗尽的是服务的“有效执行能力”。
我们在线上临时打印线程池状态,现象就很明显了:
logger.info("pool threads=%s pending=%s", len(executor._threads), executor._work_queue.qsize())日志很快变成:
pool threads=20 pending=43pool threads=20 pending=186pool threads=20 pending=517pool threads=20 pending=1249_threads 和 _work_queue 都属于内部实现,不适合拿来作为长期业务监控,但临时诊断已经足够:20 个线程一个没少,待执行任务却不断堆积。
最开始我们做了一个看起来很合理的修改:
from concurrent.futures import TimeoutErrortry: result = future.result(timeout=2)except TimeoutError:returnNone上线前复盘时发现这里还有坑。future.result(timeout=2) 只表示调用方最多等待这个 Future 两秒,并不会自动终止已经在线程中执行的 HTTP 请求。
如果底层 HTTP 客户端本身没有 timeout,worker 还是可能继续卡着。
所以真正应该限制的是网络调用:
import requestssession = requests.Session()defquery_detail(order_id): response = session.get(f"http://order-service/orders/{order_id}", timeout=(0.5, 1.5) ) response.raise_for_status()return response.json()这里分别限制连接超时和读取超时。下游异常时,请求可以及时释放线程,而不是无限占用 worker。
但这仍然没有解决另一个问题:突发流量可以持续向线程池提交任务,而 ThreadPoolExecutor 默认使用的工作队列并没有给业务提供一个可配置的容量上限。下游已经处理不过来了,上游继续提交,只会把故障从“下游变慢”扩大成“本服务大量排队”。
没有必要为了这个问题重写线程池,我们在提交任务之前增加了 Semaphore,限制同时进入下游调用链的任务数量:
from threading import BoundedSemaphorefrom concurrent.futures import ThreadPoolExecutorexecutor = ThreadPoolExecutor(max_workers=20)slots = BoundedSemaphore(40)defsubmit_query(order_id): acquired = slots.acquire(timeout=0.05)ifnot acquired:raise RuntimeError("downstream overloaded")try: future = executor.submit(query_detail, order_id)except Exception: slots.release()raise future.add_done_callback(lambda _: slots.release())return future20 个 worker 加上有限等待名额之后,系统行为发生了变化:以前是任务无限排队,调用方越来越慢;现在超过系统承载能力后,会尽快失败,由上层决定降级还是重试。
对应的业务层不能无脑重试,否则限流又会被重试流量打穿:
defget_order(order_id):try: future = submit_query(order_id)return future.result(timeout=2)except Exception as e: logger.warning("query degraded order_id=%s error=%s", order_id, type(e).__name__ )return {"order_id": order_id, "detail": None}这里的降级能不能接受,要看业务。如果是支付确认当然不能随便返回空数据;如果只是订单列表里的非核心展示字段,允许缺失往往比把整个接口拖死更合理。
事故还有一个放大因素:当时健康检查只返回固定字符串。
@app.get("/health")defhealth():return {"status": "ok"}只要 Web 框架还能接收到这个请求,监控就认为实例正常。但业务线程池已经严重排队,这个 ok 没有多少意义。
后来健康指标里增加了真正影响服务能力的数据,例如下游超时率、接口 P99、正在执行的任务数量和拒绝次数:
python_pool_active 20python_pool_rejected 138upstream_timeout_total 427api_latency_p99_ms 4860健康检查不一定要因为一次下游超时就直接摘掉实例,否则可能造成剩余实例雪崩,但告警必须能看到这些指标。监控的目标不是证明“进程还坐在工位上”,而是判断它现在还有没有处理业务的能力。
回到开头那个话题,上厕所要不要登记我没什么技术结论。但如果一个系统也只会检查“在不在线”,那确实很危险:PID 还在、端口还通,服务可能早就已经干不了活了。