Python技术迷

构建永不崩溃的可扩展 Python 系统

接口没挂,机器也没报警,RT 先抖到 8 秒,接着 worker 一批一批被打满。Python 系统真出事,很多时候不是“崩了”,而是还活着,但已经开始吐血:请求堆着不回,队列越积越长,重试把下游再补一刀,最后谁都别想好过。

“永不崩溃”这句话我是不信的。线上没有不坏的系统,只有坏了以后别一起陪葬。所以这事别从“怎么写一个很强的服务”开始想,先从“哪一层先认怂”开始想:超时、隔离、限流、降级、幂等、可恢复。顺序错了,框架选得再花也没用。

先看一个很多 Python 服务都会写出来的东西:

import requests

defcreate_order(payload: dict) -> dict:
    user = requests.get(f"http://user-service/users/{payload['user_id']}").json()
    stock = requests.post("http://stock-service/reserve", json=payload).json()
    requests.post("http://coupon-service/use", json={"user_id": payload["user_id"]})
return {
"user": user["name"],
"stock": stock["status"],
"ok": True
    }

这段代码本地跑起来没毛病,压一压就知道不对了。没有超时,调用链串行,任何一个下游慢一点,整个请求都陪着站岗。更烦的是,失败了你都说不清做到哪一步了。

我一般先改三刀,不谈架构,先止血。

第一刀,所有外部依赖必须有超时,而且连接超时和读取超时分开配。默认等到天荒地老这种事,我一眼就不太信。

import requests

SESSION = requests.Session()

defcall_json(method: str, url: str, **kwargs) -> dict:
    kwargs.setdefault("timeout", (0.3, 1.5))  # connect timeout, read timeout
    resp = SESSION.request(method, url, **kwargs)
    resp.raise_for_status()
return resp.json()

第二刀,重试不是默认开满。很多系统不是被故障打死,是被“善意重试”活活补死。只有幂等操作才配重试,而且要退避。

import time
from typing import Callable

defretry(times: int, fn: Callable, *args, **kwargs):
    last_err = None
for i in range(times):
try:
return fn(*args, **kwargs)
except Exception as e:
            last_err = e
            time.sleep(0.1 * (2 ** i))
raise last_err

第三刀,把慢操作从主链路摘出去。邮件、埋点、对账、二次通知,这些东西别堵在接口里装深情。主链路只做“必须现在成功”的事,剩下丢队列。

from queue import Queue
from threading import Thread
import logging

job_queue = Queue(maxsize=1000)

defsubmit_event(event: dict) -> bool:
try:
        job_queue.put_nowait(event)
returnTrue
except Exception:
        logging.warning("queue_full event=%s", event.get("type"))
returnFalse

defworker():
whileTrue:
        event = job_queue.get()
try:
            handle_event(event)
except Exception as e:
            logging.exception("event_failed type=%s err=%s", event.get("type"), e)
finally:
            job_queue.task_done()

Thread(target=worker, daemon=True).start()

这里故意用了 maxsize。别小看这个口子,不设上限的队列,本质上就是拿内存硬扛流量。短期像没事,真来一波高峰,进程不会立刻死,但会先把 GC、上下文切换、内存抖动全带起来,死相更难看。

再往后,就不是“代码规范”了,是系统边界。

一个可扩展的 Python 系统,扩的不是类和模块,先扩失败面。CPU 密集型任务别硬塞进 Web worker;I/O 密集型任务别全堆在线程里不设上限;共享状态别到处写内存 dict,然后指望多进程、多实例还能一致。Python 写业务快,这没问题,但也因为快,特别容易把边界写糊。

比如缓存,很多人上来就喜欢:

cache = {}

defget_profile(user_id: int):
if user_id in cache:
return cache[user_id]
    data = load_from_db(user_id)
    cache[user_id] = data
return data

这玩意儿 demo 可以,线上我一般不敢直接放。没有过期,没有上限,没有穿透保护,进程一重启全失忆。稍微靠谱一点,至少把 TTL 和最大容量补上,命中失败也别一窝蜂打数据库。

import time

classLocalCache:
def__init__(self, max_size=10000, ttl=60):
        self.max_size = max_size
        self.ttl = ttl
        self.data = {}

defget(self, key):
        item = self.data.get(key)
ifnot item:
returnNone
        value, expire_at = item
if expire_at < time.time():
            self.data.pop(key, None)
returnNone
return value

defset(self, key, value):
if len(self.data) >= self.max_size:
            self.data.pop(next(iter(self.data)))
        self.data[key] = (value, time.time() + self.ttl)

还差一块,日志和指标。这个东西平时最容易被嫌烦,真出事的时候又第一个想起来。没有请求 ID,没有下游耗时,没有队列长度,没有失败计数,线上排障基本靠猜。Python 系统尤其怕这个,因为很多异常不是直接炸,是吞掉、补偿、重试以后绕远路才炸。

我自己更关心这几个指标:接口 P95、线程池/队列堆积、下游超时数、重试次数、单机内存增长斜率。别一上来就铺满几十个监控大盘,最后没人看。先盯最容易把系统拖死的几个点。

真想把 Python 系统做得不容易崩,路子其实不玄乎:请求要能超时,任务要能丢弃,消息要能重放,状态要能恢复,实例要能随时替换。别把“稳定”理解成永远不报错,稳定是报错了别扩散,重启了别丢魂,流量冲上来先削峰,不要把数据库、缓存、第三方接口一起带走。

Python 从来不是问题。拿它把所有事都同步做完,还假设下游永远靠谱,这才是问题。