200 行写个 ORM,看清 SQLAlchemy 的 Session

格 格达软件 2026-11-06 5 次阅读

上周同事发我一个导出脚本,说 2000 条订单跑了 40 秒。打开一看,主循环里每个订单查一次用户表。改成一次 IN 查询,总耗时掉到 1 秒以内。

这类问题不是 SQL 写得烂,是不知道 ORM 什么时候偷偷发了 SQL、发了几条。下面从零写一个能跑的最小 ORM,把 SQLAlchemy 的三块核心——描述符映射、Session 的 identity map、flush 的时机——拆开看,最后说清楚它在哪里会失效。

先看那段崩掉的代码长什么样:

# 反例:2000 条订单,2001 条 SQL
cur = conn.execute('SELECT id, user_id, amount FROM orders')
for oid, uid, amount in cur.fetchall():
    name = conn.execute('SELECT name FROM users WHERE id = ?', (uid,)).fetchone()[0]
    print(oid, name, amount)

SQLite 本地一条点查 20~30 微秒,2001 次也就 50 毫秒,你根本感觉不到。换到 MySQL,同机房一次往返 0.3ms,2000 次是 0.6 秒;跨机房 2ms,就是 4 秒。再加上连接池争用、结果集解析、Python 层的循环开销,几十秒不奇怪。

手写 SQL 要解决三件重复劳动:

  1. 行 → 对象。取出来的 tuple 按下标拆,加一列就全乱。
  2. 改对象 → 发 SQL。你改了 user.name,得自己记得写 UPDATE,还得只更新改过的字段。
  3. 同一行只有一个对象。两个地方各查一次同一行,改了一个忘了另一个,写回去就是丢更新。

ORM 就是这三件事的通用解法。SQLAlchemy 对应的是:描述符 + 元类、dirty tracking + unit of work、identity map。

映射层:为什么必须是描述符,不能是类属性

新手写 ORM 的第一版通常长这样:

class User:
    id = None
    name = None

这些是类属性,所有实例共享。u1.name = 'a' 会在实例的 __dict__ 里建一条新记录,看起来能用。但你没法在类上拿到「列对象」去拼 WHERE 子句,也没法在赋值时打脏标记。

正确做法是描述符:

class Column:
    def __init__(self, type_, primary_key=False, nullable=True):
        self.type = type_
        self.primary_key = primary_key
        self.nullable = nullable
        self.name = None

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, cls):
        if obj is None:
            return self          # User.name 拿到 Column 对象本身
        return obj._data.get(self.name)

    def __set__(self, obj, value):
        if obj._data.get(self.name) != value:
            obj._dirty.add(self.name)   # 赋值时打标记
        obj._data[self.name] = value

__get__ 里 if obj is None 这个分支是整个设计的关键:类访问返回列对象,实例访问返回值。SQLAlchemy 的 InstrumentedAttribute 干的就是这件事,区别在于它返回的 QueryableAttribute 重载了 __eq__、__gt__、like 这些运算符,所以你能写 User.name == 'bob' 生成一个表达式对象。

这里有个很多人踩过的坑:为了让 user == 'bob' 能用,去重载实例的 __eq__。

# 错误写法
class User(Model):
    def __eq__(self, other):
        if isinstance(other, str):
            return self.name == other
        return super().__eq__(other)

Python 里定义了 __eq__ 会把 __hash__ 置成 None,对象直接不可 hash。后果是 identity map 不能用它当 key,塞进 set() 去重报 TypeError: unhashable type,更隐蔽的是两个字段完全相同的对象在集合里变成两条。

SQLAlchemy 从不在实例层面碰 __eq__,运算符重载全在描述符返回的对象上做,实例之间保持默认的身份相等。这是个很克制的决定,代价是你不能写 user == 'bob',只能写 User.name == 'bob'。

元类负责把列收集起来:

from collections import OrderedDict

REGISTRY = []

class ModelMeta(type):
    def __new__(mcls, name, bases, ns):
        cls = super().__new__(mcls, name, bases, ns)
        if not bases:                 # Model 基类本身跳过
            return cls
        cols = OrderedDict()
        for base in reversed(cls.__mro__):   # 支持继承,子类覆盖父类
            for k, v in vars(base).items():
                if isinstance(v, Column):
                    cols[v.name or k] = v
        cls.__columns__ = cols
        cls.__table__ = ns.get('__tablename__', name.lower())
        cls.__pk__ = next(n for n, c in cols.items() if c.primary_key)
        REGISTRY.append(cls)
        return cls

Session:ORM 和「会发 SQL 的字典」的分界线

有了映射还不够。真正让 SQLAlchemy 区别于一堆辅助函数的是 Session。它内部维护三样东西:

class Session:
    def __init__(self, conn):
        self.conn = conn
        self.identity_map = {}   # (class, pk) -> instance
        self._new = []           # 待 INSERT
        self._deleted = []       # 待 DELETE

    def get(self, cls, pk):
        key = (cls, pk)
        if key in self.identity_map:
            return self.identity_map[key]        # 命中,零 SQL
        row = self.conn.execute(
            f'SELECT * FROM {cls.__table__} WHERE {cls.__pk__} = ?', (pk,)
        ).fetchone()
        if row is None:
            return None
        return self._from_row(cls, row)

    def _from_row(self, cls, row):
        data = dict(row)                          # 需要 conn.row_factory = sqlite3.Row
        obj = cls(**data)
        obj._persisted = True
        self.identity_map[(cls, data[cls.__pk__])] = obj
        return obj

identity map 带来三个白拿的好处:

  • s.get(User, 1) is s.get(User, 1) 为 True。改一处,所有引用都看得见。
  • 同一行只发一次 SELECT。被十处引用也只有一次数据库往返。
  • flush 时能精确算出哪些对象要写回,不用全表比对。

代价是内存。identity map 持有强引用,直到 session 关闭或者 expunge。批处理 100 万行还赖在同一个 session 里,内存一路涨到 OOM——这是后面要讲的坑。

flush 是另一块:

    def flush(self):
        for obj in self._new:                     # INSERT
            self._insert(obj)
        self._new.clear()

        for obj in self._deleted:                 # DELETE
            self.conn.execute(
                f'DELETE FROM {type(obj).__table__} WHERE {type(obj).__pk__} = ?', (obj.pk,))
            self.identity_map.pop((type(obj), obj.pk), None)
        self._deleted.clear()

        for (cls, pk), obj in list(self.identity_map.items()):   # UPDATE
            if not obj._dirty:
                continue
            sets = ', '.join(f'{n} = ?' for n in obj._dirty)
            params = [obj._data[n] for n in obj._dirty] + [pk]
            self.conn.execute(f'UPDATE {cls.__table__} SET {sets} WHERE {cls.__pk__} = ?', params)
            obj._dirty.clear()

    def commit(self):
        self.flush()
        self.conn.commit()

注意 flush 不发 commit。事务边界和 SQL 生成是两回事,这个分离是 SQLAlchemy 里最容易被误解的地方之一。

因为 INSERT 推迟到 flush 才发,下面这段会让人怀疑人生:

u = User(name='bob')
s.add(u)
print(s.query(User).filter('name', '=', 'bob').all())   # [],因为还没 INSERT
s.commit()                                              # 这一刻才真正写库

SQLAlchemy 用 autoflush 兜这个坑:执行查询前先自动 flush 一次。默认开着。你可以 Session(autoflush=False) 关掉,在只读事务或者大批量导入时能省掉大量无谓的 flush,代价就是要自己记得手动 flush。

还有一处我的实现故意偷了懒:脏值判定用的是 __set__ 里打的布尔标记。SQLAlchemy 不是这么做的,它在赋值时把旧值记进 committed_state,flush 时拿当前值和快照比。区别在于 obj.name = obj.name 这种赋值——标记位会产生一条无意义的 UPDATE,快照比较不会。

flush 顺序:外键约束为什么突然炸了

我的最小实现按 add 顺序 INSERT。写个例子就露馅:

s.add(Order(user_id=1, amount=99))   # 先 add 子表
s.add(User(id=1, name='bob'))
s.commit()
# sqlite3.IntegrityError: FOREIGN KEY constraint failed

SQLAlchemy 不会炸,因为它维护了表之间的依赖图,flush 时按拓扑序插入,父表永远先。

自引用外键(无限级分类、树形结构里 parent_id 指向同表 id)拓扑排序无解,SQLAlchemy 会退化成按主键排序,或者要求你给关系加 post_update=True,把这条外键拆成第二条 UPDATE 发出去。

几个边界条件值得记一下:

  • 环形依赖(A 引用 B,B 引用 A)在 SQLAlchemy 里直接抛 CircularDependencyError,只能靠 post_update 或者把其中一条设成 nullable=True 分两步走。
  • 数据库层面的延迟约束(PostgreSQL 的 DEFERRABLE INITIALLY DEFERRED)能把检查推迟到 commit,但 SQLite 不支持,写了也白写。
  • 我的最小实现没有拓扑排序能力,所以示例里必须父表先 add。这就是「最小可用」和「生产可用」的分界线。

N+1 到底是在哪一行产生的

换回真实的 SQLAlchemy,关系加载是另一个高频事故区。

from sqlalchemy import ForeignKey, Numeric, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = 'users'
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(64), index=True)
    orders: Mapped[list['Order']] = relationship(back_populates='user')

class Order(Base):
    __tablename__ = 'orders'
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey('users.id'), index=True)
    amount: Mapped[float] = mapped_column(Numeric(12, 2))
    user: Mapped[User] = relationship(back_populates='orders')
# 1001 条 SQL:1 条查订单,1000 条查用户
orders = session.query(Order).limit(1000).all()
for o in orders:
    print(o.user.name)

打开 create_engine(..., echo=True) 能直接看到这 1001 条。修复方式:

from sqlalchemy import select
from sqlalchemy.orm import selectinload

# 2 条 SQL:一条查订单,一条 WHERE users.id IN (...)
orders = session.execute(
    select(Order).options(selectinload(Order.user)).limit(1000)
).scalars().all()

三种加载方式的取舍:

方式 SQL 条数 适合 代价
默认 lazy 1 + N 只读单个对象的关联 N+1
joinedload 1 多对一、一对一 一对多会行爆炸,要做 DISTINCT 去重
selectinload 2 一对多、多对多 IN 列表太长会超参数上限(SQLite 旧版 999)
raiseload 1 生产环境兜底 需要显式预加载

joinedload 用在一对多上是个典型误用:1000 个订单平均每个 5 条明细,结果集变 5000 行,ORM 还得按主键把重复的父对象合并回去,内存和 CPU 双亏。一对多一律 selectinload。

想从根上防住,把忘记预加载从性能问题变成运行时异常:

class Order(Base):
    ...
    user: Mapped[User] = relationship(lazy='raise')   # 忘了 selectinload 就直接抛异常

在测试环境跑一遍,比上线后看慢查询日志强。

批量插入:ORM 最不该接的活

自己跑一下这份脚本,比较三种写法的量级差异:

import time, sqlite3
from sqlalchemy import create_engine, insert, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class Base(DeclarativeBase):
    pass

class Row(Base):
    __tablename__ = 'rows'
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    a: Mapped[int] = mapped_column(Integer)
    b: Mapped[str] = mapped_column(String(32))

N = 50_000
engine = create_engine('sqlite:///bench.db')
Base.metadata.create_all(engine)

# 1) 逐条 add
with Session(engine) as s:
    t = time.perf_counter()
    for i in range(N):
        s.add(Row(a=i, b='x'))
    s.commit()
    print('add:', round(time.perf_counter() - t, 2), 's')

# 2) 字典列表直接 executemany
with Session(engine) as s:
    t = time.perf_counter()
    s.execute(insert(Row), [{'a': i, 'b': 'x'} for i in range(N)])
    s.commit()
    print('executemany:', round(time.perf_counter() - t, 2), 's')

我这边跑出来的量级差距在 20 倍上下,具体数字看磁盘。差距来源很清楚:第一种每个行都要构造实例、走描述符 __set__、塞进 identity map、flush 时逐条拼 INSERT;第二种直接交给 DBAPI 的 executemany。

方式 建实例 走 identity map 主键回填 速度
session.add 逐条 是 是 是 最慢
bulk_insert_mappings 否 否 部分 快
insert() + 字典列表 否 否 需 returning() 最快

两个坑:

一,insert() 批量绕过了 ORM 事件和 identity map。 如果你之前 session.get(Row, 1) 过,内存里那个对象和库里新插的行就对不上,后续读到的还是旧值。批量和 ORM 混用时,记得 session.expire_all()。

二,别每 1000 行 commit 一次。 一次 commit 就是一次 fsync,机械盘上 1000 次够你喝杯咖啡。但事务也别开太大:PostgreSQL 长事务会阻挡 vacuum,undo 空间一路涨。10 万到 100 万行一批是个常见折中。

五个我踩过的坑

1. DetachedInstanceError

user = session.get(User, 1)
session.close()
print(user.orders)   # DetachedInstanceError

懒加载需要一个活着的 session 来发 SQL。expire_on_commit=False 只解决属性过期,解决不了这个。要么在 session 内用 selectinload 加载完,要么别关 session。

2. Session 做成模块级单例

# 错误
session = Session(engine)

def get_user(uid):
    return session.get(User, uid)

两个请求同时进来,identity map 互相污染,一个 rollback 影响另一个,事务边界完全乱套。正确姿势是每请求一个 session:

SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)

def handle_request():
    session = SessionLocal()
    try:
        do_work(session)
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()

3. expire_on_commit=True 在脚本里变成 N+1

默认 commit 之后所有属性过期,下次访问重新 SELECT。Web 请求里一次 commit 无所谓,循环里就是灾难:

for o in session.query(Order).all():
    session.commit()
    print(o.amount)      # 每次 commit 后再访问,又发一条 SELECT

批处理脚本里设 expire_on_commit=False。

4. 金额用 Float

amount = Column(Float)              # 错误
amount = Column(Numeric(12, 2))     # 正确

SQLite 没有原生 decimal,Numeric 存进去的类型亲和性是 NUMERIC,值可能落成 INTEGER 或 REAL;PostgreSQL 是真 decimal。跨库迁移的账目核对能让你加班。稳妥点直接存整数分。

5. 表名撞 SQL 关键字

我的最小实现直接拼表名。class Order 对应的表叫 order,SELECT * FROM order 在 SQLite 里直接语法错误,因为 ORDER 是关键字。SQLAlchemy 的方言层用 quoted_name 和 identifier preparer 处理:MySQL 用反引号,PostgreSQL 用双引号。自己拼 SQL 就老老实实给标识符加引号。

完整代码

miniorm.py,纯标准库,可以直接跑:

# miniorm.py —— 用于理解原理的最小 ORM,不要上生产
import sqlite3
from collections import OrderedDict

REGISTRY = []


class Column:
    def __init__(self, type_, primary_key=False, nullable=True):
        self.type = type_
        self.primary_key = primary_key
        self.nullable = nullable
        self.name = None

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, cls):
        if obj is None:
            return self
        return obj._data.get(self.name)

    def __set__(self, obj, value):
        if obj._data.get(self.name) != value:
            obj._dirty.add(self.name)
        obj._data[self.name] = value


class ModelMeta(type):
    def __new__(mcls, name, bases, ns):
        cls = super().__new__(mcls, name, bases, ns)
        if not bases:
            return cls
        cols = OrderedDict()
        for base in reversed(cls.__mro__):
            for k, v in vars(base).items():
                if isinstance(v, Column):
                    cols[v.name or k] = v
        cls.__columns__ = cols
        cls.__table__ = ns.get('__tablename__', name.lower())
        cls.__pk__ = next(n for n, c in cols.items() if c.primary_key)
        REGISTRY.append(cls)
        return cls


class Model(metaclass=ModelMeta):
    __columns__ = OrderedDict()

    def __init__(self, **kw):
        self._data = {}
        self._dirty = set()
        self._persisted = False
        for k, v in kw.items():
            setattr(self, k, v)
        self._dirty.clear()      # 构造赋值不算修改

    @property
    def pk(self):
        return self._data.get(type(self).__pk__)

    def __repr__(self):
        return f'<{type(self).__name__} {self._data}>'


def create_all(conn):
    for cls in REGISTRY:
        cols = []
        for name, c in cls.__columns__.items():
            ddl = f'{name} {c.type}'
            if c.primary_key:
                ddl += ' PRIMARY KEY'
                if c.type.upper() == 'INTEGER':
                    ddl += ' AUTOINCREMENT'
            elif not c.nullable:
                ddl += ' NOT NULL'
            cols.append(ddl)
        conn.execute(f'CREATE TABLE IF NOT EXISTS {cls.__table__} ({", ".join(cols)})')
    conn.commit()


class Query:
    def __init__(self, session, cls):
        self.s = session
        self.cls = cls
        self._where, self._params = [], []

    def filter(self, column, op, value):
        if op not in ('=', '>', '<', '>=', '<=', '!=', 'LIKE'):
            raise ValueError(op)
        self._where.append(f'{column} {op} ?')
        self._params.append(value)
        return self

    def all(self):
        sql = f'SELECT * FROM {self.cls.__table__}'
        if self._where:
            sql += ' WHERE ' + ' AND '.join(self._where)
        rows = self.s.conn.execute(sql, self._params).fetchall()
        out = []
        for r in rows:
            data = dict(r)
            key = (self.cls, data[self.cls.__pk__])
            # 命中 identity map 就复用,不建新对象
            out.append(self.s.identity_map.get(key) or self.s._from_row(self.cls, r))
        return out

    def first(self):
        rows = self.all()
        return rows[0] if rows else None


class Session:
    def __init__(self, conn):
        self.conn = conn
        self.identity_map = {}
        self._new = []
        self._deleted = []
        conn.execute('PRAGMA foreign_keys = ON')

    def add(self, obj):
        self._new.append(obj)
        return obj

    def delete(self, obj):
        self._deleted.append(obj)

    def query(self, cls):
        return Query(self, cls)

    def get(self, cls, pk):
        key = (cls, pk)
        if key in self.identity_map:
            return self.identity_map[key]
        row = self.conn.execute(
            f'SELECT * FROM {cls.__table__} WHERE {cls.__pk__} = ?', (pk,)
        ).fetchone()
        return None if row is None else self._from_row(cls, row)

    def _from_row(self, cls, row):
        data = dict(row)
        obj = cls(**data)
        obj._persisted = True
        self.identity_map[(cls, data[cls.__pk__])] = obj
        return obj

    def _insert(self, obj):
        cls = type(obj)
        names, params = [], []
        for name, col in cls.__columns__.items():
            if col.primary_key and obj._data.get(name) is None:
                continue          # 自增主键不显式写入
            names.append(name)
            params.append(obj._data.get(name))
        sql = (f'INSERT INTO {cls.__table__} ({", ".join(names)}) '
               f'VALUES ({", ".join(["?"] * len(names))})')
        cur = self.conn.execute(sql, params)
        if obj._data.get(cls.__pk__) is None:
            obj._data[cls.__pk__] = cur.lastrowid
        obj._persisted = True
        self.identity_map[(cls, obj.pk)] = obj

    def flush(self):
        for obj in self._new:
            self._insert(obj)
        self._new.clear()

        for obj in self._deleted:
            self.conn.execute(
                f'DELETE FROM {type(obj).__table__} WHERE {type(obj).__pk__} = ?', (obj.pk,))
            self.identity_map.pop((type(obj), obj.pk), None)
        self._deleted.clear()

        for (cls, pk), obj in list(self.identity_map.items()):
            if not obj._dirty:
                continue
            sets = ', '.join(f'{n} = ?' for n in obj._dirty)
            params = [obj._data[n] for n in obj._dirty] + [pk]
            self.conn.execute(
                f'UPDATE {cls.__table__} SET {sets} WHERE {cls.__pk__} = ?', params)
            obj._dirty.clear()

    def commit(self):
        try:
            self.flush()
            self.conn.commit()
        except Exception:
            self.conn.rollback()
            self._new.clear()
            self.identity_map.clear()
            raise

    def close(self):
        self.identity_map.clear()
        self.conn.close()

跑一下,验证 identity map 和延迟 flush:

import sqlite3
from miniorm import Column, Model, Session, create_all

class User(Model):
    __tablename__ = 'users'
    id = Column('INTEGER', primary_key=True)
    name = Column('TEXT', nullable=False)

class Order(Model):
    __tablename__ = 'orders'
    id = Column('INTEGER', primary_key=True)
    user_id = Column('INTEGER', nullable=False)
    amount = Column('INTEGER', nullable=False)

conn = sqlite3.connect(':memory:')
conn.row_factory = sqlite3.Row
create_all(conn)

s = Session(conn)
s.add(User(name='alice'))
s.add(Order(user_id=1, amount=9900))
s.commit()

u1 = s.get(User, 1)
u2 = s.get(User, 1)
print(u1 is u2)          # True,identity map 命中

u1.name = 'bob'
u1.name = 'bob'          # 重复赋值,_dirty 不会重复计数
s.flush()                # 这一条 UPDATE 只在 flush 时发出
print(conn.execute('SELECT name FROM users WHERE id = 1').fetchone()[0])  # bob

__set__ 里那句 if obj._data.get(self.name) != value 顺带解决了重复赋值产生多余 UPDATE 的问题,不过它只挡住了「值没变」的情况,挡不住「改成 A 又改回原值」——那仍然会发一条 UPDATE。生产实现里这一层会用快照比对,代价是每次 flush 要遍历所有属性。

这套东西的价值不在于「我也能写 ORM」。下次查询日志里冒出 1001 条 SQL、看到 DetachedInstanceError、或者发现 10 万行导入跑了五分钟,你能立刻对上是哪个设计决策出了问题,以及该在哪一层修——是补 selectinload,是换 expire_on_commit,还是干脆绕开 ORM 走 executemany。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。