构建永不崩溃的可扩展 Python 系统
接口没挂,机器也没报警,RT 先抖到 8 秒,接着 worker 一批一批被打满。Python 系统真出事,很多时候不是“崩了”,而是还活着,但已经开始吐血:请求堆着不回,队列越积越长,重试把下游再补一刀,最后谁都别想好过。
“永不崩溃”这句话我是不信的。线上没有不坏的系统,只有坏了以后别一起陪葬。所以这事别从“怎么写一个很强的服务”开始想,先从“哪一层先认怂”开始想:超时、隔离、限流、降级、幂等、可恢复。顺序错了,框架选得再花也没用。
先看一个很多 Python 服务都会写出来的东西:
import requestsdefcreate_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 requestsSESSION = 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 Callabledefretry(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 loggingjob_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 timeclassLocalCache:
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 从来不是问题。拿它把所有事都同步做完,还假设下游永远靠谱,这才是问题。