一句话总结
Python 后端 = 语言核心 + Web 框架 + 并发模型 + 数据层 + 工程化。面试重点不是背语法,而是吃透四件事:对象模型(可变/不可变、一切皆对象)、GIL 下的并发取舍、装饰器/生成器/上下文管理器这些一等公民特性,以及FastAPI/SQLAlchemy 的真实工程落地。Python 表达力强、生态全,但"写得快"和"写得对、跑得稳"之间差着一整套底层认知。
# 面试常作开场的三句话:
# 1. 一切皆对象:连函数、类、模块都是对象,可被赋值、传参、动态增删属性
# 2. 对象分可变(list/dict/set)与不可变(str/int/tuple/frozenset),这决定了默认传引用还是"看起来像值"
# 3. CPython 有 GIL:单进程内同一时刻只有一个线程跑字节码——CPU 密集别指望多线程
初级理解
可变类型与不可变类型(最高频)
| 类别 | 典型类型 | 能否原地修改 |
| 可变(mutable) | list、dict、set、bytearray | 能:append/pop/update 后 id 不变 |
| 不可变(immutable) | int、float、str、tuple、frozenset、bytes | 不能:任何"修改"都返回新对象 |
# 不可变对象的"修改"其实是新建:
s = "abc"; print(id(s)); s += "d"; print(id(s)) # 两个 id 不同
# 经典坑:用可变对象做函数默认参数——它只初始化一次,跨调用共享!
def add(x, buf=[]): # buf 是同一个列表,多次调用会累积
buf.append(x); return buf
add(1); add(2) # 第二次调用得到 [1, 2],不是 [2]
is 与 == 的区别
# == 比值(调用 __eq__);is 比身份(两个变量是否指向同一个对象,即 id 相同)
a = [1, 2]; b = [1, 2]
print(a == b) # True(值相等)
print(a is b) # False(两个不同对象)
# 小整数池与字符串驻留:解释器对常用小对象做缓存,导致 is 偶尔为 True(不要依赖!)
x = 256; y = 256; print(x is y) # True(小整数池 -5~256)
m = 257; n = 257; print(m is n) # False(超出缓存范围)
浅拷贝与深拷贝
import copy
a = [1, [2, 3]]
b = copy.copy(a) # 浅拷贝:只拷顶层,嵌套的 [2,3] 仍共享
c = copy.deepcopy(a) # 深拷贝:递归拷贝,所有层级都独立
b[1].append(4); print(a) # a 也变成 [1, [2,3,4]]——浅拷贝的副作用
c[1].append(4); print(a) # a 不受影响
*args 与 **kwargs
def f(*args, **kwargs):
print(args) # 多余位置参数收成元组:(1, 2, 3)
print(kwargs) # 多余关键字参数收成字典:{'a': 4, 'b': 5}
f(1, 2, 3, a=4, b=5)
装饰器与生成器(基础形态)
# 装饰器:接收函数、返回新函数,@ 是语法糖
def log(fn):
def wrapper(*a, **k):
print("call", fn.__name__); return fn(*a, **k)
return wrapper
@log
def add(x, y): return x + y
# 生成器:用 yield 惰性产出,省内存、可表达无限序列
def countdown(n):
while n > 0:
yield n; n -= 1
中级深入
GIL:为什么 Python 还有多线程
# GIL(全局解释器锁)= CPython 的一把进程级锁:
# 同一进程内任意时刻只有一个线程能执行字节码。
# 原因:CPython 的内存管理(引用计数)不是线程安全的,加锁最省事。
#
# 影响与对策:
# - CPU 密集:多线程无效(线程轮流抢锁),用 multiprocessing 多进程绕过
# - IO 密集:线程在等待 IO 时释放 GIL,所以多线程/asyncio 仍能并发
# - C 扩展(numpy/cython)可在计算时释放 GIL
# - Python 3.2+ 的 GIL 改为"尽量公平",IO 密集下几乎无感
多线程 / 多进程 / asyncio 怎么选
| 模型 | 适用 | 开销 | 并行度 |
| 多线程 threading | IO 密集(网络/文件) | 低 | 并发(受 GIL) |
| 多进程 multiprocessing | CPU 密集 | 高(每进程独立内存) | 真并行 |
| 协程 asyncio | 高并发 IO(万级连接) | 极低 | 单线程并发 |
迭代器 vs 生成器
# 迭代器:实现 __iter__ 返回自身、__next__ 产出下一个值,遍历完抛 StopIteration
# 生成器:含 yield 的函数,调用即返回生成器对象,自动实现迭代器协议
# 区别:生成器是"写出迭代器的最简方式",且惰性、不占全量内存
nums = (x*x for x in range(10)) # 生成器表达式,按需计算,不存 10 个数
print(next(nums), next(nums)) # 0 1
闭包与 LEGB 作用域
# 变量查找顺序:Local → Enclosing → Global → Builtin
def outer(x):
def inner():
return x # 闭包:inner 捕获了外层 x
return inner
f = outer(10); print(f()) # 10
# 经典坑:循环里定义闭包引用循环变量,要用默认参数"冻结"
funcs = [lambda: i for i in range(3)]
print([g() for g in funcs]) # [2, 2, 2]——都引用了同一个最终 i
funcs = [lambda i=i: i for i in range(3)]
print([g() for g in funcs]) # [0, 1, 2]——默认参数在定义时绑定
垃圾回收:引用计数 + 分代回收
# 1. 引用计数为主:对象引用归零立即回收(快、确定性好)
# 2. 分代回收兜底:解决循环引用(a 引用 b、b 引用 a),0/1/2 三代,年轻代更频繁扫描
# 3. gc 模块可手动 gc.collect()、调阈值、排查不可达循环
import gc; print(gc.get_threshold()) # (700, 10, 10)
# 循环引用示例:
class Node:
def __init__(self): self.peer = None
a, b = Node(), Node(); a.peer, b.peer = b, a # 引用计数都到不了 0,靠分代回收
高级进阶
asyncio 事件循环与协程
import asyncio
async def fetch(n):
await asyncio.sleep(1) # 遇到 await 让出控制权,事件循环去跑别的协程
return n * n
async def main():
# asyncio.gather 并发跑多个协程,总耗时约等于最慢的一个
return await asyncio.gather(fetch(1), fetch(2), fetch(3))
asyncio.run(main()) # 3 个 1s 任务并发,约 1s 完成(非 3s)
# 要点:await 只能在 async 函数内用;CPU 密集别用 asyncio(仍受 GIL),
# 应交由线程/进程池 run_in_executor
FastAPI:异步端点、依赖注入与 pydantic
from fastapi import FastAPI, Depends
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel): # pydantic 模型:自动校验、序列化、生成 OpenAPI
name: str; price: float; stock: int = 0
def get_db():
db = {"conn": "..."} # 真实项目里是连接池
try: yield db
finally: db.clear()
@app.post("/items/")
async def create_item(item: Item, db=Depends(get_db)):
return {"ok": True, "item": item.model_dump()}
# 依赖注入 Depends:把"取数据库连接""鉴权""公共参数"抽成可复用单元,测试时可轻松 mock
SQLAlchemy:Session、N+1 与连接池
# ORM 让 Python 对象与表行互转,但隐藏了 SQL,容易写出 N+1
# N+1:查 1 次拿 N 个对象,再对每对象各查 1 次关联 → 共 N+1 条 SQL
# 解决: joinedload(JOIN 一次取出)/ subqueryload / 显式 selectinload
from sqlalchemy.orm import joinedload
query = select(User).options(joinedload(User.orders)) # 1 条 SQL 搞定
# 连接池:engine = create_engine(url, pool_size=10, max_overflow=20, pool_pre_ping=True)
# pool_pre_ping 在借连接前 ping 一下,规避"MySQL 八小时断开"导致的失效连接
描述符与 property 原理
# 描述符:定义了 __get__/__set__/__delete__ 中至少一个的对象,接管属性访问
# property 就是用描述符实现的"托管属性"
class Celsius:
def __get__(self, obj, owner): return obj._c
def __set__(self, obj, val):
if val < -273.15: raise ValueError("低于绝对零度")
obj._c = val
class Temp: c = Celsius() # c 是个描述符,赋值会被 __set__ 拦截校验
实战场景
后端项目分层结构
app/
routers/ # 路由层:只做参数解析与响应封装,不含业务
services/ # 服务层:业务逻辑、事务边界
repositories/ # 仓储层:SQLAlchemy 查询、ORM 交互
schemas/ # pydantic 模型:请求/响应契约
core/ # 配置、依赖、数据库引擎、安全
main.py # 组装 FastAPI 应用
# 关键:路由不直连 DB,业务放在 service,便于单测与复用
配置管理与类型安全
# pydantic-settings:配置从环境变量/`.env` 读取并校验类型
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
redis_url: str = "redis://localhost:6379"
debug: bool = False
class Config: env_file = ".env"
settings = Settings() # 缺失必填项会启动即报错,避免线上才暴露配置错
日志、测试与类型检查
# 日志用标准 logging,结构化(JSON)便于采集;别用 print 上生产
import logging; logging.basicConfig(level=logging.INFO)
# pytest + fixtures:用依赖注入思想准备测试上下文
def test_create_item(client, db_session): ...
# 类型注解 + mypy:大型项目几乎必备,捕捉"传错类型"这类低级 bug
def add(a: int, b: int) -> int: return a + b
依赖与部署
# 依赖管理:venv 隔离 + pip-tools/poetry/uv 锁版本(避免"我本地能跑")
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# 部署:gunicorn + uvicorn worker(多进程扛 GIL,每个 worker 一个事件循环)
# gunicorn main:app -k uvicorn.workers.UvicornWorker -w 4
# 容器化:把上述命令写进 Dockerfile 的 CMD,配合健康检查与优雅停机
注意:FastAPI 的 Depends 与同步/异步混用要当心——在 async 路由里调用阻塞的同步 DB 驱动会卡住整个事件循环;要么用异步驱动(asyncpg/aiomysql),要么用 run_in_executor 把阻塞调用丢到线程池。
面试模拟
面试官:Python 有 GIL,那多线程还有意义吗?什么时候该用多进程?
你:有意义,但要分场景。GIL 只在执行字节码时互斥,线程在等待网络/文件 IO 时会释放 GIL,所以 IO 密集任务(Web 请求、爬虫)用多线程或 asyncio 依然能高并发。只有 CPU 密集(大量计算、图像处理)才受 GIL 限制,此时多线程帮不上忙,要用 multiprocessing 多进程拿到真并行,或用 C 扩展释放 GIL。一句话:IO 密集靠并发,CPU 密集靠并行。
面试官:深拷贝和浅拷贝的区别?什么场景必须用深拷贝?
你:浅拷贝只复制顶层对象,内嵌的可变对象(如列表里的列表)仍与原对象共享同一份;深拷贝递归复制所有层级,完全独立。当数据结构嵌套了可变对象、且你修改副本时不想影响原对象(比如复制一份配置再改、复制图结构做变换),就必须用 deepcopy,否则会产生"改一个另一个也变"的隐蔽 bug。
面试官:is 和 == 有什么不同?为什么有时候 a is b 是 True?
你:== 比较值(走 __eq__),is 比较身份——两个变量是否指向同一个对象(id 相同)。偶尔为 True 是因为解释器的对象缓存:小整数(-5~256)和短字符串会被驻留复用,所以两个 256 指向同一对象。但这是实现细节,不应依赖;比较值永远用 ==。
面试官:装饰器原理是什么?functools.wraps 解决什么问题?
你:装饰器本质是高阶函数:接收被装饰的函数,包一层新函数返回,@ 只是语法糖。问题是直接返回 wrapper 会丢失原函数的 __name__/__doc__ 等元信息,导致日志、调试、反射拿到的是 wrapper。functools.wraps 是个装饰器,它把原函数的元信息复制到 wrapper 上,相当于"保留身份证"。