Flask数据库操作SQLAlchemy
本文是Flask服务器专栏的第四篇,系统讲解Flask与SQLAlchemy数据库操作。从ORM基础到高级查询,从模型定义到数据库迁移,从性能优化到实战案例,助你掌握Flask数据库开发核心技能。
引言
在现代Web应用开发中,数据库操作是最核心的功能之一。Flask本身不内置数据库功能,而是通过扩展与Python最强大的ORM框架SQLAlchemy深度集成,为开发者提供了一套完整、高效、优雅的数据库操作方案。本文将从零开始,系统讲解Flask数据库开发的完整知识体系,共分为十章,助你掌握Flask数据库开发核心技能。
各参数详解:
| 参数 | 默认值 | 说明 |
|---|---|---|
SQLALCHEMY_DATABASE_URI |
None | 主数据库连接URI(必填) |
SQLALCHEMY_BINDS |
{} | 多数据库绑定配置字典 |
SQLALCHEMY_ENGINE_OPTIONS |
{} | 引擎选项(连接池等),传给create_engine |
SQLALCHEMY_ECHO |
False | 是否在日志中打印执行的SQL语句(调试用) |
SQLALCHEMY_RECORD_QUERIES |
None | 是否记录查询统计信息(用于性能分析) |
SQLALCHEMY_TRACK_MODIFICATIONS |
False | 是否追踪对象修改(3.x已废弃,建议设为False) |
2.3.1 SQLALCHEMY_TRACK_MODIFICATIONS
这个参数在Flask-SQLAlchemy 3.x中已不再需要,建议显式设为False以避免警告:
python
# False: 不追踪修改(推荐,节省内存)
# True: 追踪对象修改,会额外内存开销(不推荐)
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
2.3.2 SQLALCHEMY_ECHO
开发调试时非常有用的参数,会在日志中打印所有执行的SQL:
python
# 开发环境开启,生产环境关闭
app.config['SQLALCHEMY_ECHO'] = True # 开发环境
# 日志输出示例:
# 2024-01-01 10:00:00 INFO sqlalchemy.engine.Engine SELECT users.id, users.name FROM users WHERE users.name = ?
# 2024-01-01 10:00:00 INFO sqlalchemy.engine.Engine [generated in 0.00012s] ('张三',)
2.3.3 SQLALCHEMY_ENGINE_OPTIONS
这是最重要的配置之一,用于控制数据库引擎和连接池行为:
python
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
# --- 连接池配置 ---
'pool_size': 10, # 连接池中保持的连接数(默认5)
'max_overflow': 5, # 超出pool_size后允许的额外连接数(默认10)
'pool_timeout': 30, # 获取连接的超时时间(秒,默认30)
'pool_recycle': 3600, # 连接自动回收时间(秒,默认-1不回收)
'pool_pre_ping': True, # 使用连接前先检测是否有效(推荐开启)
# --- 日志配置 ---
'echo': False, # 是否打印SQL(同SQLALCHEMY_ECHO)
'echo_pool': False, # 是否打印连接池日志
# --- 其他配置 ---
'isolation_level': 'READ_COMMITTED', # 事务隔离级别
'future': True, # 使用SQLAlchemy 2.0风格(默认True)
}
2.4 初始化SQLAlchemy实例
初始化是Flask-SQLAlchemy使用的第一步,有两种方式:直接初始化和延迟初始化。
2.4.1 直接初始化(简单项目)
python
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
# 创建Flask应用
app = Flask(__name__)
# 配置数据库
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# 初始化SQLAlchemy ------ 直接传入app
db = SQLAlchemy(app)
# 定义模型
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
# 使用
with app.app_context():
db.create_all()
user = User(username='张三')
db.session.add(user)
db.session.commit()
2.4.2 延迟初始化(推荐,适合大型项目)
python
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
# 先创建db对象,不传入app
db = SQLAlchemy()
def create_app():
"""应用工厂函数"""
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# 延迟初始化 ------ 此时才关联app和db
db.init_app(app)
# 在应用上下文中创建表
with app.app_context():
db.create_all()
return app
if __name__ == '__main__':
app = create_app()
app.run(debug=True)
2.5 工厂模式中的数据库初始化
在大型Flask项目中,通常使用工厂模式(Application Factory Pattern)来创建应用实例。这种模式下,数据库的初始化需要特别处理。
2.5.1 完整的工厂模式项目结构
myapp/
├── app/
│ ├── __init__.py # 应用工厂
│ ├── extensions.py # 扩展实例(不绑定app)
│ ├── models.py # 数据模型
│ ├── config.py # 配置
│ ├── auth/
│ │ ├── __init__.py
│ │ └── routes.py
│ └── blog/
│ ├── __init__.py
│ └── routes.py
├── migrations/ # 迁移脚本
├── tests/
├── run.py # 启动脚本
└── requirements.txt
app/extensions.py ------ 存放扩展实例:
python
"""
扩展实例文件
所有扩展在这里创建,但不绑定到具体的app
在应用工厂函数中通过init_app()方法绑定
"""
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
# 创建SQLAlchemy实例(尚未绑定到任何app)
db = SQLAlchemy()
# 创建Migrate实例(用于数据库迁移)
migrate = Migrate()
app/config.py ------ 配置文件:
python
import os
from pathlib import Path
basedir = Path(__file__).resolve().parent.parent
class Config:
"""基础配置"""
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
# 数据库配置
SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL',
f'sqlite:///{basedir / "app.db"}'
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# 引擎选项
SQLALCHEMY_ENGINE_OPTIONS = {
'pool_size': 10,
'max_overflow': 5,
'pool_timeout': 30,
'pool_recycle': 3600,
'pool_pre_ping': True,
}
class DevelopmentConfig(Config):
"""开发环境配置"""
DEBUG = True
SQLALCHEMY_ECHO = True # 开发环境打印SQL
class ProductionConfig(Config):
"""生产环境配置"""
DEBUG = False
SQLALCHEMY_ECHO = False
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
class TestingConfig(Config):
"""测试环境配置"""
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite://' # 内存数据库
SQLALCHEMY_ENGINE_OPTIONS = {} # 测试不需要连接池
# 配置映射
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig,
}
app/__init__.py ------ 应用工厂:
python
"""
应用工厂模块
通过create_app()函数创建和配置Flask应用
"""
from flask import Flask
from app.config import config
from app.extensions import db, migrate
def create_app(config_name='default'):
"""创建Flask应用实例
Args:
config_name: 配置名称(development/production/testing)
Returns:
Flask应用实例
"""
app = Flask(__name__)
# 加载配置
app.config.from_object(config[config_name])
# 初始化扩展
db.init_app(app)
migrate.init_app(app, db) # 必须在db.init_app之后
# 注册蓝图
from app.auth import auth_bp
from app.blog import blog_bp
app.register_blueprint(auth_bp)
app.register_blueprint(blog_bp)
# 导入模型(确保模型被SQLAlchemy注册)
from app import models
return app
run.py ------ 启动脚本:
python
import os
from app import create_app
# 从环境变量读取配置名,默认使用development
config_name = os.environ.get('FLASK_CONFIG', 'development')
app = create_app(config_name)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
2.6 多数据库绑定
在某些场景下,一个应用可能需要连接多个数据库。例如:用户数据在一个数据库,日志数据在另一个数据库。Flask-SQLAlchemy通过SQLALCHEMY_BINDS支持多数据库绑定。
2.6.1 配置多数据库
python
app = Flask(__name__)
# 主数据库
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:pass@localhost/main_db'
# 绑定的其他数据库
app.config['SQLALCHEMY_BINDS'] = {
'users': 'mysql+pymysql://root:pass@localhost/users_db',
'logs': 'postgresql://user:pass@localhost/logs_db',
'cache': 'sqlite:///cache.db',
}
2.6.2 在模型中指定数据库
python
db = SQLAlchemy(app)
class User(db.Model):
"""用户模型 ------ 存储在users数据库"""
__bind_key__ = 'users'
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
class AccessLog(db.Model):
"""访问日志模型 ------ 存储在logs数据库"""
__bind_key__ = 'logs'
__tablename__ = 'access_logs'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, nullable=False)
ip_address = db.Column(db.String(45))
accessed_at = db.Column(db.DateTime, server_default=db.func.now())
class CacheItem(db.Model):
"""缓存模型 ------ 存储在cache数据库"""
__bind_key__ = 'cache'
__tablename__ = 'cache_items'
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(200), unique=True, nullable=False)
value = db.Column(db.Text)
expires_at = db.Column(db.DateTime)
class Article(db.Model):
"""文章模型 ------ 存储在主数据库(不指定__bind_key__)"""
__tablename__ = 'articles'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text)
2.6.3 操作多数据库
python
with app.app_context():
# 创建所有数据库的表
db.create_all()
# 也可以只创建指定bind的表
db.create_all(bind_key='users')
# 操作不同数据库的模型 ------ 使用方式完全一致
user = User(username='张三', email='zhangsan@example.com')
db.session.add(user) # 自动路由到users数据库
log = AccessLog(user_id=1, ip_address='192.168.1.1')
db.session.add(log) # 自动路由到logs数据库
article = Article(title='Flask教程', content='...')
db.session.add(article) # 路由到主数据库
db.session.commit() # 一次性提交所有数据库的变更
2.6.4 多数据库绑定的注意事项
python
# 注意: 跨数据库的外键关系通常不被支持!
class User(db.Model):
__bind_key__ = 'users'
id = db.Column(db.Integer, primary_key=True)
class Article(db.Model):
# 主数据库的表不能引用users数据库的外键
# author_id = db.Column(db.Integer, db.ForeignKey('users.id')) # 这会报错!
author_id = db.Column(db.Integer) # 只能用普通字段,在应用层维护关系
2.7 连接池配置
连接池(Connection Pool)是数据库性能优化的关键。它维护一组数据库连接,避免频繁创建和销毁连接的开销。
2.7.1 连接池工作原理
应用代码 连接池 数据库
│ │ │
│── 获取连接 ──────────────────>│ │
│<── 返回空闲连接 ──────────────│ │
│ │ │
│── 执行SQL ─────────────────────────────────────────────>│
│<── 返回结果 ────────────────────────────────────────────│
│ │ │
│── 归还连接 ──────────────────>│ │
│ │ │
2.7.2 连接池参数详解
python
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_size': 10, # 连接池大小
'max_overflow': 5, # 最大溢出连接数
'pool_timeout': 30, # 获取连接超时时间
'pool_recycle': 3600, # 连接回收时间
'pool_pre_ping': True, # 连接前预检测
}
各参数的含义和工作机制:
python
# 假设配置: pool_size=10, max_overflow=5
#
# 连接数变化:
# ┌─────────────────────────────────────────────────┐
# │ 状态 │ 活跃连接 │ 空闲连接 │ 总连接 │
# ├─────────────────────────────────────────────────┤
# │ 初始(无请求) │ 0 │ 0 │ 0 │
# │ 5个并发请求 │ 5 │ 0 │ 5 │
# │ 请求完成(归还5个) │ 0 │ 5 │ 5 │
# │ 15个并发请求 │ 10 │ 0 │ 10 │ (达到pool_size)
# │ 又来5个请求(溢出) │ 15 │ 0 │ 15 │ (达到pool_size+max_overflow)
# │ 又来1个请求 │ 等待... │ 0 │ 15 │ (等待pool_timeout秒后报错)
# └─────────────────────────────────────────────────┘
2.7.3 不同场景的连接池配置建议
python
# ========== 开发环境 ==========
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_size': 5,
'max_overflow': 2,
'pool_timeout': 10,
'pool_pre_ping': True,
}
# ========== 生产环境(中型应用) ==========
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_size': 20,
'max_overflow': 10,
'pool_timeout': 30,
'pool_recycle': 1800, # 30分钟回收,避免数据库端超时断开
'pool_pre_ping': True,
}
# ========== 生产环境(高并发) ==========
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_size': 50,
'max_overflow': 20,
'pool_timeout': 60,
'pool_recycle': 1200, # 20分钟回收
'pool_pre_ping': True,
}
# ========== SQLite(不支持连接池,使用QueuePool的替代方案) ==========
# SQLite默认使用SingletonThreadPool或NullPool
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'poolclass': 'sqlalchemy.pool.SingletonThreadPool', # 每个线程一个连接
}
2.7.4 pool_pre_ping的重要性
python
# 问题场景: 数据库连接被服务端断开(超时、重启等)
# 如果不开pool_pre_ping,会抛出异常:
# sqlalchemy.exc.OperationalError: (pymysql.err.OperationalError)
# (2006, "MySQL server has gone away")
# 开启pool_pre_ping后,每次从连接池取连接前先执行一个简单查询
# 如果连接已断开,自动重新创建连接
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_pre_ping': True, # 强烈建议开启!
}
2.7.5 自定义连接池类
python
from sqlalchemy.pool import QueuePool, NullPool, SingletonThreadPool
# NullPool: 不使用连接池,每次都创建新连接(适合短生命周期的脚本)
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'poolclass': NullPool,
}
# QueuePool: 标准连接池(默认)
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'poolclass': QueuePool,
'pool_size': 10,
'max_overflow': 5,
}
2.8 数据库连接最佳实践
2.8.1 环境变量管理敏感信息
python
import os
from urllib.parse import quote_plus
# 从环境变量读取数据库配置,避免在代码中硬编码
DB_USER = os.environ.get('DB_USER', 'root')
DB_PASSWORD = os.environ.get('DB_PASSWORD', '')
DB_HOST = os.environ.get('DB_HOST', 'localhost')
DB_PORT = os.environ.get('DB_PORT', '3306')
DB_NAME = os.environ.get('DB_NAME', 'myapp')
# 密码URL编码(处理特殊字符)
encoded_password = quote_plus(DB_PASSWORD)
SQLALCHEMY_DATABASE_URI = (
f'mysql+pymysql://{DB_USER}:{encoded_password}'
f'@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4'
)
使用.env文件管理环境变量:
python
# 安装python-dotenv: pip install python-dotenv
from dotenv import load_dotenv
load_dotenv() # 从.env文件加载环境变量
# .env文件内容:
# DB_USER=root
# DB_PASSWORD=my_secret_password
# DB_HOST=localhost
# DB_PORT=3306
# DB_NAME=myapp
# SECRET_KEY=your-secret-key
2.8.2 正确处理Session生命周期
python
from flask import Flask
from contextlib import contextmanager
app = Flask(__name__)
db = SQLAlchemy(app)
@contextmanager
def session_scope():
"""提供事务范围的上下文管理器
使用方式:
with session_scope() as session:
session.add(user)
# ... 其他操作
# 退出with块时自动commit或rollback
"""
session = db.session
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
# 使用示例
@app.route('/transfer', methods=['POST'])
def transfer():
try:
with session_scope() as session:
from_account = session.get(Account, request.form['from_id'])
to_account = session.get(Account, request.form['to_id'])
amount = float(request.form['amount'])
from_account.balance -= amount
to_account.balance += amount
# 退出with块时自动提交
return '转账成功!'
except Exception as e:
return f'转账失败: {e}', 400
2.9 完整配置示例
下面是一个生产级Flask应用的完整数据库配置示例:
python
"""
完整配置示例 ------ 生产级Flask应用的数据库配置
"""
import os
from pathlib import Path
from urllib.parse import quote_plus
from dotenv import load_dotenv
# 加载.env文件
load_dotenv()
basedir = Path(__file__).resolve().parent.parent
class Config:
"""基础配置类"""
# Flask配置
SECRET_KEY = os.environ.get('SECRET_KEY', 'change-me-in-production')
# ====== 数据库主配置 ======
# 优先使用环境变量中的DATABASE_URL
SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL',
f'sqlite:///{basedir / "instance" / "app.db"}'
)
# 禁用修改追踪(节省内存)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# ====== 引擎选项(连接池) ======
SQLALCHEMY_ENGINE_OPTIONS = {
# 连接池大小
'pool_size': int(os.environ.get('DB_POOL_SIZE', 10)),
# 最大溢出连接数
'max_overflow': int(os.environ.get('DB_MAX_OVERFLOW', 5)),
# 获取连接超时(秒)
'pool_timeout': int(os.environ.get('DB_POOL_TIMEOUT', 30)),
# 连接回收周期(秒) ------ 防止数据库端断开连接
'pool_recycle': int(os.environ.get('DB_POOL_RECYCLE', 1800)),
# 使用前预检测连接有效性 ------ 防止"server has gone away"
'pool_pre_ping': True,
}
# ====== 多数据库绑定(可选) ======
SQLALCHEMY_BINDS = {}
@staticmethod
def init_app(app):
"""应用初始化钩子"""
# 确保instance目录存在
instance_dir = basedir / 'instance'
instance_dir.mkdir(exist_ok=True)
class DevelopmentConfig(Config):
"""开发环境配置"""
DEBUG = True
SQLALCHEMY_ECHO = os.environ.get('SQL_ECHO', 'true').lower() == 'true'
SQLALCHEMY_ENGINE_OPTIONS = {
**Config.SQLALCHEMY_ENGINE_OPTIONS,
'pool_size': 5,
'max_overflow': 2,
}
class ProductionConfig(Config):
"""生产环境配置"""
DEBUG = False
@staticmethod
def init_app(app):
Config.init_app(app)
# 生产环境日志配置
import logging
from logging.handlers import RotatingFileHandler
# 确保日志目录存在
log_dir = basedir / 'logs'
log_dir.mkdir(exist_ok=True)
handler = Rota
## 第四章 数据库操作: CRUD
CRUD是Create(创建)、Read(读取)、Update(更新)、Delete(删除)四种基本数据库操作的缩写。本章将全面讲解Flask-SQLAlchemy中的CRUD操作,包括Session管理、事务控制和并发控制等核心知识。
### 4.1 创建记录
创建记录是将新的数据行插入到数据库表中的操作。
#### 4.1.1 创建单条记录
```python
from datetime import datetime
# 创建模型实例
user = User(
username='zhangsan',
email='zhangsan@example.com',
is_active=True
)
# 方式1: 使用db.session.add()添加到session,然后commit
db.session.add(user) # 将对象添加到session(暂存区)
db.session.commit() # 提交事务,真正写入数据库
# 提交后,user.id会被自动赋值(获取自增主键的值)
print(f"新用户ID: {user.id}")
# 方式2: 在Flask视图函数中创建记录
@app.route('/register', methods=['POST'])
def register():
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
# 检查用户名是否已存在
existing = db.session.execute(
select(User).where(User.username == username)
).scalar_one_or_none()
if existing:
return '用户名已存在', 400
# 创建新用户
user = User(username=username, email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
return f'注册成功!用户ID: {user.id}', 201
4.1.2 创建多条记录
python
# 方式1: 使用add_all()批量添加
users = [
User(username='user1', email='user1@example.com'),
User(username='user2', email='user2@example.com'),
User(username='user3', email='user3@example.com'),
User(username='user4', email='user4@example.com'),
User(username='user5', email='user5@example.com'),
]
db.session.add_all(users) # 批量添加到session
db.session.commit() # 一次性提交
# 所有用户都会被插入,各自获得自增ID
# 方式2: 循环添加(不推荐,效率较低)
# for user in users:
# db.session.add(user)
# db.session.commit()
# 方式3: 批量插入(高性能,跳过ORM对象创建)
# 适用于大批量数据导入
db.session.execute(
db.insert(User),
[
{'username': 'user6', 'email': 'user6@example.com'},
{'username': 'user7', 'email': 'user7@example.com'},
{'username': 'user8', 'email': 'user8@example.com'},
]
)
db.session.commit()
4.1.3 创建带关系的记录
python
# 创建带一对多关系的记录
category = Category(name='Python')
category.articles = [
Article(title='Python基础'),
Article(title='Python进阶'),
Article(title='Python高级'),
]
db.session.add(category) # 级联添加: category和3个articles都会被添加
db.session.commit()
# 创建带多对多关系的记录
flask_tag = Tag(name='Flask')
python_tag = Tag(name='Python')
article = Article(title='Flask教程')
article.tags = [flask_tag, python_tag] # 设置多对多关系
db.session.add_all([flask_tag, python_tag, article])
db.session.commit()
4.1.4 get_or_create模式
python
def get_or_create(model, defaults=None, **kwargs):
"""获取或创建记录
如果满足条件的记录已存在,返回该记录;
如果不存在,创建新记录并返回。
Args:
model: 模型类
defaults: 创建时的默认值字典
**kwargs: 查询条件
"""
instance = db.session.execute(
select(model).filter_by(**kwargs)
).scalar_one_or_none()
if instance:
return instance, False # 已存在,返回(实例, False)
# 不存在,创建新记录
params = kwargs.copy()
if defaults:
params.update(defaults)
instance = model(**params)
db.session.add(instance)
db.session.commit()
return instance, True # 新创建,返回(实例, True)
# 使用示例
tag, created = get_or_create(Tag, name='Flask')
if created:
print(f"创建了新标签: {tag.name}")
else:
print(f"标签已存在: {tag.name}")
4.2 查询记录
查询是数据库操作中最常用的操作。SQLAlchemy 2.0推荐使用select()语句来构建查询。
4.2.1 基本查询
python
from sqlalchemy import select
# ========== SQLAlchemy 2.0 风格 ==========
# 查询所有记录
stmt = select(User)
users = db.session.execute(stmt).scalars().all()
# scalars()提取每一行的第一个元素(即User对象)
# all()将结果转换为列表
# 查询单条记录(按主键)
user = db.session.get(User, 1) # 按主键查询,最高效的方式
# 等价于: user = db.session.execute(select(User).where(User.id == 1)).scalar_one()
# 查询第一条记录
stmt = select(User).where(User.is_active == True)
user = db.session.execute(stmt).scalars().first() # 返回第一条或None
# 查询唯一记录(如果结果不唯一会抛异常)
stmt = select(User).where(User.username == 'zhangsan')
user = db.session.execute(stmt).scalar_one() # 结果必须恰好一条,否则报错
# 查询唯一记录或None(如果结果为空返回None,多条报错)
stmt = select(User).where(User.email == 'zhangsan@example.com')
user = db.session.execute(stmt).scalar_one_or_none()
4.2.2 查询指定列
python
from sqlalchemy import select
# 查询指定列(返回Row对象,不是模型实例)
stmt = select(User.username, User.email)
result = db.session.execute(stmt).all()
for row in result:
print(row.username, row.email) # 通过属性名访问
# 查询指定列并转为字典
stmt = select(User.id, User.username, User.email)
result = db.session.execute(stmt)
users_list = [row._mapping for row in result] # 转为字典列表
# 使用with_entities指定列
# SQLAlchemy 2.0中,with_entities仍然可用
users = db.session.query(User).with_entities(User.id, User.username).all()
4.3 查询过滤器
过滤器用于在查询中添加条件,缩小查询范围。
4.3.1 filter vs filter_by
python
# ========== filter_by: 使用关键字参数,简洁但功能有限 ==========
# 适合简单的等值查询
users = db.session.execute(
select(User).filter_by(username='zhangsan', is_active=True)
).scalars().all()
# ========== filter: 使用表达式,功能强大 ==========
# 支持各种比较运算符
users = db.session.execute(
select(User).filter(
User.username == 'zhangsan', # 等于
User.is_active == True, # 等于
)
).scalars().all()
4.3.2 排序: order_by
python
from sqlalchemy import select, desc, asc
# 升序排列(默认)
users = db.session.execute(
select(User).order_by(User.username) # 按用户名升序
).scalars().all()
# 降序排列
users = db.session.execute(
select(User).order_by(desc(User.created_at)) # 按创建时间降序
).scalars().all()
# 多列排序
users = db.session.execute(
select(User).order_by(
User.is_active.desc(), # 先按是否激活降序(激活的在前)
User.username.asc(), # 再按用户名升序
)
).scalars().all()
# NULL值的排序位置
from sqlalchemy import nullsfirst, nullslast
users = db.session.execute(
select(User).order_by(
nullslast(User.last_login) # NULL值排在最后
)
).scalars().all()
4.3.3 分组与聚合: group_by, having
python
from sqlalchemy import func, select
# 按状态分组,统计每种状态的文章数量
stmt = (
select(Article.status, func.count(Article.id).label('count'))
.group_by(Article.status)
)
result = db.session.execute(stmt).all()
for row in result:
print(f"状态: {row.status}, 数量: {row.count}")
# 使用having过滤分组结果(只显示文章数大于5的状态)
stmt = (
select(Article.status, func.count(Article.id).label('count'))
.group_by(Article.status)
.having(func.count(Article.id) > 5)
)
result = db.session.execute(stmt).all()
# 按分类分组,统计每个分类的文章数和总浏览量
stmt = (
select(
Category.name.label('category_name'),
func.count(Article.id).label('article_count'),
func.sum(Article.view_count).label('total_views'),
func.avg(Article.view_count).label('avg_views'),
)
.join(Article, Category.id == Article.category_id)
.group_by(Category.id, Category.name)
.having(func.count(Article.id) > 0)
.order_by(func.sum(Article.view_count).desc())
)
result = db.session.execute(stmt).all()
4.3.4 去重: distinct
python
from sqlalchemy import distinct
# 查询所有不同的城市
cities = db.session.execute(
select(distinct(UserProfile.address))
).scalars().all()
# 查询文章中不同的标签组合
stmt = select(Article.id, Article.title).distinct()
articles = db.session.execute(stmt).scalars().all()
# 统计不同城市的数量
city_count = db.session.execute(
select(func.count(distinct(UserProfile.address)))
).scalar()
4.3.5 限制与偏移: limit, offset
python
# 查询前10条记录
users = db.session.execute(
select(User).limit(10)
).scalars().all()
# 跳过前20条,查询10条(第3页,每页10条)
users = db.session.execute(
select(User).offset(20).limit(10)
).scalars().all()
# 使用切片(更Pythonic)
# 注意: 在SQLAlchemy 2.0中,select语句不支持切片
# 需要使用limit和offset
page = 3
per_page = 10
offset = (page - 1) * per_page
users = db.session.execute(
select(User).offset(offset).limit(per_page)
).scalars().all()
4.4 常用查询函数
python
from sqlalchemy import select, func
# ========== count: 统计记录数 ==========
# 方式1
total = db.session.execute(
select(func.count(User.id))
).scalar()
# 方式2: 统计满足条件的记录数
active_count = db.session.execute(
select(func.count(User.id)).where(User.is_active == True)
).scalar()
# 方式3: 使用select与exists
from sqlalchemy import exists
has_admin = db.session.execute(
select(exists().where(User.is_admin == True))
).scalar()
# ========== first: 获取第一条 ==========
first_user = db.session.execute(
select(User).order_by(User.id)
).scalars().first() # 返回User对象或None
# ========== one: 获取唯一一条(不存在或多条都会报错) ==========
try:
user = db.session.execute(
select(User).where(User.username == 'zhangsan')
).scalar_one()
except NoResultFound:
print("未找到记录")
except MultipleResultsFound:
print("找到多条记录")
# ========== scalar: 获取标量值(第一行第一列) ==========
count = db.session.execute(
select(func.count(User.id))
).scalar() # 直接返回整数值
# ========== get: 按主键查询(最优化) ==========
user = db.session.get(User, 1) # 按主键查询,利用identity map缓存
# ========== exists: 检查记录是否存在 ==========
user_exists = db.session.execute(
select(User.id).where(User.email == 'zhangsan@example.com')
).first() is not None
4.5 更新记录
4.5.1 直接修改属性
python
# 方式1: 查询后修改属性
user = db.session.get(User, 1)
if user:
user.email = 'new_email@example.com'
user.is_active = False
db.session.commit() # 提交修改
# 方式2: 在视图函数中更新
@app.route('/user/<int:user_id>/update', methods=['POST'])
def update_user(user_id):
user = db.session.get(User, user_id)
if not user:
return '用户不存在', 404
if 'email' in request.form:
user.email = request.form['email']
if 'username' in request.form:
user.username = request.form['username']
db.session.commit()
return '更新成功'
4.5.2 批量更新
python
from sqlalchemy import update
# 方式1: 使用update()语句批量更新(SQLAlchemy 2.0推荐)
# 将所有状态为draft的文章的状态更新为archived
db.session.execute(
update(Article)
.where(Article.status == 'draft')
.values(status='archived', updated_at=datetime.utcnow())
)
db.session.commit()
# 方式2: 使用 synchronize_session 控制session同步策略
db.session.execute(
update(Article)
.where(Article.status == 'draft')
.values(status='archived')
.execution_options(synchronize_session='fetch')
# synchronize_session选项:
# 'auto': 自动选择(默认)
# 'fetch': 先查询受影响的行,再更新session中的对象
# 'evaluate': 在Python层面评估条件,更新匹配的session对象
# False: 不同步session(更新后session中的对象可能过期)
)
db.session.commit()
# 方式3: 查询后循环修改(不推荐大数据量)
articles = db.session.execute(
select(Article).where(Article.status == 'draft')
).scalars().all()
for article in articles:
article.status = 'archived'
db.session.commit()
# 这种方式会产生N条UPDATE语句,效率低
4.5.3 更新JSON字段
python
# 更新整个JSON字段
user = db.session.get(User, 1)
user.metadata_json = {'theme': 'dark', 'language': 'zh-CN'}
db.session.commit()
# PostgreSQL: 更新JSON字段的某个键
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy import update
from sqlalchemy.dialects.postgresql import JSONB
# 使用JSON路径更新(PostgreSQL特有)
db.session.execute(
update(User)
.where(User.id == 1)
.values(
metadata_json=User.metadata_json['theme'].astext == 'dark'
)
)
4.6 删除记录
4.6.1 删除单条记录
python
# 方式1: 查询后删除
user = db.session.get(User, 1)
if user:
db.session.delete(user) # 从session标记为删除
db.session.commit() # 提交,真正从数据库删除
# 方式2: 在视图函数中删除
@app.route('/user/<int:user_id>/delete', methods=['POST'])
def delete_user(user_id):
user = db.session.get(User, user_id)
if not user:
return '用户不存在', 404
db.session.delete(user)
db.session.commit()
return '删除成功'
4.6.2 批量删除
python
from sqlalchemy import delete
# 方式1: 使用delete()语句批量删除(SQLAlchemy 2.0推荐)
# 删除所有未激活的用户
result = db.session.execute(
delete(User).where(User.is_active == False)
)
db.session.commit()
print(f"删除了 {result.rowcount} 条记录")
# 方式2: 查询后循环删除(不推荐大数据量)
inactive_users = db.session.execute(
select(User).where(User.is_active == False)
).scalars().all()
for user in inactive_users:
db.session.delete(user)
db.session.commit()
# 方式3: 软删除(推荐,数据可恢复)
db.session.execute(
update(User)
.where(User.is_active == False)
.values(is_deleted=True, updated_at=datetime.utcnow())
)
db.session.commit()
4.7 事务管理
事务是数据库操作的核心概念。Session本身就是事务的载体。
4.7.1 基本事务操作
python
# ========== commit: 提交事务 ==========
# 将session中所有挂起的变更写入数据库
db.session.commit()
# ========== rollback: 回滚事务 ==========
# 撤销所有未提交的变更
try:
user1 = User(username='user1', email='user1@example.com')
user2 = User(username='user2', email='user1@example.com') # 邮箱重复!
db.session.add_all([user1, user2])
db.session.commit()
except Exception as e:
db.session.rollback() # 回滚,撤销所有变更
print(f"操作失败: {e}")
# ========== flush: 刷新(不提交) ==========
# 将session中的变更发送到数据库,但不提交事务
# 主要用于获取自增ID或验证约束
user = User(username='flush_test', email='flush@example.com')
db.session.add(user)
db.session.flush() # 发送到数据库,获取user.id
print(f"用户ID: {user.id}") # 此时可以获取ID
# 但事务尚未提交,其他连接看不到这条记录
db.session.commit() # 正式提交
# ========== close: 关闭session ==========
db.session.close() # 关闭session,释放资源
4.7.2 嵌套事务与SAVEPOINT
python
# 使用begin_nested()创建SAVEPOINT(嵌套事务)
try:
# 外层事务
user = User(username='outer', email='outer@example.com')
db.session.add(user)
db.session.flush()
# 内层事务(SAVEPOINT)
try:
nested = db.session.begin_nested()
inner_user = User(username='inner', email='inner@example.com')
db.session.add(inner_user)
nested.commit() # 提交SAVEPOINT
except Exception:
nested.rollback() # 回滚SAVEPOINT,外层事务不受影响
# 外层事务继续
db.session.commit() # 提交外层事务
except Exception:
db.session.rollback()
4.7.3 事务上下文管理器
python
from contextlib import contextmanager
@contextmanager
def transaction():
"""事务上下文管理器
自动管理事务的提交和回滚
"""
try:
yield db.session
db.session.commit()
except Exception:
db.session.rollback()
raise
# 使用示例
@app.route('/transfer', methods=['POST'])
def transfer_money():
from_id = int(request.form['from_id'])
to_id = int(request.form['to_id'])
amount = float(request.form['amount'])
try:
with transaction() as session:
from_account = session.get(Account, from_id)
to_account = session.get(Account, to_id)
if from_account.balance < amount:
raise ValueError("余额不足")
from_account.balance -= amount
to_account.balance += amount
return '转账成功'
except ValueError as e:
return str(e), 400
except Exception as e:
return f'系统错误: {e}', 500
4.8 事务隔离级别
事务隔离级别控制并发事务之间的可见性。SQLAlchemy支持设置不同的事务隔离级别:
python
from sqlalchemy import create_engine
# 在引擎配置中设置隔离级别
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'isolation_level': 'READ_COMMITTED', # 设置隔离级别
}
# 事务隔离级别对照表:
# READ UNCOMMITTED - 读未提交(脏读)
# READ COMMITTED - 读已提交(防止脏读) ← PostgreSQL/Oracle默认
# REPEATABLE READ - 可重复读(防止脏读、不可重复读) ← MySQL默认
# SERIALIZABLE - 串行化(最高隔离级别,防止所有并发问题)
python
# 在运行时动态设置隔离级别
from sqlalchemy import text
# 设置当前会话的隔离级别
db.session.execute(text("SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED"))
# 查询当前隔离级别
result = db.session.execute(text("SELECT @@transaction_isolation"))
isolation = result.scalar()
print(f"当前隔离级别: {isolation}")
4.9 乐观锁与悲观锁
在并发环境下,多个请求可能同时修改同一条记录,需要锁机制来保证数据一致性。
4.9.1 乐观锁
乐观锁假设冲突很少发生,通过版本号机制在更新时检测冲突:
python
class Product(db.Model):
__tablename__ = 'products'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
stock = db.Column(db.Integer, default=0)
version = db.Column(db.Integer, default=0) # 版本号,用于乐观锁
price = db.Column(db.Numeric(10, 2))
def purchase_with_optimistic_lock(product_id, quantity):
"""使用乐观锁进行库存扣减
如果在读取和更新之间,记录被其他事务修改了,
更新会失败(影响行数为0),此时重试。
"""
max_retries = 3
for attempt in range(max_retries):
# 1. 读取当前数据(包含版本号)
product = db.session.get(Product, product_id)
if not product:
raise ValueError("商品不存在")
if product.stock < quantity:
raise ValueError("库存不足")
# 2. 计算新库存
new_stock = product.stock - quantity
old_version = product.version
# 3. 尝试更新(带版本号检查)
# WHERE条件中包含version,如果version变了则更新0行
result = db.session.execute(
db.update(Product)
.where(
Product.id == product_id,
Product.version == old_version # 乐观锁检查
)
.values(
stock=new_stock,
version=old_version + 1 # 版本号+1
)
)
if result.rowcount == 1:
db.session.commit()
return True # 更新成功
else:
# 版本号不匹配,说明被其他事务修改了
db.session.rollback()
if attempt == max_retries - 1:
raise ValueError("并发冲突,重试次数用尽")
continue # 重试
return False
4.9.2 悲观锁
悲观锁假设冲突经常发生,在读取数据时就加锁:
python
from sqlalchemy import select, with_for_update
def purchase_with_pessimistic_lock(product_id, quantity):
"""使用悲观锁进行库存扣减
SELECT ... FOR UPDATE 会对查询的行加排他锁,
其他事务必须等待锁释放才能读取或修改。
"""
try:
# 使用with_for_update()加行锁
stmt = (
select(Product)
.where(Product.id == product_id)
.with_for_update() # SELECT ... FOR UPDATE
)
product = db.session.execute(stmt).scalar_one()
if product.stock < quantity:
raise ValueError("库存不足")
product.stock -= quantity
db.session.commit()
return True
except Exception:
db.session.rollback()
raise
# with_for_update的参数
stmt = (
select(Product)
.where(Product.id == product_id)
.with_for_update(
nowait=True, # 不等待锁,获取不到立即报错
# skip_locked=True, # 跳过已锁定的行(MySQL/PostgreSQL)
# of=Product, # 指定锁定的表
)
)
乐观锁与悲观锁对比:
| 特性 | 乐观锁 | 悲观锁 |
|---|---|---|
| 实现 | 版本号 + CAS | SELECT FOR UPDATE |
| 并发性能 | 高(不阻塞读) | 低(阻塞其他事务) |
| 适用场景 | 冲突少、读多写少 | 冲突多、写操作频繁 |
| 回滚成本 | 需要重试 | 直接等待 |
| 死锁风险 | 无 | 有 |
4.10 CRUD完整实战案例
下面是一个完整的用户管理API,涵盖所有CRUD操作:
python
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import select, or_
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users_demo.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
class User(db.Model):
"""用户模型"""
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(256), nullable=False)
full_name = db.Column(db.String(100))
is_active = db.Column(db.Boolean, default=True, server_default='true')
is_admin = db.Column(db.Boolean, default=False, server_default='false')
created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now())
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def to_dict(self):
return {
'id': self.id,
'username': self.username,
'email': self.email,
'full_name': self.full_name,
'is_active': self.is_active,
'is_admin': self.is_admin,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<User {self.username}>'
# ========== CREATE: 创建用户 ==========
@app.route('/api/users', methods=['POST'])
def create_user():
"""创建新用户"""
data = request.get_json()
# 验证必填字段
required_fields = ['username', 'email', 'password']
for field in required_fields:
if not data.get(field):
return jsonify({'error': f'缺少必填字段: {field}'}), 400
# 检查用户名和邮箱是否已存在
existing = db.session.execute(
select(User).where(
or_(
User.username == data['username'],
User.email == data['email']
)
)
).scalar_one_or_none()
if existing:
if existing.username == data['username']:
return jsonify({'error': '用户名已存在'}), 409
else:
return jsonify({'error': '邮箱已存在'}), 409
# 创建用户
user = User(
username=data['username'],
email=data['email'],
full_name=data.get('full_name'),
)
user.set_password(data['password'])
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict()), 201
# ========== READ: 查询用户 ==========
@app.route('/api/users', methods=['GET'])
def list_users():
"""获取用户列表(支持分页、搜索、过滤)"""
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 10)), 100) # 最大100条
search = request.args.get('search', '').strip()
is_active = request.args.get('is_active')
# 构建查询
stmt = select(User)
# 搜索(用户名或邮箱)
if search:
stmt = stmt.where(
or_(
User.username.ilike(f'%{search}%'),
User.email.ilike(f'%{search}%'),
User.full_name.ilike(f'%{search}%'),
)
)
# 过滤激活状态
if is_active is not None:
stmt = stmt.where(User.is_active == (is_active.lower() == 'true'))
# 排序
stmt = stmt.order_by(User.created_at.desc())
# 统计总数
from sqlalchemy import func
count_stmt = select(func.count()).select_from(stmt.subquery())
total = db.session.execute(count_stmt).scalar()
# 分页
offset = (page - 1) * per_page
stmt = stmt.offset(offset).limit(per_page)
users = db.session.execute(stmt).scalars().all()
return jsonify({
'users': [user.to_dict() for user in users],
'pagination': {
'page': page,
'per_page': per_page,
'total': total,
'pages': (total + per_page - 1) // per_page,
}
})
@app.route('/api/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
"""获取单个用户详情"""
user = db.session.get(User, user_id)
if not user:
return jsonify({'error': '用户不存在'}), 404
return jsonify(user.to_dict())
# ========== UPDATE: 更新用户 ==========
@app.route('/api/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
"""更新用户信息"""
user = db.session.get(User, user_id)
if not user:
return jsonify({'error': '用户不存在'}), 404
data = request.get_json()
# 更新字段(只更新提供的字段)
if 'email' in data:
# 检查邮箱是否被其他用户使用
existing = db.session.execute(
select(User).where(
User.email == data['email'],
User.id != user_id
)
).scalar_one_or_none()
if existing:
return jsonify({'error': '邮箱已被使用'}), 409
user.email = data['email']
if 'full_name' in data:
user.full_name = data['full_name']
if 'is_active' in data:
user.is_active = data['is_active']
if 'password' in data:
user.set_password(data['password'])
db.session.commit()
return jsonify(user.to_dict())
# ========== DELETE: 删除用户 ==========
@app.route('/api/users/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
"""删除用户"""
user = db.session.get(User, user_id)
if not user:
return jsonify({'error': '用户不存在'}), 404
db.session.delete(user)
db.session.commit()
return '', 204
# ========== 批量操作 ==========
@app.route('/api/users/batch', methods=['POST'])
def batch_create_users():
"""批量创建用户"""
data = request.get_json()
users_data = data.get('users', [])
if not users_data:
return jsonify({'error': '没有提供用户数据'}), 400
created = []
errors = []
for i, user_data in enumerate(users_data):
try:
user = User(
username=user_data['username'],
email=user_data['email'],
full_name=user_data.get('full_name'),
)
user.set_password(user_data['password'])
db.session.add(user)
db.session.flush() # 获取ID但不提交
created.append(user.to_dict())
except Exception as e:
errors.append({'index': i, 'error': str(e)})
db.session.rollback()
continue
db.session.commit()
return jsonify({
'created': created,
'created_count': len(created),
'errors': errors,
}), 201
# 初始化数据库并运行
with app.app_context():
db.create_all()
if __name__ == '__main__':
app.run(debug=True)
第五章 高级查询
SQLAlchemy的查询能力是其最强大的特性之一。从简单的条件过滤到复杂的窗口函数、CTE、子查询,SQLAlchemy几乎能表达任何SQL查询。本章将全面讲解SQLAlchemy 2.0风格的高级查询技巧。
5.1 SQLAlchemy 2.0查询语法
SQLAlchemy 2.0全面采用select()语句作为查询的统一入口。这是与1.x版本最大的区别。
python
from sqlalchemy import select
# ========== 基本select()语句 ==========
# select()是所有查询的起点
stmt = select(User) # 查询所有列
stmt = select(User.id, User.username) # 查询指定列
stmt = select(User).where(User.is_active) # 带条件
stmt = select(User).order_by(User.id) # 带排序
stmt = select(User).limit(10) # 带限制
# 执行查询
result = db.session.execute(stmt) # 返回Result对象
users = result.scalars().all() # 提取所有User对象
user = result.scalars().first() # 提取第一个
user = result.scalars().one() # 提取唯一一个(不存在或多条会报错)
# ========== select()的链式调用 ==========
# select()返回Select对象,支持链式调用
stmt = (
select(User)
.where(User.is_active == True)
.where(User.email.isnot(None)) # 多个where是AND关系
.order_by(User.created_at.desc())
.limit(10)
)
# 等价SQL:
# SELECT * FROM users
# WHERE is_active = 1 AND email IS NOT NULL
# ORDER BY created_at DESC
# LIMIT 10
# ========== 执行并获取结果 ==========
result = db.session.execute(stmt)
# 方式1: scalars() + all() ------ 获取模型对象列表
users = result.scalars().all()
# 方式2: 遍历结果
for user in db.session.execute(stmt).scalars():
print(user.username)
# 方式3: 获取指定列的值
stmt = select(User.username, User.email)
for row in db.session.execute(stmt):
print(row.username, row.email) # Row对象,通过属性名访问
5.2 旧版Query API
虽然SQLAlchemy 2.0推荐使用select(),但旧版的Query API仍然被支持。Flask-SQLAlchemy 3.x也保留了db.session.query()接口。
python
# ========== 旧版Query API(仍然可用,但不推荐新项目使用) ==========
# 基本查询
users = User.query.all() # 查询所有
user = User.query.first() # 第一条
user = User.query.get(1) # 按主键
user = User.query.filter_by(username='张三').first() # 按条件
# 链式查询
users = (
User.query
.filter(User.is_active == True)
.order_by(User.created_at.desc())
.limit(10)
.all()
)
# 聚合查询
count = User.query.count()
count = User.query.filter(User.is_active == True).count()
# ========== 新旧API对比 ==========
# 旧版: User.query.filter(User.name == '张三').first()
# 新版: db.session.execute(select(User).where(User.name == '张三')).scalars().first()
# 旧版: User.query.filter_by(name='张三').all()
# 新版: db.session.execute(select(User).filter_by(name='张三')).scalars().all()
# 旧版: User.query.get(1)
# 新版: db.session.get(User, 1)
# 旧版: User.query.order_by(User.name.desc()).limit(10).all()
# 新版: db.session.execute(select(User).order_by(User.name.desc()).limit(10)).scalars().all()
迁移建议 : 新项目直接使用
select()风格。旧项目可以逐步迁移,两种风格可以混用。Flask-SQLAlchemy的Model.query属性在3.x中仍然保留。
5.3 条件表达式
SQLAlchemy提供了丰富的条件表达式,映射到SQL的各种操作符。
python
from sqlalchemy import select
# ========== 比较运算符 ==========
stmt = select(User).where(User.age == 25) # 等于
stmt = select(User).where(User.age != 25) # 不等于
stmt = select(User).where(User.age > 18) # 大于
stmt = select(User).where(User.age < 60) # 小于
stmt = select(User).where(User.age >= 18) # 大于等于
stmt = select(User).where(User.age <= 60) # 小于等于
# ========== LIKE和ILIKE ==========
# LIKE: 区分大小写的模糊匹配
stmt = select(User).where(User.username.like('%张%'))
# SQL: WHERE username LIKE '%张%'
# ILIKE: 不区分大小写的模糊匹配(PostgreSQL/SQLite支持)
stmt = select(User).where(User.username.ilike('%john%'))
# SQL: WHERE username ILIKE '%john%' (PostgreSQL)
# MySQL不原生支持ILIKE,可以使用lower()函数实现
stmt = select(User).where(db.func.lower(User.username).like('%john%'))
# ========== IN和NOT IN ==========
# IN: 在指定列表中
stmt = select(User).where(User.id.in_([1, 2, 3, 4, 5]))
# SQL: WHERE id IN (1, 2, 3, 4, 5)
# NOT IN: 不在指定列表中
stmt = select(User).where(User.id.notin_([1, 2, 3]))
# 或者使用 ~ 取反
stmt = select(User).where(~User.id.in_([1, 2, 3]))
# IN 子查询
subq = select(Article.user_id).where(Article.status == 'published')
stmt = select(User).where(User.id.in_(subq))
# SQL: WHERE id IN (SELECT user_id FROM articles WHERE status = 'published')
# ========== IS NULL和IS NOT NULL ==========
stmt = select(User).where(User.email.is_(None)) # IS NULL
stmt = select(User).where(User.email.is_not(None)) # IS NOT NULL
# ========== BETWEEN ==========
stmt = select(User).where(User.age.between(18, 60))
# SQL: WHERE age BETWEEN 18 AND 60
# ========== 字符串方法 ==========
stmt = select(User).where(User.username.startswith('张')) # 前缀匹配
stmt = select(User).where(User.username.endswith('三')) # 后缀匹配
stmt = select(User).where(User.username.contains('张')) # 包含匹配
# 这些方法底层使用LIKE:
# startswith('张') → LIKE '张%'
# endswith('三') → LIKE '%三'
# contains('张') → LIKE '%张%'
5.4 逻辑运算
在实际查询中,经常需要组合多个条件。SQLAlchemy提供了and_、or_、not_三个逻辑运算函数。
python
from sqlalchemy import and_, or_, not_, select
# ========== AND: 与运算 ==========
# 方式1: 多个where()自动是AND关系
stmt = select(User).where(User.is_active == True).where(User.age > 18)
# SQL: WHERE is_active = 1 AND age > 18
# 方式2: 使用and_()显式组合
stmt = select(User).where(
and_(
User.is_active == True,
User.age > 18,
User.age < 60,
)
)
# SQL: WHERE is_active = 1 AND age > 18 AND age < 60
# 方式3: 直接传多个条件(也是AND)
stmt = select(User).where(
User.is_active == True,
User.age > 18,
)
# ========== OR: 或运算 ==========
stmt = select(User).where(
or_(
User.username == '张三',
User.username == '李四',
User.username == '王五',
)
)
# SQL: WHERE username = '张三' OR username = '李四' OR username = '王五'
# 等价的IN写法
stmt = select(User).where(User.username.in_(['张三', '李四', '王五']))
# ========== NOT: 非运算 ==========
stmt = select(User).where(not_(User.is_active == True))
# SQL: WHERE NOT (is_active = 1)
# 等价于: User.is_active != True 或 User.is_active == False
# 也可以使用~操作符
stmt = select(User).where(~(User.age > 60))
# SQL: WHERE NOT (age > 60)
# ========== 组合使用 ==========
# 查找: (活跃用户 且 年龄>18) 或 (管理员)
stmt = select(User).where(
or_(
and_(
User.is_active == True,
User.age > 18,
),
User.is_admin == True,
)
)
# SQL: WHERE (is_active = 1 AND age > 18) OR is_admin = 1
# 复杂组合示例
stmt = select(Article).where(
and_(
Article.status == 'published',
or_(
Article.title.ilike('%Flask%'),
Article.title.ilike('%Python%'),
),
not_(Article.is_deleted == True),
Article.created_at >= '2024-01-01',
)
).order_by(Article.created_at.desc())
5.5 聚合查询
聚合函数用于对一组值进行计算,返回单个汇总值。
python
from sqlalchemy import func, select
# ========== COUNT: 计数 ==========
# 统计总用户数
total = db.session.execute(select(func.count(User.id))).scalar()
# SQL: SELECT COUNT(users.id) FROM users
# 统计满足条件的用户数
active_count = db.session.execute(
select(func.count(User.id)).where(User.is_active == True)
).scalar()
# SQL: SELECT COUNT(users.id) FROM users WHERE is_active = 1
# COUNT(*) ------ 统计行数
total = db.session.execute(select(func.count()).select_from(User)).scalar()
# SQL: SELECT COUNT(*) FROM users
# COUNT(DISTINCT) ------ 统计去重后的数量
unique_emails = db.session.execute(
select(func.count(func.distinct(User.email)))
).scalar()
# SQL: SELECT COUNT(DISTINCT email) FROM users
# ========== SUM: 求和 ==========
total_views = db.session.execute(
select(func.sum(Article.view_count))
).scalar()
# SQL: SELECT SUM(view_count) FROM articles
# ========== AVG: 平均值 ==========
avg_views = db.session.execute(
select(func.avg(Article.view_count))
).scalar()
# SQL: SELECT AVG(view_count) FROM articles
# ========== MIN和MAX: 最小值和最大值 ==========
min_views = db.session.execute(
select(func.min(Article.view_count))
).scalar()
max_views = db.session.execute(
select(func.max(Article.view_count))
).scalar()
# ========== 综合聚合查询 ==========
# 一条查询获取多个聚合值
stmt = select(
func.count(Article.id).label('total_articles'),
func.sum(Article.view_count).label('total_views'),
func.avg(Article.view_count).label('avg_views'),
func.min(Article.view_count).label('min_views'),
func.max(Article.view_count).label('max_views'),
).where(Article.status == 'published')
result = db.session.execute(stmt).one()
print(f"文章总数: {result.total_articles}")
print(f"总浏览量: {result.total_views}")
print(f"平均浏览量: {result.avg_views}")
print(f"最低浏览量: {result.min_views}")
print(f"最高浏览量: {result.max_views}")
5.6 分组与聚合
group_by将数据按指定列分组,having对分组结果进行过滤。
python
from sqlalchemy import func, select
# ========== 基本分组 ==========
# 按文章状态分组,统计每种状态的文章数
stmt = (
select(
Article.status,
func.count(Article.id).label('count'),
)
.group_by(Article.status)
)
result = db.session.execute(stmt).all()
for row in result:
print(f"状态: {row.status}, 文章数: {row.count}")
# ========== 多列分组 ==========
# 按用户和文章状态分组,统计每个用户每种状态的文章数
stmt = (
select(
User.username,
Article.status,
func.count(Article.id).label('count'),
func.sum(Article.view_count).label('total_views'),
)
.join(Article, User.id == Article.author_id)
.group_by(User.id, User.username, Article.status)
.order_by(User.username, Article.status)
)
result = db.session.execute(stmt).all()
# ========== HAVING: 分组后过滤 ==========
# 查找发表文章数超过10篇的用户
stmt = (
select(
User.username,
func.count(Article.id).label('article_count'),
)
.join(Article, User.id == Article.author_id)
.group_by(User.id, User.username)
.having(func.count(Article.id) > 10)
.order_by(func.count(Article.id).desc())
)
result = db.session.execute(stmt).all()
# ========== 按日期分组 ==========
# 按天统计文章发布数量
from sqlalchemy import func
# 使用func.date()提取日期部分
stmt = (
select(
func.date(Article.created_at).label('date'),
func.count(Article.id).label('count'),
)
.where(Article.status == 'published')
.group_by(func.date(Article.created_at))
.order_by(func.date(Article.created_at).desc())
.limit(30)
)
result = db.session.execute(stmt).all()
for row in result:
print(f"日期: {row.date}, 发布数: {row.count}")
# 按月分组(PostgreSQL)
stmt = (
select(
func.to_char(Article.created_at, 'YYYY-MM').label('month'),
func.count(Article.id).label('count'),
)
.group_by('month')
.order_by('month')
)
# 按年-月分组(MySQL)
stmt = (
select(
func.date_format(Article.created_at, '%Y-%m').label('month'),
func.count(Article.id).label('count'),
)
.group_by('month')
.order_by('month')
)
5.7 排序
python
from sqlalchemy import select, desc, asc, nullsfirst, nullslast
# ========== 基本排序 ==========
# 升序(默认)
stmt = select(User).order_by(User.username)
# 降序
stmt = select(User).order_by(User.username.desc())
# 或使用desc()函数
stmt = select(User).order_by(desc(User.username))
# ========== 多列排序 ==========
# 先按is_active降序,再按created_at降序
stmt = select(User).order_by(
User.is_active.desc(),
User.created_at.desc(),
)
# SQL: ORDER BY is_active DESC, created_at DESC
# ========== NULL值排序 ==========
# NULL值排在最前
stmt = select(User).order_by(nullsfirst(User.last_login))
# SQL: ORDER BY last_login NULLS FIRST
# NULL值排在最后
stmt = select(User).order_by(nullslast(User.last_login))
# SQL: ORDER BY last_login NULLS LAST
# ========== 按表达式排序 ==========
# 按字段长度排序
stmt = select(User).order_by(func.length(User.username).desc())
# 按计算字段排序
stmt = (
select(Article)
.order_by((Article.view_count + Article.like_count * 5).desc())
)
# ========== 按关联模型字段排序 ==========
# 按作者名排序文章
stmt = (
select(Article)
.join(User, Article.author_id == User.id)
.order_by(User.username, Article.created_at.desc())
)
# ========== 随机排序 ==========
# MySQL/SQLite
stmt = select(User).order_by(func.random())
# PostgreSQL
stmt = select(User).order_by(func.random())
# MySQL
stmt = select(User).order_by(func.rand())
5.8 限制与偏移
python
from sqlalchemy import select
# ========== LIMIT: 限制返回行数 ==========
# 查询前10条
stmt = select(User).limit(10)
# ========== OFFSET: 跳过指定行数 ==========
# 跳过前20条,取10条
stmt = select(User).offset(20).limit(10)
# ========== 分页查询封装 ==========
def paginate(query, page, per_page):
"""通用分页函数
Args:
query: select语句
page: 页码(从1开始)
per_page: 每页条数
Returns:
dict: 包含数据和分页信息
"""
# 计算偏移量
offset = (page - 1) * per_page
# 统计总数
from sqlalchemy import func
count_stmt = select(func.count()).select_from(query.subquery())
total = db.session.execute(count_stmt).scalar()
# 查询当前页数据
page_stmt = query.offset(offset).limit(per_page)
items = db.session.execute(page_stmt).scalars().all()
return {
'items': items,
'page': page,
'per_page': per_page,
'total': total,
'pages': (total + per_page - 1) // per_page,
'has_prev': page > 1,
'has_next': page < (total + per_page - 1) // per_page,
}
# 使用分页函数
base_query = select(Article).where(Article.status == 'published').order_by(Article.created_at.desc())
result = paginate(base_query, page=2, per_page=10)
print(f"第{result['page']}页,共{result['pages']}页,总数{result['total']}")
for article in result['items']:
print(article.title)
5.9 连接查询
JOIN是关系型数据库最强大的查询能力之一,用于将多个表的数据关联在一起。
python
from sqlalchemy import select, aliased
# ========== INNER JOIN(内连接) ==========
# 查询文章及其作者信息
stmt = (
select(Article, User)
.join(User, Article.author_id == User.id) # 指定连接条件
)
result = db.session.execute(stmt).all()
for article, user in result:
print(f"文章: {article.title}, 作者: {user.username}")
# SQL: SELECT articles.*, users.*
# FROM articles
# INNER JOIN users ON articles.author_id = users.id
# ========== 简化的JOIN(自动推断连接条件) ==========
# 如果有外键关系,可以省略连接条件
stmt = select(Article, User).join(Article.author)
# SQLAlchemy自动使用Article.author_id → User.id的外键关系
# ========== LEFT JOIN(左外连接) ==========
# 查询所有用户,以及他们发表的文章(可能为空)
stmt = (
select(User, Article)
.outerjoin(Article, User.id == Article.author_id)
)
result = db.session.execute(stmt).all()
# SQL: SELECT users.*, articles.*
# FROM users
# LEFT OUTER JOIN articles ON users.id = articles.author_id
# ========== RIGHT JOIN(右外连接) ==========
# SQLAlchemy 2.0使用 .join_from() 实现右连接
stmt = (
select(User, Article)
.join_from(Article, User, Article.author_id == User.id, isouter=True)
)
# 实际上是把Article作为左表,User作为右表的LEFT JOIN
# ========== 多表JOIN ==========
# 查询文章、作者、分类信息
stmt = (
select(Article.title, User.username, Category.name)
.join(User, Article.author_id == User.id)
.join(Category, Article.category_id == Category.id)
.where(Article.status == 'published')
)
result = db.session.execute(stmt).all()
for row in result:
print(f"文章: {row.title}, 作者: {row.username}, 分类: {row.name}")
# ========== 使用aliased(别名) ==========
# 当同一张表需要JOIN多次时,使用aliased创建别名
# 例如: 查询文章及其编辑者(也是User表)
Author = aliased(User, name='author')
Editor = aliased(User, name='editor')
stmt = (
select(Article.title, Author.username.label('author_name'), Editor.username.label('editor_name'))
.join(Author, Article.author_id == Author.id)
.outerjoin(Editor, Article.editor_id == Editor.id)
)
result = db.session.execute(stmt).all()
# ========== 自连接 ==========
# 查询员工及其直属上级
Employee = aliased(db.Model.metadata.tables['employees'], name='employee')
Manager = aliased(db.Model.metadata.tables['employees'], name='manager')
# 使用ORM方式
emp_alias1 = aliased(Employee)
emp_alias2 = aliased(Employee)
stmt = (
select(emp_alias1.name.label('employee_name'), emp_alias2.name.label('manager_name'))
.join(emp_alias2, emp_alias1.manager_id == emp_alias2.id)
)
# ========== JOIN with FILTER ==========
# 查询发表过文章的用户
stmt = (
select(User)
.join(Article, User.id == Article.author_id)
.where(Article.status == 'published')
.distinct() # 去重,一个用户可能有多篇文章
)
# 查询没有发表过文章的用户(LEFT JOIN + IS NULL)
stmt = (
select(User)
.outerjoin(Article, User.id == Article.author_id)
.where(Article.id.is_(None))
)
# SQL: SELECT users.* FROM users
# LEFT OUTER JOIN articles ON users.id = articles.author_id
# WHERE articles.id IS NULL
5.10 子查询
子查询(Subquery)是嵌套在另一个查询中的查询。
python
from sqlalchemy import select, func, subquery
# ========== 标量子查询(返回单个值) ==========
# 查询浏览量高于平均值的文章
avg_subq = (
select(func.avg(Article.view_count))
.where(Article.status == 'published')
.scalar_subquery()
)
stmt = select(Article).where(Article.view_count > avg_subq)
# SQL: SELECT * FROM articles
# WHERE view_count > (SELECT AVG(view_count) FROM articles WHERE status = 'published')
# ========== IN子查询 ==========
# 查询发表过已发布文章的用户
subq = (
select(Article.author_id)
.where(Article.status == 'published')
.distinct()
)
stmt = select(User).where(User.id.in_(subq))
# SQL: SELECT * FROM users
# WHERE id IN (SELECT DISTINCT author_id FROM articles WHERE status = 'published')
# ========== 子查询作为派生表 ==========
# 查询每个用户发表文章数,并按数量排序
subq = (
select(
Article.author_id.label('user_id'),
func.count(Article.id).label('article_count'),
)
.group_by(Article.author_id)
.subquery() # 转换为子查询(派生表)
)
stmt = (
select(User.username, subq.c.article_count)
.join(subq, User.id == subq.c.user_id)
.order_by(subq.c.article_count.desc())
)
result = db.session.execute(stmt).all()
# SQL: SELECT users.username, sub.article_count
# FROM users
# INNER JOIN (
# SELECT author_id AS user_id, COUNT(id) AS article_count
# FROM articles
# GROUP BY author_id
# ) AS sub ON users.id = sub.user_id
# ORDER BY sub.article_count DESC
# ========== EXISTS子查询 ==========
from sqlalchemy import exists
# 查询有文章的用户
subq = select(Article.id).where(Article.author_id == User.id)
stmt = select(User).where(exists(subq))
# SQL: SELECT * FROM users
# WHERE EXISTS (SELECT id FROM articles WHERE author_id = users.id)
# NOT EXISTS: 查询没有文章的用户
stmt = select(User).where(~exists(subq))
# ========== 子查询在SELECT子句中 ==========
# 查询每个用户及其文章数
article_count_subq = (
select(func.count(Article.id))
.where(Article.author_id == User.id)
.correlate(User) # 关联外层查询的User表
.scalar_subquery()
.label('article_count')
)
stmt = select(User.username, article_count_subq)
result = db.session.execute(stmt).all()
5.11 联合查询
集合操作用于合并多个查询的结果。
python
from sqlalchemy import select, union, union_all, except_, intersect
# ========== UNION: 并集(去重) ==========
# 查询所有作者和编辑的名字(去重)
authors = select(User.username).join(Article, User.id == Article.author_id)
editors = select(User.username).join(Article, User.id == Article.editor_id)
stmt = union(authors, editors)
result = db.session.execute(stmt).all()
# SQL: SELECT username FROM users JOIN articles ON ...
# UNION
# SELECT username FROM users JOIN articles ON ...
# ========== UNION ALL: 并集(不去重) ==========
# 性能更好,因为不需要去重
stmt = union_all(authors, editors)
# ========== EXCEPT: 差集 ==========
# 查询是作者但不是编辑的用户
stmt = except_(authors, editors)
# SQL: SELECT ... EXCEPT SELECT ...
# ========== INTERSECT: 交集 ==========
# 查询既是作者又是编辑的用户
stmt = intersect(authors, editors)
# SQL: SELECT ... INTERSECT SELECT ...
# ========== 组合使用 ==========
# 查询最近7天和最近30天的文章数
recent_7 = (
select(func.count(Article.id).label('count'))
.where(Article.created_at >= func.date_sub(func.now(), text('INTERVAL 7 DAY')))
)
recent_30 = (
select(func.count(Article.id).label('count'))
.where(Article.created_at >= func.date_sub(func.now(), text('INTERVAL 30 DAY')))
)
# 使用UNION ALL合并结果
stmt = union_all(
select(text("'7 days'").label('period'), recent_7.scalar_subquery().label('count')),
select(text("'30 days'").label('period'), recent_30.scalar_subquery().label('count')),
)
5.12 窗口函数
窗口函数(Window Function)允许在不分组的情况下对结果集进行聚合计算。
python
from sqlalchemy import func, select, over
# ========== ROW_NUMBER: 行号 ==========
# 为每篇文章按浏览量排名
stmt = (
select(
Article.title,
Article.view_count,
func.row_number().over(
order_by=Article.view_count.desc()
).label('rank'),
)
)
result = db.session.execute(stmt).all()
# SQL: SELECT title, view_count,
# ROW_NUMBER() OVER (ORDER BY view_count DESC) AS rank
# FROM articles
# ========== RANK和DENSE_RANK: 排名 ==========
# 按分类内浏览量排名(分区)
stmt = (
select(
Article.title,
Category.name.label('category'),
Article.view_count,
func.rank().over(
partition_by=Article.category_id,
order_by=Article.view_count.desc(),
).label('category_rank'),
)
.join(Category)
)
# SQL: SELECT title, ...,
# RANK() OVER (PARTITION BY category_id ORDER BY view_count DESC) AS category_rank
# FROM articles JOIN categories ON ...
# RANK vs DENSE_RANK:
# RANK: 并列后跳号(1,1,3,4,...)
# DENSE_RANK: 并列后不跳号(1,1,2,3,...)
# ========== LAG和LEAD: 偏移函数 ==========
# 查询每篇文章与前一篇的浏览量差值
stmt = (
select(
Article.title,
Article.view_count,
func.lag(Article.view_count).over(
order_by=Article.created_at
).label('prev_views'),
(Article.view_count - func.lag(Article.view_count).over(
order_by=Article.created_at
)).label('views_diff'),
)
)
# SQL: SELECT title, view_count,
# LAG(view_count) OVER (ORDER BY created_at) AS prev_views,
# view_count - LAG(view_count) OVER (ORDER BY created_at) AS views_diff
# FROM articles
# ========== 累计求和 ==========
# 按日期累计浏览量
stmt = (
select(
func.date(Article.created_at).label('date'),
func.sum(Article.view_count).over(
order_by=func.date(Article.created_at)
).label('cumulative_views'),
)
.group_by(func.date(Article.created_at))
)
# ========== 使用over()对象 ==========
# 更灵活的窗口函数定义
window = over(
partition_by=Article.category_id,
order_by=Article.view_count.desc(),
)
stmt = select(
Article.title,
func.row_number().over(
partition_by=Article.category_id,
order_by=Article.view_count.desc(),
).label('rank_in_category'),
).where(Article.status == 'published')
5.13 CTE(Common Table Expression)
CTE(公用表表达式)是一种临时的命名结果集,类似于子查询但可读性更好,且支持递归。
python
from sqlalchemy import select, func
# ========== 基本CTE ==========
# 查询每个用户的文章数,然后筛选大于5篇的
article_counts = (
select(
Article.author_id.label('user_id'),
func.count(Article.id).label('count'),
)
.group_by(Article.author_id)
.cte('article_counts') # 命名为CTE
)
stmt = (
select(User.username, article_counts.c.count)
.join(article_counts, User.id == article_counts.c.user_id)
.where(article_counts.c.count > 5)
.order_by(article_counts.c.count.desc())
)
result = db.session.execute(stmt).all()
# SQL: WITH article_counts AS (
# SELECT author_id AS user_id, COUNT(id) AS count
# FROM articles
# GROUP BY author_id
# )
# SELECT users.username, article_counts.count
# FROM users
# JOIN article_counts ON users.id = article_counts.user_id
# WHERE article_counts.count > 5
# ORDER BY article_counts.count DESC
# ========== 递归CTE: 树形结构遍历 ==========
# 查询分类的树形结构(父分类 → 子分类)
# 假设Category有parent_id字段表示父分类
# 基础查询: 选择根分类(parent_id为NULL)
base = (
select(
Category.id,
Category.name,
Category.parent_id,
text('0').label('level'),
Category.name.label('path'),
)
.where(Category.parent_id.is_(None))
.cte('category_tree', recursive=True)
)
# 递归查询: 选择子分类
child = (
select(
Category.id,
Category.name,
Category.parent_id,
(category_tree.c.level + 1).label('level'),
(category_tree.c.path + ' > ' + Category.name).label('path'),
)
.join(category_tree, Category.parent_id == category_tree.c.id)
)
# 合并基础查询和递归查询
recursive_cte = base.union_all(child)
# 查询完整的分类树
stmt = select(recursive_cte).order_by(recursive_cte.c.path)
result = db.session.execute(stmt).all()
for row in result:
print(f"{' ' * row.level}{row.name} (ID: {row.id}, 层级: {row.level})")
# ========== 多个CTE ==========
# 一个查询中使用多个CTE
published_articles = (
select(Article)
.where(Article.status == 'published')
.cte('published_articles')
)
top_authors = (
select(
published_articles.c.author_id,
func.count(published_articles.c.id).label('article_count'),
)
.group_by(published_articles.c.author_id)
.cte('top_authors')
)
stmt = (
select(User.username, top_authors.c.article_count)
.join(top_authors, User.id == top_authors.c.author_id)
.order_by(top_authors.c.article_count.desc())
)
5.14 原生SQL执行
当ORM的表达能力不足以满足需求时,SQLAlchemy允许直接执行原生SQL。
python
from sqlalchemy import text
# ========== 使用text()执行原生SQL ==========
# 简单查询
stmt = text("SELECT id, username, email FROM users WHERE is_active = 1")
result = db.session.execute(stmt)
for row in result:
print(row.id, row.username, row.email)
# 带参数的查询(参数化,防止SQL注入)
stmt = text("SELECT * FROM users WHERE username = :username AND is_active = :active")
result = db.session.execute(stmt, {'username': '张三', 'active': 1})
# IN查询
stmt = text("SELECT * FROM users WHERE id IN :ids")
# 注意: 不同数据库对IN参数的处理不同
# 使用expanding bind参数(SQLAlchemy 2.0推荐)
stmt = text("SELECT * FROM users WHERE id IN :ids").bindparams(
db.bindparam('ids', expanding=True)
)
result = db.session.execute(stmt, {'ids': [1, 2, 3, 4, 5]})
# ========== INSERT/UPDATE/DELETE ==========
# 插入
stmt = text(
"INSERT INTO users (username, email, password_hash) "
"VALUES (:username, :email, :password_hash)"
)
db.session.execute(stmt, {
'username': '原生SQL',
'email': 'raw@example.com',
'password_hash': 'hashed_password',
})
db.session.commit()
# 更新
stmt = text("UPDATE users SET is_active = :active WHERE id = :id")
db.session.execute(stmt, {'active': 0, 'id': 1})
db.session.commit()
# 删除
stmt = text("DELETE FROM users WHERE is_active = :active")
result = db.session.execute(stmt, {'active': 0})
db.session.commit()
print(f"删除了 {result.rowcount} 条记录")
# ========== 将原生SQL结果映射到模型 ==========
# 使用select + from_statement将原生SQL映射到ORM
from sqlalchemy import select
stmt = select(User).from_statement(
text("SELECT * FROM users WHERE is_active = 1 ORDER BY created_at DESC LIMIT 10")
)
users = db.session.execute(stmt).scalars().all()
# 返回的是User模型对象列表
# ========== 复杂原生SQL示例 ==========
# 使用原生SQL进行复杂统计
stmt = text("""
SELECT
u.username,
COUNT(DISTINCT a.id) AS article_count,
COALESCE(SUM(a.view_count), 0) AS total_views,
COALESCE(AVG(a.view_count), 0) AS avg_views
FROM users u
LEFT JOIN articles a ON u.id = a.author_id AND a.status = 'published'
WHERE u.is_active = 1
GROUP BY u.id, u.username
HAVING COUNT(DISTINCT a.id) > 0
ORDER BY total_views DESC
LIMIT 20
""")
result = db.session.execute(stmt)
for row in result:
print(f"用户: {row.username}, 文章数: {row.article_count}, "
f"总浏览: {row.total_views}, 平均浏览: {row.avg_views}")
5.15 高级查询实战案例集
案例1: 多维度文章统计报表
python
def get_article_statistics():
"""获取文章多维统计报表
包括: 按状态统计、按月份统计、Top作者统计
"""
from sqlalchemy import func, select, case
# 1. 按状态统计
status_stats = (
select(
Article.status,
func.count(Article.id).label('count'),
func.sum(Article.view_count).label('total_views'),
func.avg(Article.view_count).label('avg_views'),
)
.group_by(Article.status)
)
# 2. 按月份统计(PostgreSQL语法)
monthly_stats = (
select(
func.to_char(Article.created_at, 'YYYY-MM').label('month'),
func.count(Article.id).label('count'),
func.sum(
case(
(Article.status == 'published', 1),
else_=0,
)
).label('published_count'),
)
.group_by('month')
.order_by('month')
)
# 3. Top 10 作者
top_authors = (
select(
User.username,
func.count(Article.id).label('article_count'),
func.sum(Article.view_count).label('total_views'),
func.avg(Article.view_count).label('avg_views'),
)
.join(Article, User.id == Article.author_id)
.where(Article.status == 'published')
.group_by(User.id, User.username)
.order_by(func.sum(Article.view_count).desc())
.limit(10)
)
# 执行查询
status_result = db.session.execute(status_stats).all()
monthly_result = db.session.execute(monthly_stats).all()
authors_result = db.session.execute(top_authors).all()
return {
'by_status': [
{
'status': r.status,
'count': r.count,
'total_views': r.total_views,
'avg_views': float(r.avg_views) if r.avg_views else 0,
}
for r in status_result
],
'by_month': [
{
'month': r.month,
'total': r.count,
'published': r.published_count,
}
for r in monthly_result
],
'top_authors': [
{
'username': r.username,
'article_count': r.article_count,
'total_views': r.total_views,
'avg_views': float(r.avg_views) if r.avg_views else 0,
}
for r in authors_result
],
}
案例2: 标签云统计
python
def get_tag_cloud(limit=50):
"""获取标签云数据(标签使用频率统计)"""
from sqlalchemy import func, select
# 统计每个标签被多少篇文章使用
stmt = (
select(
Tag.id,
Tag.name,
func.count(article_tag.c.article_id).label('article_count'),
)
.join(article_tag, Tag.id == article_tag.c.tag_id)
.group_by(Tag.id, Tag.name)
.order_by(func.count(article_tag.c.article_id).desc())
.limit(limit)
)
result = db.session.execute(stmt).all()
# 计算标签权重(1-5级)
if not result:
return []
max_count = result[0].article_count
min_count = result[-1].article_count
tag_cloud = []
for row in result:
# 根据使用频率计算权重(1-5)
if max_count == min_count:
weight = 3
else:
weight = int((row.article_count - min_count) / (max_count - min_count) * 4) + 1
tag_cloud.append({
'id': row.id,
'name': row.name,
'count': row.article_count,
'weight': weight, # 用于前端字体大小
})
return tag_cloud
案例3: 全文搜索查询
python
def search_articles(keyword, page=1, per_page=10):
"""全文搜索文章
在标题和内容中搜索关键词
"""
from sqlalchemy import or_, select, func
# 构建搜索条件
search_filter = or_(
Article.title.ilike(f'%{keyword}%'),
Article.content.ilike(f'%{keyword}%'),
Article.summary.ilike(f'%{keyword}%'),
)
# 基础查询
base_query = (
select(Article)
.where(
and_(
Article.status == 'published',
search_filter,
)
)
)
# 统计总数
count_stmt = select(func.count()).select_from(base_query.subquery())
total = db.session.execute(count_stmt).scalar()
# 分页查询
stmt = (
base_query
.order_by(Article.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
)
articles = db.session.execute(stmt).scalars().all()
# 计算相关性分数(简单的基于出现次数的评分)
scored_articles = []
for article in articles:
score = 0
if keyword.lower() in (article.title or '').lower():
score += 10 # 标题匹配权重高
if keyword.lower() in (article.summary or '').lower():
score += 5 # 摘要匹配权重中
if keyword.lower() in (article.content or '').lower():
score += 1 # 内容匹配权重低
scored_articles.append((article, score))
# 按相关性排序
scored_articles.sort(key=lambda x: x[1], reverse=True)
return {
'keyword': keyword,
'results': [
{
'article': article.to_dict(),
'score': score,
}
for article, score in scored_articles
],
'pagination': {
'page': page,
'per_page': per_page,
'total': total,
'pages': (total + per_page - 1) // per_page,
}
}
第六章 关系查询与预加载
关系查询是ORM最强大也最容易出问题的领域。如果不了解关系加载策略,很容易遇到著名的"N+1查询问题"------一次查询获取N条主记录,然后为每条记录单独查询关联数据,产生N+1次数据库查询。本章将深入讲解SQLAlchemy的关系加载机制和预加载策略。
6.1 关系加载策略概述
当通过ORM访问关联数据时,SQLAlchemy需要决定何时 以及如何 从数据库加载关联记录。这个决策由lazy参数控制。
python
class Category(db.Model):
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50))
# lazy参数控制articles关系的加载策略
articles = db.relationship('Article', backref='category', lazy='select')
默认情况下(lazy='select'),关系数据是懒加载 的:只有在真正访问category.articles属性时,才会向数据库发起查询。
python
# 懒加载的工作流程
categories = db.session.execute(select(Category)).scalars().all()
# 此时只查询了categories表,articles尚未加载
for category in categories:
print(category.name)
# 第一次访问 category.articles 时,触发一次SQL查询
for article in category.articles:
print(f" - {article.title}")
# 如果有5个分类,这里会产生5次额外的SQL查询 ------ 这就是N+1问题!
6.2 lazy参数详解
lazy参数支持多种加载策略,每种策略在查询次数和内存使用上有不同的权衡。
| 策略 | 说明 | 查询次数 | 适用场景 |
|---|---|---|---|
| select (默认) | 懒加载,访问时才查询 | N+1 | 关联数据很少访问 |
| joined | JOIN查询,一次获取 | 1 | 一对一、多对一关系 |
| subquery | 子查询,二次查询 | 2 | 一对多,需要预加载 |
| selectin | IN查询,二次查询 | 2 | 一对多,推荐的预加载方式 |
| dynamic | 返回Query对象,不立即加载 | 按需 | 需要进一步过滤关联数据 |
| raise | 访问时抛异常 | 0 | 强制显式预加载 |
| noload | 永不加载,返回空 | 0 | 临时禁用关系加载 |
python
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80))
# 1. select(默认): 懒加载
articles = db.relationship('Article', lazy='select')
# 2. joined: 使用JOIN一次性加载
articles_joined = db.relationship('Article', lazy='joined')
# 3. subquery: 使用子查询加载
articles_subquery = db.relationship('Article', lazy='subquery')
# 4. selectin: 使用IN查询加载(SQLAlchemy 2.0推荐)
articles_selectin = db.relationship('Article', lazy='selectin')
# 5. dynamic: 返回Query对象(不立即加载)
articles_dynamic = db.relationship('Article', lazy='dynamic')
# 6. raise: 访问时抛出异常(强制显式加载)
articles_raise = db.relationship('Article', lazy='raise')
# 7. noload: 永不加载
articles_noload = db.relationship('Article', lazy='noload')
6.2.1 各策略的SQL行为对比
python
# 假设有3个用户,每个用户有2篇文章
# ========== lazy='select'(默认,懒加载) ==========
users = db.session.execute(select(User)).scalars().all()
# SQL: SELECT * FROM users (1次查询)
for user in users:
print(user.articles)
# SQL: SELECT * FROM articles WHERE user_id = ? (3次查询,每用户1次)
# 总计: 1 + 3 = 4次查询 (N+1问题)
# ========== lazy='joined'(JOIN加载) ==========
users = db.session.execute(select(User)).scalars().all()
# SQL: SELECT users.*, articles.* FROM users
# LEFT OUTER JOIN articles ON users.id = articles.user_id (1次查询)
# 总计: 1次查询
# 注意: 如果一个用户有多篇文章,结果会有重复行(SQLAlchemy会自动去重)
# ========== lazy='subquery'(子查询加载) ==========
users = db.session.execute(select(User)).scalars().all()
# SQL 1: SELECT * FROM users (第1次查询)
# SQL 2: SELECT articles.* FROM articles
# WHERE articles.user_id IN (
# SELECT users.id FROM users
# ) (第2次查询,一次获取所有用户的文章)
# 总计: 2次查询
# ========== lazy='selectin'(IN查询加载,推荐) ==========
users = db.session.execute(select(User)).scalars().all()
# SQL 1: SELECT * FROM users (第1次查询)
# SQL 2: SELECT articles.* FROM articles
# WHERE articles.user_id IN (?, ?, ?) (第2次查询,直接用ID列表)
# 总计: 2次查询
# 与subquery类似,但使用IN而非子查询,通常性能更好
# ========== lazy='dynamic'(动态加载) ==========
users = db.session.execute(select(User)).scalars().all()
# SQL: SELECT * FROM users (1次查询)
for user in users:
# user.articles返回的是Query对象,不触发查询
published = user.articles.filter_by(status='published').all()
# SQL: SELECT * FROM articles WHERE user_id = ? AND status = 'published'
# 总计: 1 + N次查询(但可以进一步过滤)
6.3 预加载解决N+1问题
预加载(Eager Loading)是在查询主记录时,同时(或紧接着)加载关联数据,从而避免N+1查询。
6.3.1 joinedload: JOIN预加载
python
from sqlalchemy.orm import joinedload
# 使用joinedload在查询时一次性加载关联数据
stmt = (
select(Category)
.options(joinedload(Category.articles))
)
categories = db.session.execute(stmt).scalars().all()
# SQL: SELECT categories.*, articles.*
# FROM categories
# LEFT OUTER JOIN articles ON categories.id = articles.category_id
# 总计: 1次查询!
# 访问关联数据时不再触发额外查询
for category in categories:
print(f"{category.name}:")
for article in category.articles: # 已经加载,不触发SQL
print(f" - {article.title}")
# ========== joinedload的多层嵌套 ==========
# 加载分类 -> 文章 -> 作者(三层关系)
stmt = (
select(Category)
.options(
joinedload(Category.articles)
.joinedload(Article.author)
)
)
categories = db.session.execute(stmt).scalars().all()
# 一次JOIN查询获取所有数据
# ========== joinedload的innerjoin参数 ==========
# 默认使用LEFT OUTER JOIN,可以改为INNER JOIN
stmt = (
select(Category)
.options(
joinedload(Category.articles, innerjoin=True)
)
)
# 只返回有文章的分类
# ========== 注意: joinedload与一对多 ==========
# joinedload在一对多关系上可能导致大量重复数据(笛卡尔积)
# 因为JOIN会将每个子记录作为单独的行返回
# 对于一对多关系,推荐使用selectinload
6.3.2 selectinload: IN查询预加载(推荐)
python
from sqlalchemy.orm import selectinload
# selectinload使用IN查询预加载关联数据
stmt = (
select(Category)
.options(selectinload(Category.articles))
)
categories = db.session.execute(stmt).scalars().all()
# SQL 1: SELECT * FROM categories
# SQL 2: SELECT * FROM articles WHERE category_id IN (?, ?, ?, ...)
# 总计: 2次查询,无论有多少个分类!
# 访问关联数据不触发额外查询
for category in categories:
print(f"{category.name}: {len(category.articles)} 篇文章")
# ========== selectinload的多层嵌套 ==========
# 加载用户 -> 文章 -> 评论(三层)
stmt = (
select(User)
.options(
selectinload(User.articles)
.selectinload(Article.comments)
)
)
users = db.session.execute(stmt).scalars().all()
# SQL 1: SELECT * FROM users
# SQL 2: SELECT * FROM articles WHERE user_id IN (...)
# SQL 3: SELECT * FROM comments WHERE article_id IN (...)
# 总计: 3次查询
# ========== selectinload vs joinedload ==========
# 一对一/多对一: 推荐joinedload(一条SQL搞定)
# 一对多/多对多: 推荐selectinload(避免JOIN产生的重复数据)
6.3.3 subqueryload: 子查询预加载
python
from sqlalchemy.orm import subqueryload
# subqueryload使用子查询预加载
stmt = (
select(Category)
.options(subqueryload(Category.articles))
)
categories = db.session.execute(stmt).scalars().all()
# SQL 1: SELECT * FROM categories
# SQL 2: SELECT articles.*, anon_1.category_id AS anon_1_category_id
# FROM articles
# RIGHT JOIN (
# SELECT categories.id AS category_id FROM categories
# ) AS anon_1 ON articles.category_id = anon_1.category_id
# 总计: 2次查询
# selectinload vs subqueryload:
# selectinload: 第二次查询用 IN (id1, id2, ...) ------ 更快
# subqueryload: 第二次查询用子查询 ------ 当ID列表太长时可能更优
# 一般推荐selectinload
6.3.4 预加载策略选择指南
python
from sqlalchemy.orm import joinedload, selectinload, subqueryload
# 场景1: 加载用户及其详情(一对一)
stmt = select(User).options(joinedload(User.profile))
# 场景2: 加载分类及其文章(一对多)
stmt = select(Category).options(selectinload(Category.articles))
# 场景3: 加载文章及其作者(多对一)
stmt = select(Article).options(joinedload(Article.author))
# 场景4: 加载文章及其标签(多对多)
stmt = select(Article).options(selectinload(Article.tags))
# 场景5: 多层关系加载
stmt = (
select(User)
.options(
selectinload(User.articles) # 第1层: 一对多,用selectinload
.joinedload(Article.author) # 第2层: 多对一,用joinedload
.selectinload(Article.tags) # 第2层: 多对多,用selectinload
)
)
# 场景6: 混合使用多种策略
stmt = (
select(Article)
.options(
joinedload(Article.category), # 分类: joinedload(多对一)
joinedload(Article.author), # 作者: joinedload(多对一)
selectinload(Article.comments), # 评论: selectinload(一对多)
selectinload(Article.tags), # 标签: selectinload(多对多)
)
)
articles = db.session.execute(stmt).scalars().all()
# 所有关联数据一次性加载,无N+1问题
6.4 contains_eager预加载
contains_eager是一种特殊的预加载策略,它不会自动生成JOIN,而是要求你在查询中手动添加JOIN,然后告诉SQLAlchemy将JOIN的结果填充到关系属性中。
python
from sqlalchemy.orm import contains_eager
# 场景: 查询已发布文章的分类,同时加载每个分类下的已发布文章
# 如果用selectinload,会加载所有文章(包括非已发布的)
# 使用contains_eager可以精确控制加载哪些关联数据
stmt = (
select(Category)
.join(Article, Category.id == Article.category_id) # 手动JOIN
.where(Article.status == 'published') # 过滤条件
.options(contains_eager(Category.articles)) # 告诉SQLAlchemy填充articles
.order_by(Category.name, Article.created_at.desc())
)
categories = db.session.execute(stmt).scalars().all()
for category in categories:
# category.articles只包含已发布的文章
print(f"{category.name}:")
for article in category.articles:
print(f" - {article.title} ({article.status})")
# ========== contains_eager与过滤结合 ==========
# 查询用户及其最近5篇文章
stmt = (
select(User)
.outerjoin(Article, and_(
User.id == Article.author_id,
Article.created_at >= '2024-01-01', # 只加载2024年后的文章
))
.options(contains_eager(User.articles))
.order_by(User.username)
)
users = db.session.execute(stmt).scalars().unique().all()
# .unique()是必须的,因为JOIN可能产生重复的User行
for user in users:
print(f"{user.username}的近期文章:")
for article in user.articles:
print(f" - {article.title}")
6.5 with_entities指定查询列
with_entities用于指定查询返回的列,而不是返回完整的模型对象。
python
from sqlalchemy import select
# ========== 查询指定列 ==========
# 只查询用户名和邮箱(不加载完整User对象)
stmt = select(User.username, User.email)
result = db.session.execute(stmt).all()
for row in result:
print(row.username, row.email)
# ========== 查询计算列 ==========
from sqlalchemy import func
# 查询用户名和文章数
stmt = (
select(
User.username,
func.count(Article.id).label('article_count'),
)
.outerjoin(Article, User.id == Article.author_id)
.group_by(User.id, User.username)
)
result = db.session.execute(stmt).all()
# ========== 使用with_entities(旧版API) ==========
# 查询所有用户的用户名列表
usernames = db.session.query(User).with_entities(User.username).all()
# 查询特定列并转换为列表
usernames_list = [
row[0] for row in
db.session.query(User.username).all()
]
# ========== 使用select只查询需要的列(2.0推荐) ==========
# 只查询ID和标题,减少数据传输
stmt = select(Article.id, Article.title).where(Article.status == 'published')
result = db.session.execute(stmt).all()
articles = [{'id': r.id, 'title': r.title} for r in result]
6.6 关系查询实战: 多层级联数据查询
下面通过一个完整的示例,展示如何高效查询多层级联数据。
python
# 假设有以下模型关系:
# User (1) -> (N) Article (1) -> (N) Comment
# Article (N) <-> (N) Tag
# Article (N) -> (1) Category
def get_user_dashboard(user_id):
"""获取用户仪表盘数据(多层级联查询)
需要的数据:
- 用户信息
- 用户的所有文章(含分类、标签)
- 每篇文章的评论数
- 用户的总浏览量、总点赞数
"""
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload, joinedload
# ========== 方法1: 使用预加载一次性获取(推荐) ==========
stmt = (
select(User)
.options(
# 预加载文章
selectinload(User.articles)
# 预加载文章的分类(多对一,用joinedload)
.joinedload(Article.category)
# 预加载文章的标签(多对多,用selectinload)
.selectinload(Article.tags)
# 预加载文章的评论(一对多,用selectinload)
.selectinload(Article.comments),
)
.where(User.id == user_id)
)
user = db.session.execute(stmt).scalars().one()
# 组装结果(所有数据已在内存中,不触发额外查询)
result = {
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
},
'articles': [],
'total_views': 0,
'total_likes': 0,
}
for article in user.articles:
result['articles'].append({
'id': article.id,
'title': article.title,
'category': article.category.name if article.category else '未分类',
'tags': [tag.name for tag in article.tags],
'comment_count': len(article.comments),
'view_count': article.view_count,
'like_count': article.like_count,
})
result['total_views'] += article.view_count or 0
result['total_likes'] += article.like_count or 0
return result
def get_category_tree_with_articles():
"""获取分类树及其文章(使用递归CTE + 预加载)"""
from sqlalchemy import select
from sqlalchemy.orm import selectinload
# 使用预加载一次获取所有分类和文章
stmt = (
select(Category)
.options(
selectinload(Category.articles)
.joinedload(Article.author)
)
.order_by(Category.name)
)
categories = db.session.execute(stmt).scalars().all()
# 构建分类树
result = []
for category in categories:
result.append({
'id': category.id,
'name': category.name,
'article_count': len(category.articles),
'articles': [
{
'title': article.title,
'author': article.author.username,
'view_count': article.view_count,
}
for article in category.articles
]
})
return result
6.7 N+1问题检测与解决
N+1问题是ORM最常见的性能陷阱。本节介绍如何检测和解决它。
6.7.1 N+1问题示例
python
# ========== 典型的N+1问题 ==========
from sqlalchemy import select
# 查询所有文章
articles = db.session.execute(
select(Article).where(Article.status == 'published')
).scalars().all()
# SQL: SELECT * FROM articles WHERE status = 'published' (1次查询)
# 遍历文章,访问每篇文章的作者
for article in articles:
author_name = article.author.username # 每次访问都触发一次SQL!
print(f"{article.title} - {author_name}")
# 如果有100篇文章,这里会产生100次SQL查询!
# SQL: SELECT * FROM users WHERE id = ? (100次查询)
# 总计: 1 + 100 = 101次查询 (这就是N+1问题)
# ========== 解决方案1: joinedload(多对一关系,推荐) ==========
from sqlalchemy.orm import joinedload
stmt = (
select(Article)
.options(joinedload(Article.author)) # 预加载作者
.where(Article.status == 'published')
)
articles = db.session.execute(stmt).scalars().all()
# SQL: SELECT articles.*, users.*
# FROM articles
# LEFT OUTER JOIN users ON articles.author_id = users.id
# WHERE articles.status = 'published' (1次查询!)
for article in articles:
print(f"{article.title} - {article.author.username}") # 不触发额外查询
# ========== 解决方案2: selectinload(一对多关系,推荐) ==========
from sqlalchemy.orm import selectinload
stmt = (
select(User)
.options(selectinload(User.articles)) # 预加载文章
.where(User.is_active == True)
)
users = db.session.execute(stmt).scalars().all()
# SQL 1: SELECT * FROM users WHERE is_active = 1
# SQL 2: SELECT * FROM articles WHERE user_id IN (?, ?, ...) (2次查询!)
for user in users:
print(f"{user.username}: {len(user.articles)} 篇文章") # 不触发额外查询
6.7.2 检测N+1问题
python
# ========== 方法1: 开启SQL日志 ==========
app.config['SQLALCHEMY_ECHO'] = True # 打印所有SQL
# 然后在日志中观察是否有大量相似的查询
# ========== 方法2: 使用Flask-SQLAlchemy的查询统计 ==========
from flask import g
from time import time
@app.before_request
def before_request():
g.query_count = 0
g.query_start = time()
@app.after_request
def after_request(response):
if hasattr(g, 'query_count'):
duration = time() - g.query_start
if g.query_count > 10: # 查询数超过10次时告警
app.logger.warning(
f"可能的N+1查询: {g.query_count}次查询, 耗时{duration:.2f}s"
)
return response
# ========== 方法3: 使用SQLAlchemy事件监听 ==========
from sqlalchemy import event
query_count = 0
@event.listens_for(db.engine, 'before_cursor_execute')
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
global query_count
query_count += 1
context._query_start_time = time()
@event.listens_for(db.engine, 'after_cursor_execute')
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
duration = time() - context._query_start_time
if duration > 0.1: # 慢查询(超过100ms)
app.logger.warning(
f"慢查询 ({duration:.3f}s): {statement[:200]}"
)
6.8 查询性能优化技巧
6.8.1 只查询需要的列
python
from sqlalchemy import select
# 不好的做法: 查询所有列,包括不需要的大字段
articles = db.session.execute(
select(Article).where(Article.status == 'published')
).scalars().all()
# 如果Article有content(TEXT)字段,会加载所有内容到内存
# 好的做法: 只查询需要的列
stmt = (
select(Article.id, Article.title, Article.created_at)
.where(Article.status == 'published')
)
result = db.session.execute(stmt).all()
# 或者使用defer延迟加载大字段
from sqlalchemy.orm import defer
stmt = (
select(Article)
.options(defer(Article.content)) # 延迟加载content字段
.where(Article.status == 'published')
)
articles = db.session.execute(stmt).scalars().all()
# content字段不会立即加载,只有访问article.content时才查询
# 使用undefer强制加载(覆盖lazy='deferred'的列)
from sqlalchemy.orm import undefer
stmt = select(Article).options(undefer(Article.content))
6.8.2 使用yield_per分批获取
python
from sqlalchemy import select
# 处理大量数据时,使用yield_per分批获取,避免内存溢出
stmt = select(Article).where(Article.status == 'published')
# 每次获取100条,处理完再获取下一批
result = db.session.execute(stmt).scalars()
for article in result.yield_per(100):
process_article(article) # 处理每篇文章
# 不会一次性将所有文章加载到内存
# 或者使用stream_results(更底层的方式)
stmt = select(Article).execution_options(stream_results=True)
6.8.3 使用Redis缓存查询结果
python
import json
from datetime import timedelta
def get_cached_articles(category_id, page=1, per_page=10):
"""带缓存的查询
将常用查询结果缓存到Redis,减少数据库压力
"""
cache_key = f"articles:category:{category_id}:page:{page}"
# 尝试从缓存获取
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# 缓存未命中,查询数据库
stmt = (
select(Article)
.where(
and_(
Article.category_id == category_id,
Article.status == 'published',
)
)
.order_by(Article.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
)
articles = db.session.execute(stmt).scalars().all()
result = [article.to_dict() for article in articles]
# 写入缓存(5分钟过期)
redis_client.setex(
cache_key,
timedelta(minutes=5),
json.dumps(result, ensure_ascii=False)
)
return result
6.8.4 批量查询替代循环查询
python
from sqlalchemy import select
# 不好的做法: 循环中查询(产生N次SQL)
article_ids = [1, 2, 3, 4, 5]
articles = []
for aid in article_ids:
article = db.session.get(Article, aid) # 每次一次SQL
articles.append(article)
# 5次SQL查询
# 好的做法: 一次查询所有
stmt = select(Article).where(Article.id.in_(article_ids))
articles = db.session.execute(stmt).scalars().all()
# 1次SQL查询
# 如果需要按ID顺序返回,可以排序
articles_dict = {a.id: a for a in articles}
articles_ordered = [articles_dict[aid] for aid in article_ids if aid in articles_dict]
第七章 数据库迁移: Flask-Migrate
在项目开发过程中,数据库结构会不断变化:新增表、修改字段、添加索引等。如何安全、可控地管理这些变更?数据库迁移工具应运而生。Flask-Migrate是Flask生态中最常用的数据库迁移工具,本章将全面讲解其使用方法。
7.1 数据库迁移的概念与必要性
数据库迁移(Database Migration)是指对数据库结构进行版本化管理的过程。它类似于代码的版本控制(Git),但针对的是数据库结构(schema)的变更。
7.1.1 为什么需要数据库迁移
在没有迁移工具时,数据库变更面临以下问题:
| 问题 | 无迁移工具 | 有迁移工具 |
|---|---|---|
| 新环境部署 | 手动执行所有DDL | 一条命令自动建表 |
| 结构变更 | 手动编写ALTER语句 | 自动生成迁移脚本 |
| 版本回退 | 几乎不可能 | 一条命令回退 |
| 团队协作 | 各自手动同步 | 提交迁移脚本,自动同步 |
| 生产部署 | 高风险,容易遗漏 | 迁移脚本经过测试 |
text
开发流程对比:
【无迁移工具】
开发者A修改了表结构 → 手动在开发库执行ALTER →
开发者B不知道 → 代码运行报错 → 手动排查 → 手动执行ALTER
【有迁移工具】
开发者A修改了模型 → flask db migrate → 生成迁移脚本 →
提交到Git → 开发者B拉取代码 → flask db upgrade → 自动同步结构
7.1.2 迁移工具的工作原理
┌─────────────┐ 自动检测 ┌──────────────┐ 执行 ┌──────────┐
│ 模型定义 │ ──────────────> │ 迁移脚本 │ ──────────> │ 数据库 │
│ (models.py) │ (对比数据库现状) │ (migrations/) │ (ALTER等) │ (tables) │
└─────────────┘ └──────────────┘ └──────────┘
│ │ │
│ 版本记录 │ │
└─────────────────────────────────┘ <────────────────────────┘
alembic_version表(记录当前版本)
7.2 Alembic简介
Alembic是SQLAlchemy官方的数据库迁移工具,由SQLAlchemy的作者Michael Bayer开发。Flask-Migrate本质上是对Alembic的Flask封装。
7.2.1 Alembic的核心概念
| 概念 | 说明 |
|---|---|
| Migration Script | 迁移脚本,包含upgrade()和downgrade()函数 |
| Revision | 版本号,每个迁移脚本有唯一的revision ID |
| alembic_version | 数据库中的版本记录表,记录当前迁移版本 |
| autogenerate | 自动生成迁移脚本(对比模型与数据库的差异) |
| upgrade | 执行升级(向前迁移) |
| downgrade | 执行降级(回退迁移) |
7.2.2 迁移脚本的结构
python
"""添加用户年龄字段
Revision ID: a1b2c3d4e5f6
Revises: 9z8y7x6w5v4u
Create Date: 2024-01-15 10:00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = 'a1b2c3d4e5f6'
down_revision = '9z8y7x6w5v4u'
branch_labels = None
depends_on = None
def upgrade():
"""升级: 添加age字段"""
op.add_column('users', sa.Column('age', sa.Integer(), nullable=True))
def downgrade():
"""降级: 删除age字段"""
op.drop_column('users', 'age')
7.3 Flask-Migrate安装与初始化
python
# 安装
# pip install Flask-Migrate
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
migrate = Migrate(app, db) # 初始化Flask-Migrate,传入app和db
# 定义模型(必须在使用migrate之前或同时导入)
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
# 在工厂模式中初始化
def create_app():
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
db.init_app(app)
migrate.init_app(app, db) # 必须在db.init_app之后
# 导入模型(确保模型被注册)
from app import models
return app
7.4 创建迁移仓库
bash
# 初始化迁移仓库(只需执行一次)
# 会在项目根目录创建migrations/文件夹
flask db init
# 迁移仓库结构:
# migrations/
# ├── env.py # 迁移环境配置
# ├── README # 说明文件
# ├── script.py.mako # 迁移脚本模板
# └── versions/ # 迁移脚本存放目录(初始为空)
env.py文件配置(通常不需要修改):
python
# migrations/env.py 核心配置
from flask import current_app
def run_migrations_online():
# 获取Flask-SQLAlchemy的引擎配置
configuration = config.get_section(config.config_ini_section)
configuration['sqlalchemy.url'] = current_app.config.get(
'SQLALCHEMY_DATABASE_URI'
)
# ... 使用该配置运行迁移
7.5 生成迁移脚本
bash
# 自动检测模型变更并生成迁移脚本
flask db migrate -m "添加用户年龄字段"
# 输出示例:
# INFO [alembic.runtime.migration] Context impl SQLiteImpl.
# INFO [alembic.runtime.migration] Generating new revision
# INFO [alembic.autogenerate.compare] Detected added column 'age' on 'users' table
# ...
# INFO [alembic.runtime.migration] Revision ID: a1b2c3d4e5f6
# INFO [alembic.runtime.migration] Revises: 9z8y7x6w5v4u
# ...
# 生成文件: migrations/versions/a1b2c3d4e5f6_添加用户年龄字段.py
生成的迁移脚本:
python
"""添加用户年龄字段
Revision ID: a1b2c3d4e5f6
Revises: 9z8y7x6w5v4u
Create Date: 2024-01-15 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'a1b2c3d4e5f6'
down_revision = '9z8y7x6w5v4u'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic ###
op.add_column('users', sa.Column('age', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic ###
op.drop_column('users', 'age')
# ### end Alembic commands ###
7.5.1 检查并修改迁移脚本
自动生成的脚本不一定完美,需要人工检查:
python
def upgrade():
# 添加age字段
op.add_column('users', sa.Column('age', sa.Integer(), nullable=True))
# 人工补充: 为已有数据设置默认年龄
op.execute("UPDATE users SET age = 18 WHERE age IS NULL")
# 人工补充: 添加非空约束
op.alter_column('users', 'age',
existing_type=sa.Integer(),
nullable=False,
server_default='18',
)
# 人工补充: 添加索引
op.create_index('idx_users_age', 'users', ['age'])
def downgrade():
op.drop_index('idx_users_age', table_name='users')
op.drop_column('users', 'age')
7.6 应用迁移
bash
# 应用所有待执行的迁移(升级到最新版本)
flask db upgrade
# 输出示例:
# INFO [alembic.runtime.migration] Running upgrade 9z8y7x6w5v4u -> a1b2c3d4e5f6, 添加用户年龄字段
# 升级到指定版本
flask db upgrade a1b2c3d4e5f6
# 查看当前版本
flask db current
# 输出:
# INFO [alembic.runtime.migration] Current revision for sqlite:///app.db: a1b2c3d4e5f6 (head)
7.7 回滚迁移
bash
# 回退一个版本
flask db downgrade
# 回退到指定版本
flask db downgrade 9z8y7x6w5v4u
# 回退到最初(撤销所有迁移)
flask db downgrade base
7.8 查看迁移历史
bash
# 查看所有迁移历史
flask db history
# 输出示例:
# a1b2c3d4e5f6 (head) -> 添加用户年龄字段
# 9z8y7x6w5v4u -> 创建用户表
# <base> -> 初始状态
# 查看详细历史
flask db history --verbose
# 查看指定范围的迁移
flask db history -r 9z8y7x6w5v4u:a1b2c3d4e5f6
7.9 迁移脚本编写规范
python
"""
良好实践的迁移脚本示例
规范:
1. 消息描述清晰,说明做了什么变更
2. upgrade和downgrade必须对称
3. 数据迁移和结构迁移分开
4. 复杂操作分步骤执行
5. 添加注释说明
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
revision = 'b2c3d4e5f6g7'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
def upgrade():
"""升级: 创建文章表并迁移初始数据"""
# 步骤1: 创建articles表
op.create_table(
'articles',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('title', sa.String(200), nullable=False, comment='文章标题'),
sa.Column('content', sa.Text(), nullable=True, comment='文章内容'),
sa.Column('status', sa.String(20), nullable=False, server_default='draft'),
sa.Column('author_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('category_id', sa.Integer(), sa.ForeignKey('categories.id'), nullable=True),
sa.Column('view_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False),
)
# 步骤2: 创建索引
op.create_index('idx_articles_status', 'articles', ['status'])
op.create_index('idx_articles_author', 'articles', ['author_id'])
op.create_index('idx_articles_created', 'articles', ['created_at'])
op.create_index('idx_articles_status_created', 'articles', ['status', 'created_at'])
# 步骤3: 数据迁移(如果需要)
# op.execute("INSERT INTO articles (title, content, author_id) VALUES (...)")
def downgrade():
"""降级: 删除文章表"""
# 删除索引(顺序与创建相反)
op.drop_index('idx_articles_status_created', table_name='articles')
op.drop_index('idx_articles_created', table_name='articles')
op.drop_index('idx_articles_author', table_name='articles')
op.drop_index('idx_articles_status', table_name='articles')
# 删除表
op.drop_table('articles')
7.10 多环境迁移配置
开发、测试、生产环境通常需要不同的迁移策略:
python
# migrations/env.py 中的多环境配置
def run_migrations_online():
# 根据环境变量选择配置
import os
environment = os.environ.get('FLASK_ENV', 'development')
if environment == 'production':
# 生产环境: 不允许自动生成,只能手动编写
config.set_main_option('sqlalchemy.url', os.environ['DATABASE_URL'])
elif environment == 'testing':
# 测试环境: 使用内存数据库
config.set_main_option('sqlalchemy.url', 'sqlite://')
else:
# 开发环境
config.set_main_option(
'sqlalchemy.url',
current_app.config.get('SQLALCHEMY_DATABASE_URI')
)
# ... 运行迁移
bash
# 不同环境执行迁移
# 开发环境
export FLASK_ENV=development
flask db upgrade
# 生产环境
export FLASK_ENV=production
export DATABASE_URL=postgresql://user:pass@prod-host/mydb
flask db upgrade
7.11 迁移常见问题与解决方案
问题1: autogenerate检测不到变更
bash
# 原因: 模型没有正确导入到迁移环境
# 解决: 确保在migrate.init_app之后导入了所有模型
# migrations/env.py 中确保导入了模型
# 在run_migrations_online()中添加:
from app import models # 导入所有模型
target_metadata = db.metadata
问题2: 迁移冲突(多人同时修改)
bash
# 场景: 开发者A和B同时基于同一版本创建了迁移
# 解决: 使用merge合并分支
flask db merge -m "合并A和B的迁移" a1b2c3d4e5f6 b2c3d4e5f6g7
问题3: SQLite不支持某些ALTER操作
python
# SQLite不支持ALTER COLUMN的某些操作(如修改列类型)
# 使用batch mode解决:
def upgrade():
# 使用batch_alter_table在SQLite上执行复杂的ALTER操作
with op.batch_alter_table('users') as batch_op:
batch_op.alter_column('username',
existing_type=sa.String(80),
type_=sa.String(200),
nullable=True,
)
batch_op.drop_column('old_column')
问题4: 数据丢失风险
python
# 安全的列类型变更(先添加新列,迁移数据,再删除旧列)
def upgrade():
# 1. 添加新列
op.add_column('users', sa.Column('age_new', sa.Integer(), nullable=True))
# 2. 迁移数据
op.execute("UPDATE users SET age_new = CAST(age_string AS INTEGER)")
# 3. 删除旧列
op.drop_column('users', 'age_string')
# 4. 重命名新列
op.alter_column('users', 'age_new', new_column_name='age')
7.12 迁移最佳实践
- 每次模型变更后立即生成迁移脚本: 不要积累大量变更再生成
- 检查自动生成的脚本: autogenerate不是万能的,需要人工审查
- 先在开发环境测试: 不要直接在生产环境执行未测试的迁移
- 备份生产数据: 执行迁移前先备份数据库
- 保持迁移线性: 避免分叉,及时合并
- 不要修改已执行的迁移: 如果需要修改,创建新的迁移
- 将迁移脚本纳入版本控制: 迁移脚本应该提交到Git
- downgrade必须可用: 确保每个迁移都能正确回退
bash
# 完整的迁移工作流
# 1. 修改模型
# vim models.py (添加新字段)
# 2. 生成迁移脚本
flask db migrate -m "添加xxx字段"
# 3. 检查脚本
# vim migrations/versions/xxx_添加xxx字段.py
# 4. 在开发环境测试
flask db upgrade
# 测试应用功能
# 5. 测试回退
flask db downgrade
# 确认回退正常
# 6. 重新升级
flask db upgrade
# 7. 提交到版本控制
git add migrations/ models.py
git commit -m "添加xxx字段"
# 8. 部署到生产
# 先备份
# pg_dump mydb > backup.sql
# 执行迁移
flask db upgrade
第八章 高级主题
本章探讨SQLAlchemy的高级功能,包括事件监听、混合属性、自定义类型、软删除、多租户架构、读写分离、分库分表和全文搜索等。这些功能在实际项目开发中非常实用,能解决许多复杂场景的需求。
8.1 事件监听
SQLAlchemy提供了强大的事件系统,允许开发者在ORM操作的不同阶段插入自定义逻辑。
8.1.1 常用事件类型
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
| before_insert | INSERT执行前 | 数据验证、自动填充字段 |
| after_insert | INSERT执行后 | 记录日志、触发通知 |
| before_update | UPDATE执行前 | 数据验证、更新时间戳 |
| after_update | UPDATE执行后 | 记录变更日志 |
| before_delete | DELETE执行前 | 级联清理、权限检查 |
| after_delete | DELETE执行后 | 清理关联文件 |
| before_commit | COMMIT前 | 全局数据检查 |
| after_commit | COMMIT后 | 发送通知、推送消息 |
8.1.2 使用事件监听
python
from sqlalchemy import event
from datetime import datetime
# ========== 方式1: 使用@event.listens_for装饰器 ==========
# 在User插入前自动填充created_at
@event.listens_for(User, 'before_insert')
def set_created_at(mapper, connection, target):
"""target就是即将插入的User实例"""
if not target.created_at:
target.created_at = datetime.utcnow()
# 在User更新前自动更新updated_at
@event.listens_for(User, 'before_update')
def set_updated_at(mapper, connection, target):
"""target就是即将更新的User实例"""
target.updated_at = datetime.utcnow()
# 在文章删除后清理关联的评论和标签
@event.listens_for(Article, 'after_delete')
def cleanup_after_article_delete(mapper, connection, target):
"""文章删除后,清理关联数据"""
# 删除关联评论
connection.execute(
text("DELETE FROM comments WHERE article_id = :aid"),
{'aid': target.id}
)
# 删除标签关联
connection.execute(
text("DELETE FROM article_tags WHERE article_id = :aid"),
{'aid': target.id}
)
# ========== 方式2: 使用事件函数 ==========
def log_insert(mapper, connection, target):
"""记录插入日志"""
print(f"[LOG] 插入{target.__class__.__name__}: {target}")
event.listen(User, 'after_insert', log_insert)
event.listen(Article, 'after_insert', log_insert)
# ========== 方式3: Session级别事件 ==========
@event.listens_for(db.session, 'before_commit')
def before_commit(session):
"""提交前检查"""
for obj in session.new: # 新增的对象
if hasattr(obj, 'created_at') and not obj.created_at:
obj.created_at = datetime.utcnow()
@event.listens_for(db.session, 'after_commit')
def after_commit(session):
"""提交后发送通知(此时数据已持久化)"""
for obj in session.new:
if isinstance(obj, Article) and obj.status == 'published':
# 发送文章发布通知
send_notification(f"新文章发布: {obj.title}")
8.1.3 实战: 自动密码哈希
python
from werkzeug.security import generate_password_hash
@event.listens_for(User, 'before_insert')
@event.listens_for(User, 'before_update')
def hash_password_if_changed(mapper, connection, target):
"""如果password字段被修改,自动哈希"""
# 检查password是否在变更历史中
history = db.inspect(target).attrs.password.history
if history.has_changes():
# 获取新密码(未哈希的)
new_password = target.password
if new_password and not new_password.startswith('pbkdf2:'):
target.password_hash = generate_password_hash(new_password)
target.password = None # 清除明文密码
8.2 混合属性
混合属性(Hybrid Property)允许同一个属性在Python层面和SQL层面有不同的实现,使得属性既能用于实例操作,也能用于查询过滤。
python
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy import case, func
class Article(db.Model):
__tablename__ = 'articles'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text)
view_count = db.Column(db.Integer, default=0)
like_count = db.Column(db.Integer, default=0)
comment_count = db.Column(db.Integer, default=0)
status = db.Column(db.String(20), default='draft')
is_deleted = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@hybrid_property
def is_published(self):
"""Python层面: 判断是否已发布"""
return self.status == 'published' and not self.is_deleted
@is_published.expression
def is_published(cls):
"""SQL层面: 生成SQL表达式
可以在查询中使用:
select(Article).where(Article.is_published == True)
"""
return (cls.status == 'published') & (~cls.is_deleted)
@hybrid_property
def popularity_score(self):
"""Python层面: 计算热度分数"""
return self.view_count + self.like_count * 5 + self.comment_count * 3
@popularity_score.expression
def popularity_score(cls):
"""SQL层面: 生成热度SQL表达式"""
return cls.view_count + cls.like_count * 5 + cls.comment_count * 3
@popularity_score.comparator
def popularity_score(cls):
"""自定义比较器: 支持 > < 等运算符"""
# 使用现成的表达式
expr = cls.view_count + cls.like_count * 5 + cls.comment_count * 3
return expr
@hybrid_property
def word_count(self):
"""Python层面: 计算字数"""
return len(self.content) if self.content else 0
@word_count.expression
def word_count(cls):
"""SQL层面: 使用数据库函数计算字数"""
return func.length(cls.content)
# 使用示例
with app.app_context():
# Python层面使用
article = db.session.get(Article, 1)
print(article.is_published) # True/False
print(article.popularity_score) # 1230
# SQL层面使用(在查询中使用)
from sqlalchemy import select
# 过滤已发布文章
stmt = select(Article).where(Article.is_published == True)
# 按热度排序
stmt = select(Article).order_by(Article.popularity_score.desc())
# 过滤高热度文章
stmt = select(Article).where(Article.popularity_score > 1000)
8.3 自定义类型
当SQLAlchemy内置的列类型无法满足需求时,可以通过TypeDecorator创建自定义类型。
python
from sqlalchemy.types import TypeDecorator, String, Text
import json
class JSONEncodedDict(TypeDecorator):
"""自定义JSON类型
在数据库中存储为TEXT,在Python中自动转换为dict/list
适用于不支持原生JSON的数据库(如旧版SQLite)
"""
impl = Text # 底层使用Text类型存储
cache_ok = True # 启用缓存(安全类型)
def process_bind_param(self, value, dialect):
"""Python -> 数据库: 将Python对象序列化为JSON字符串"""
if value is not None:
return json.dumps(value, ensure_ascii=False)
return None
def process_result_value(self, value, dialect):
"""数据库 -> Python: 将JSON字符串反序列化为Python对象"""
if value is not None:
return json.loads(value)
return None
class EncryptedString(TypeDecorator):
"""加密字符串类型
存储时自动加密,读取时自动解密
"""
impl = String
cache_ok = True
def __init__(self, key=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.key = key or 'default-secret-key'
def process_bind_param(self, value, dialect):
"""加密"""
if value is not None:
# 简单的加密示例(实际应使用AES等)
from cryptography.fernet import Fernet
import base64
key = base64.urlsafe_b64encode(self.key.encode().ljust(32)[:32])
f = Fernet(key)
return f.encrypt(value.encode()).decode()
return None
def process_result_value(self, value, dialect):
"""解密"""
if value is not None:
from cryptography.fernet import Fernet
import base64
key = base64.urlsafe_b64encode(self.key.encode().ljust(32)[:32])
f = Fernet(key)
return f.decrypt(value.encode()).decode()
return None
# 使用自定义类型
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80))
# 使用自定义JSON类型
preferences = db.Column(JSONEncodedDict, default=dict)
settings = db.Column(JSONEncodedDict, default=dict)
# 使用加密类型
phone = db.Column(EncryptedString(100))
id_card = db.Column(EncryptedString(200))
# 使用
user = User(
username='张三',
preferences={'theme': 'dark', 'language': 'zh-CN'},
phone='13800138000', # 自动加密存储
)
db.session.add(user)
db.session.commit()
# 读取时自动解密
user = db.session.get(User, 1)
print(user.preferences) # {'theme': 'dark', 'language': 'zh-CN'}
print(user.phone) # 13800138000 (自动解密)
8.4 计算列与虚拟列
某些数据库支持计算列(Generated Column),即列的值由其他列计算得出。
python
# PostgreSQL: 生成列
class Product(db.Model):
__tablename__ = 'products'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100))
price = db.Column(db.Numeric(10, 2))
discount = db.Column(db.Numeric(5, 2), default=0)
# 使用混合属性模拟计算列(Python层面)
@hybrid_property
def final_price(self):
"""最终价格 = 原价 * (1 - 折扣)"""
return self.price * (1 - self.discount / 100)
@final_price.expression
def final_price(cls):
return cls.price * (1 - cls.discount / 100.0)
# 如果使用PostgreSQL,可以使用原生生成列
# CREATE TABLE products (
# price NUMERIC(10,2),
# discount NUMERIC(5,2) DEFAULT 0,
# final_price NUMERIC(10,2) GENERATED ALWAYS AS (price * (1 - discount/100)) STORED
# );
8.5 软删除实现
软删除(Soft Delete)是将删除操作标记为"已删除"而非真正从数据库删除,数据可以恢复。
python
from sqlalchemy import event
class SoftDeleteMixin:
"""软删除Mixin
使用方式: class User(SoftDeleteMixin, db.Model): ...
"""
is_deleted = db.Column(db.Boolean, default=False, nullable=False, index=True)
deleted_at = db.Column(db.DateTime, nullable=True)
def soft_delete(self, commit=True):
"""执行软删除"""
self.is_deleted = True
self.deleted_at = datetime.utcnow()
db.session.add(self)
if commit:
db.session.commit()
def restore(self, commit=True):
"""恢复软删除的记录"""
self.is_deleted = False
self.deleted_at = None
db.session.add(self)
if commit:
db.session.commit()
@classmethod
def get_active(cls):
"""获取未删除的记录的查询"""
from sqlalchemy import select
return select(cls).where(cls.is_deleted == False)
@classmethod
def get_deleted(cls):
"""获取已删除的记录"""
from sqlalchemy import select
return select(cls).where(cls.is_deleted == True)
# ========== 使用事件自动过滤软删除记录 ==========
@event.listens_for(SoftDeleteMixin, 'before_delete', propagate=True)
def prevent_hard_delete(mapper, connection, target):
"""拦截物理删除,转为软删除"""
raise ValueError(
f"不允许物理删除{target.__class__.__name__},请使用soft_delete()方法"
)
# ========== 查询过滤器Mixin ==========
class QueryWithSoftDelete:
"""自动过滤软删除记录的查询基类"""
@classmethod
def query_active(cls):
"""查询未删除的记录"""
from sqlalchemy import select
return select(cls).where(cls.is_deleted == False)
# 使用
class Article(SoftDeleteMixin, db.Model):
__tablename__ = 'articles'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text)
status = db.Column(db.String(20), default='draft')
# 软删除操作
article = db.session.get(Article, 1)
article.soft_delete() # 标记为已删除
# 查询未删除的文章
from sqlalchemy import select
stmt = Article.get_active().where(Article.status == 'published')
articles = db.session.execute(stmt).scalars().all()
# 恢复
article.restore() # 恢复记录
8.6 多租户架构
多租户(Multi-tenancy)是指一个应用实例为多个客户(租户)提供服务,数据需要隔离。
8.6.1 行隔离方案(共享表,添加tenant_id)
python
class TenantMixin:
"""多租户Mixin"""
tenant_id = db.Column(db.Integer, db.ForeignKey('tenants.id'), nullable=False, index=True)
class Tenant(db.Model):
__tablename__ = 'tenants'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
domain = db.Column(db.String(200), unique=True)
class Article(TenantMixin, db.Model):
__tablename__ = 'articles'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200))
content = db.Column(db.Text)
# 查询时自动过滤当前租户
def get_current_tenant_id():
"""从请求上下文获取当前租户ID"""
from flask import g
return getattr(g, 'tenant_id', None)
def get_tenant_articles():
"""获取当前租户的文章"""
tenant_id = get_current_tenant_id()
stmt = select(Article).where(Article.tenant_id == tenant_id)
return db.session.execute(stmt).scalars().all()
8.6.2 Schema隔离方案(PostgreSQL)
python
# PostgreSQL支持多Schema,每个租户一个Schema
from sqlalchemy import text
def create_tenant_schema(tenant_name):
"""为新租户创建独立的Schema"""
schema_name = f"tenant_{tenant_name}"
db.session.execute(text(f'CREATE SCHEMA IF NOT EXISTS {schema_name}'))
db.session.commit()
def set_search_path(tenant_name):
"""设置当前会话的搜索路径"""
schema_name = f"tenant_{tenant_name}"
db.session.execute(text(f'SET search_path TO {schema_name}, public'))
8.7 读写分离实现
读写分离是将读操作路由到从库,写操作路由到主库,提升数据库并发处理能力。
python
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
class RoutingSession(Session):
"""自定义Session,实现读写分离"""
def get_bind(self, mapper=None, clause=None, **kw):
"""根据操作类型选择引擎"""
# 写操作(INSERT, UPDATE, DELETE)使用主库
if self._flushing or isinstance(clause, (Update, Delete, Insert)):
return self.binds.get('master') or self.bind
# 读操作(SELECT)使用从库
return self.binds.get('slave') or self.bind
# 创建主从引擎
master_engine = create_engine('mysql+pymysql://root:pass@master-host/mydb')
slave_engine = create_engine('mysql+pymysql://root:pass@slave-host/mydb')
# 创建Session工厂
session_factory = sessionmaker(
class_=RoutingSession,
binds={
'master': master_engine,
'slave': slave_engine,
}
)
# 在Flask-SQLAlchemy中使用自定义Session
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_size': 10,
}
# 更简单的方式: 在查询时手动选择
@app.route('/api/articles')
def get_articles():
# 读操作: 可以使用从库
# 实际中通常通过中间件或代理自动路由
stmt = select(Article).where(Article.status == 'published')
articles = db.session.execute(stmt).scalars().all()
return jsonify([a.to_dict() for a in articles])
@app.route('/api/articles', methods=['POST'])
def create_article():
# 写操作: 使用主库
article = Article(title=request.json['title'])
db.session.add(article)
db.session.commit()
return jsonify(article.to_dict()), 201
8.8 分库分表策略
当单表数据量过大(如超过1000万行)时,需要考虑分库分表。
python
# ========== 水平分表: 按日期分表 ==========
def get_article_table_name(date):
"""根据日期生成分表名"""
return f"articles_{date.strftime('%Y_%m')}"
def get_article_model(date):
"""动态创建分表模型"""
table_name = get_article_table_name(date)
class DynamicArticle(db.Model):
__tablename__ = table_name
__table_args__ = {'extend_existing': True}
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200))
content = db.Column(db.Text)
created_at = db.Column(db.DateTime)
return DynamicArticle
# 使用
model = get_article_model(datetime(2024, 1, 15))
article = model(title='测试', content='...')
db.session.add(article)
db.session.commit()
# ========== 使用SQLAlchemy的分片(Sharding) ==========
from sqlalchemy.orm import ShardedSession
# 配置分片规则
shard_1_engine = create_engine('mysql+pymysql://shard1/mydb')
shard_2_engine = create_engine('mysql+pymysql://shard2/mydb')
def shard_chooser(mapper, instance, clause=None):
"""选择分片: 根据用户ID决定存储到哪个分片"""
if isinstance(instance, User):
return 'shard1' if instance.id % 2 == 0 else 'shard2'
return 'shard1'
def id_chooser(query, ident):
"""根据ID选择分片"""
return ['shard1', 'shard2'] # 查询所有分片
def query_chooser(query):
"""根据查询条件选择分片"""
return ['shard1', 'shard2']
8.9 全文搜索集成
python
# ========== 方案1: 使用数据库内置全文搜索(PostgreSQL) ==========
class Article(db.Model):
__tablename__ = 'articles'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200))
content = db.Column(db.Text)
# PostgreSQL全文搜索向量列
# from sqlalchemy.dialects.postgresql import TSVECTOR
# search_vector = db.Column(TSVECTOR)
# 创建全文搜索索引
# CREATE INDEX idx_articles_search ON articles USING gin(to_tsvector('chinese', title || ' ' || content));
# 使用全文搜索查询
from sqlalchemy import text
def fulltext_search(keyword):
"""PostgreSQL全文搜索"""
stmt = text("""
SELECT id, title, ts_rank_cd(search_vector, query) AS rank
FROM articles, to_tsquery(:keyword) query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 20
""")
result = db.session.execute(stmt, {'keyword': keyword})
return result.all()
# ========== 方案2: 使用SQLAlchemy-Searchable(PostgreSQL) ==========
# pip install SQLAlchemy-Searchable
# from sqlalchemy_searchable import make_searchable
# from sqlalchemy_utils import TSVectorType
#
# make_searchable(db.metadata)
#
# class Article(db.Model):
# __tablename__ = 'articles'
# id = db.Column(db.Integer, primary_key=True)
# title = db.Column(db.String(200))
# content = db.Column(db.Text)
# search_vector = db.Column(TSVectorType('title', 'content'))
#
# # 使用
# Article.search('Flask', sort=True) # 搜索并按相关性排序
# ========== 方案3: 使用Whoosh(纯Python,适合中小项目) ==========
# pip install whoosh
from whoosh.index import create_in, open_dir
from whoosh.fields import Schema, TEXT, ID
from whoosh.qparser import QueryParser
import os
# 创建索引
def create_search_index():
"""创建Whoosh索引"""
schema = Schema(
id=ID(stored=True, unique=True),
title=TEXT(stored=True),
content=TEXT(stored=True),
)
if not os.path.exists('search_index'):
os.mkdir('search_index')
ix = create_in('search_index', schema)
# 将文章数据导入索引
writer = ix.writer()
articles = db.session.execute(select(Article)).scalars().all()
for article in articles:
writer.add_document(
id=str(article.id),
title=article.title,
content=article.content or '',
)
writer.commit()
# 搜索
def search_with_whoosh(keyword, limit=20):
"""使用Whoosh搜索"""
ix = open_dir('search_index')
with ix.searcher() as searcher:
query = QueryParser('content', ix.schema).parse(keyword)
results = searcher.search(query, limit=limit)
return [int(r['id']) for r in results]
8.10 JSON字段操作
PostgreSQL的JSONB类型提供了强大的JSON操作能力。
python
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import select, cast, String
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80))
# 使用JSONB存储结构化数据
preferences = db.Column(JSONB, default=dict)
metadata_json = db.Column(JSONB, default=dict)
# ========== JSON字段操作 ==========
# 1. 设置JSON值
user = User(
username='张三',
preferences={
'theme': 'dark',
'language': 'zh-CN',
'notifications': {
'email': True,
'push': False,
}
}
)
# 2. 查询JSON字段中的值(PostgreSQL)
# 查询主题为dark的用户
stmt = select(User).where(
User.preferences['theme'].astext == 'dark'
)
# SQL: WHERE preferences->>'theme' = 'dark'
# 查询开启了邮件通知的用户
stmt = select(User).where(
User.preferences['notifications']['email'].astext == 'true'
)
# SQL: WHERE preferences->'notifications'->>'email' = 'true'
# 3. JSON包含查询
# 查询preferences中包含theme键的用户
stmt = select(User).where(
User.preferences.has_key('theme')
)
# SQL: WHERE preferences ? 'theme'
# 查询preferences中包含特定JSON的用户
stmt = select(User).where(
User.preferences.contains({'language': 'zh-CN'})
)
# SQL: WHERE preferences @> '{"language": "zh-CN"}'
# 4. 更新JSON字段的特定键(PostgreSQL 9.5+)
from sqlalchemy import update
stmt = (
update(User)
.where(User.id == 1)
.values({
'preferences': User.preferences.op('||')(
{'theme': 'light', 'font_size': 14}
)
})
)
# SQL: UPDATE users SET preferences = preferences || '{"theme": "light", "font_size": 14}'::jsonb
# 5. 提取JSON数组中的元素
# 假设preferences中有一个tags数组: {"tags": ["python", "flask"]}
stmt = select(User).where(
User.preferences['tags'].astext.contains('flask')
)
# 6. JSON路径查询
# 使用jsonb_path_query(PostgreSQL 12+)
stmt = text("""
SELECT id, username, jsonb_path_query(preferences, '$.notifications.*') as notif
FROM users
WHERE jsonb_path_exists(preferences, '$.notifications.email ? (@ == true)')
""")
第九章 实战: 博客系统数据库设计
本章将综合运用前八章的知识,完整设计一个博客系统的数据库。从需求分析到ER图设计,从模型实现到常用查询,展示Flask-SQLAlchemy在实际项目中的完整应用。
9.1 需求分析与ER图设计
9.1.1 功能需求
一个完整的博客系统通常需要以下功能模块:
| 模块 | 功能 |
|---|---|
| 用户管理 | 注册、登录、个人资料、密码管理 |
| 文章管理 | 发布、编辑、删除、草稿、发布状态管理 |
| 评论系统 | 评论、回复、嵌套评论、审核 |
| 标签系统 | 文章标签、标签云、按标签筛选 |
| 分类系统 | 树形分类、按分类浏览 |
| 关注系统 | 用户关注/被关注、动态 Feed |
| 搜索 | 全文搜索文章 |
| 统计 | 浏览量、点赞、文章数统计 |
9.1.2 ER图设计
┌──────────┐ 1:N ┌──────────┐ N:1 ┌──────────┐
│ User │─────────────>│ Article │<─────────────│ Category │
│ │ │ │ │ (自引用) │
└──────────┘ └──────────┘ └──────────┘
│ │ │ │
│ 1:N │ │ N:N │ N:1
│ │ │ │
v v v v
┌──────────┐ ┌──────────┐ ┌──────┐ ┌──────────┐
│ Comment │ │ Comment │ │ Tag │ │Category │
│ (嵌套) │ │ │ │ │ │(parent) │
└──────────┘ └──────────┘ └──────┘ └──────────┘
^ ^
│ N:N │
└─────────── Follow ──────────────────┘
(用户关注用户)
9.2 用户表设计
python
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class User(db.Model):
"""用户模型
存储用户基本信息和认证数据
"""
__tablename__ = 'users'
# 主键
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
# 基本信息
username = db.Column(db.String(80), unique=True, nullable=False, index=True, comment='用户名')
email = db.Column(db.String(120), unique=True, nullable=False, index=True, comment='邮箱')
password_hash = db.Column(db.String(256), nullable=False, comment='密码哈希')
# 个人资料
nickname = db.Column(db.String(80), comment='昵称')
avatar_url = db.Column(db.String(500), comment='头像URL')
bio = db.Column(db.Text, comment='个人简介')
website = db.Column(db.String(200), comment='个人网站')
# 状态
is_active = db.Column(db.Boolean, default=True, server_default='true', nullable=False)
is_admin = db.Column(db.Boolean, default=False, server_default='false', nullable=False)
is_deleted = db.Column(db.Boolean, default=False, server_default='false', nullable=False)
# 时间戳
created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=db.func.now(), nullable=False)
last_login = db.Column(db.DateTime, comment='最后登录时间')
# 关系
articles = db.relationship('Article', backref='author', lazy='dynamic',
foreign_keys='Article.author_id')
comments = db.relationship('Comment', backref='author', lazy='dynamic')
profile = db.relationship('UserProfile', backref='user', uselist=False, cascade='all, delete-orphan')
# ========== 密码管理 ==========
def set_password(self, password: str):
"""设置密码(自动哈希)"""
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
"""验证密码"""
return check_password_hash(self.password_hash, password)
# ========== 属性 ==========
@property
def display_name(self):
"""显示名称(优先使用昵称)"""
return self.nickname or self.username
@property
def article_count(self):
"""已发布文章数"""
return self.articles.filter_by(status='published').count()
@property
def follower_count(self):
"""粉丝数"""
return self.followers.count()
@property
def following_count(self):
"""关注数"""
return self.following.count()
# ========== 序列化 ==========
def to_dict(self, include_profile=False):
data = {
'id': self.id,
'username': self.username,
'nickname': self.nickname,
'avatar_url': self.avatar_url,
'bio': self.bio,
'is_admin': self.is_admin,
'created_at': self.created_at.isoformat() if self.created_at else None,
'article_count': self.article_count,
'follower_count': self.follower_count,
}
if include_profile and self.profile:
data['profile'] = self.profile.to_dict()
return data
def __repr__(self):
return f'<User {self.username}>'
class UserProfile(db.Model):
"""用户详情模型(一对一)"""
__tablename__ = 'user_profiles'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), unique=True, nullable=False)
# 扩展信息
phone = db.Column(db.String(20), comment='手机号')
location = db.Column(db.String(100), comment='所在地')
birthday = db.Column(db.Date, comment='生日')
gender = db.Column(db.String(10), comment='性别')
# 偏好设置(JSON)
preferences = db.Column(db.JSON, default=dict, comment='用户偏好设置')
def to_dict(self):
return {
'phone': self.phone,
'location': self.location,
'birthday': self.birthday.isoformat() if self.birthday else None,
'gender': self.gender,
'preferences': self.preferences or {},
}
9.3 文章表设计
python
import enum
class ArticleStatus(enum.Enum):
"""文章状态枚举"""
draft = 'draft' # 草稿
published = 'published' # 已发布
archived = 'archived' # 已归档
private = 'private' # 私密
class Article(db.Model):
"""文章模型"""
__tablename__ = 'articles'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
# 内容
title = db.Column(db.String(200), nullable=False, comment='标题')
slug = db.Column(db.String(200), unique=True, nullable=False, index=True, comment='URL友好的标识')
summary = db.Column(db.String(500), comment='摘要')
content = db.Column(db.Text, nullable=False, comment='正文')
cover_image = db.Column(db.String(500), comment='封面图URL')
# 状态
status = db.Column(db.Enum(ArticleStatus), default=ArticleStatus.draft, nullable=False, index=True)
is_pinned = db.Column(db.Boolean, default=False, comment='是否置顶')
is_deleted = db.Column(db.Boolean, default=False, server_default='false', nullable=False)
# 统计
view_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
like_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
comment_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
# 外键
author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True, index=True)
# 时间戳
created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False, index=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=db.func.now(), nullable=False)
published_at = db.Column(db.DateTime, nullable=True, comment='发布时间')
# 关系
comments = db.relationship('Comment', backref='article', lazy='dynamic',
cascade='all, delete-orphan',
order_by='Comment.created_at.desc()')
tags = db.relationship('Tag', secondary='article_tags', backref='articles', lazy='selectin')
# ========== 属性 ==========
@property
def is_published(self):
return self.status == ArticleStatus.published
@property
def reading_time(self):
"""估算阅读时间(分钟)"""
if not self.content:
return 1
return max(1, len(self.content) // 400)
@property
def popularity(self):
"""热度分数"""
return self.view_count + self.like_count * 5 + self.comment_count * 3
# ========== 方法 ==========
def increment_view_count(self):
"""增加浏览量(使用原子操作)"""
self.view_count = Article.view_count + 1
db.session.commit()
def publish(self):
"""发布文章"""
self.status = ArticleStatus.published
self.published_at = datetime.utcnow()
db.session.commit()
def to_dict(self, include_content=False):
data = {
'id': self.id,
'title': self.title,
'slug': self.slug,
'summary': self.summary,
'cover_image': self.cover_image,
'status': self.status.value if self.status else None,
'view_count': self.view_count,
'like_count': self.like_count,
'comment_count': self.comment_count,
'reading_time': self.reading_time,
'author': self.author.to_dict() if self.author else None,
'category': {'id': self.category.id, 'name': self.category.name} if self.category else None,
'tags': [{'id': t.id, 'name': t.name} for t in self.tags],
'created_at': self.created_at.isoformat() if self.created_at else None,
'published_at': self.published_at.isoformat() if self.published_at else None,
}
if include_content:
data['content'] = self.content
return data
def __repr__(self):
return f'<Article {self.title}>'
9.4 评论表设计
python
class Comment(db.Model):
"""评论模型(支持嵌套评论)"""
__tablename__ = 'comments'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
# 内容
content = db.Column(db.Text, nullable=False, comment='评论内容')
# 状态
is_deleted = db.Column(db.Boolean, default=False, server_default='false', nullable=False)
is_approved = db.Column(db.Boolean, default=True, server_default='true', nullable=False)
# 外键
article_id = db.Column(db.Integer, db.ForeignKey('articles.id'), nullable=False, index=True)
author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
# 嵌套评论(自引用)
parent_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=True, index=True)
# 统计
like_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
# 时间戳
created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=db.func.now(), nullable=False)
# 关系(自引用: 父评论和子评论)
replies = db.relationship(
'Comment',
backref=db.backref('parent', remote_side='Comment.id'),
lazy='selectin',
cascade='all, delete-orphan',
order_by='Comment.created_at.asc()',
)
@property
def reply_count(self):
"""回复数量"""
return self.replies.count() if hasattr(self.replies, 'count') else len(self.replies)
def to_dict(self, include_replies=True):
data = {
'id': self.id,
'content': self.content,
'like_count': self.like_count,
'created_at': self.created_at.isoformat() if self.created_at else None,
'author': self.author.to_dict() if self.author else None,
'parent_id': self.parent_id,
}
if include_replies:
data['replies'] = [r.to_dict(include_replies=False) for r in self.replies]
return data
def __repr__(self):
return f'<Comment id={self.id} by={self.author_id}>'
9.5 标签系统设计
python
# 多对多关联表
article_tags = db.Table(
'article_tags',
db.Column('article_id', db.Integer, db.ForeignKey('articles.id'), primary_key=True),
db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True),
db.Column('created_at', db.DateTime, default=datetime.utcnow, server_default=db.func.now()),
)
class Tag(db.Model):
"""标签模型"""
__tablename__ = 'tags'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(50), unique=True, nullable=False, index=True, comment='标签名')
slug = db.Column(db.String(50), unique=True, nullable=False, comment='URL标识')
description = db.Column(db.String(200), comment='标签描述')
usage_count = db.Column(db.Integer, default=0, server_default='0', comment='使用次数')
created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False)
@property
def article_count(self):
"""使用此标签的文章数"""
return len(self.articles)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'slug': self.slug,
'description': self.description,
'article_count': self.article_count,
}
def __repr__(self):
return f'<Tag {self.name}>'
9.6 分类系统设计(自引用树形结构)
python
class Category(db.Model):
"""分类模型(树形结构,自引用)"""
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(50), nullable=False, comment='分类名称')
slug = db.Column(db.String(50), unique=True, nullable=False, index=True, comment='URL标识')
description = db.Column(db.Text, comment='分类描述')
icon = db.Column(db.String(100), comment='分类图标')
sort_order = db.Column(db.Integer, default=0, comment='排序权重')
# 自引用: 父分类
parent_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True, index=True)
# 时间戳
created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False)
# 关系(自引用)
children = db.relationship(
'Category',
backref=db.backref('parent', remote_side='Category.id'),
lazy='selectin',
order_by='Category.sort_order.asc()',
)
articles = db.relationship('Article', backref='category', lazy='dynamic')
@property
def article_count(self):
"""本分类下的文章数(不含子分类)"""
return self.articles.filter_by(status='published').count()
@property
def total_article_count(self):
"""本分类及所有子分类的文章总数"""
count = self.article_count
for child in self.children:
count += child.total_article_count # 递归计算
return count
def get_descendants(self):
"""获取所有子孙分类"""
result = []
for child in self.children:
result.append(child)
result.extend(child.get_descendants())
return result
def get_ancestors(self):
"""获取所有祖先分类"""
result = []
if self.parent:
result.append(self.parent)
result.extend(self.parent.get_ancestors())
return result
def to_dict(self, include_children=True):
data = {
'id': self.id,
'name': self.name,
'slug': self.slug,
'description': self.description,
'icon': self.icon,
'sort_order': self.sort_order,
'parent_id': self.parent_id,
'article_count': self.article_count,
}
if include_children:
data['children'] = [c.to_dict(include_children=True) for c in self.children]
return data
def __repr__(self):
return f'<Category {self.name}>'
9.7 关注系统设计(多对多自引用)
python
# 用户关注关联表(多对多自引用)
user_follows = db.Table(
'user_follows',
db.Column('follower_id', db.Integer, db.ForeignKey('users.id'), primary_key=True, comment='关注者'),
db.Column('followed_id', db.Integer, db.ForeignKey('users.id'), primary_key=True, comment='被关注者'),
db.Column('created_at', db.DateTime, default=datetime.utcnow, server_default=db.func.now()),
)
# 在User模型中添加关注关系
# followers: 谁关注了我(粉丝)
# following: 我关注了谁
# 在User类中添加(补充):
# followers = db.relationship(
# 'User',
# secondary=user_follows,
# primaryjoin=(user_follows.c.followed_id == id),
# secondaryjoin=(user_follows.c.follower_id == id),
# backref=db.backref('following', lazy='dynamic'),
# lazy='dynamic'
# )
# 使用示例:
# user.following.append(target_user) # 关注
# user.following.remove(target_user) # 取消关注
# user.following.all() # 我关注的人
# user.followers.all() # 关注我的人
9.8 数据库索引设计
python
# ========== 索引设计原则 ==========
# 1. 在WHERE、JOIN、ORDER BY频繁出现的列上建索引
# 2. 选择区分度高的列建索引
# 3. 复合索引遵循"最左前缀"原则
# 4. 避免过度索引(影响写入性能)
# articles表的索引(在模型定义中通过index=True或__table_args__设置):
class Article(db.Model):
__tablename__ = 'articles'
# ... 字段定义 ...
__table_args__ = (
# 复合索引: 按状态+创建时间查询(最常用的查询模式)
db.Index('idx_status_created', 'status', 'created_at'),
# 复合索引: 按作者+状态查询
db.Index('idx_author_status', 'author_id', 'status'),
# 复合索引: 按分类+状态查询
db.Index('idx_category_status', 'category_id', 'status'),
# 全文搜索索引(PostgreSQL)
# db.Index('idx_content_search', 'content', postgresql_using='gin'),
)
# comments表的索引
class Comment(db.Model):
__tablename__ = 'comments'
# ... 字段定义 ...
__table_args__ = (
# 按文章+创建时间查询评论
db.Index('idx_article_created', 'article_id', 'created_at'),
# 按父评论查询回复
db.Index('idx_parent', 'parent_id'),
)
9.9 完整模型代码实现
以下是博客系统的完整模型定义(整合版):
python
"""
博客系统完整模型定义
文件: app/models.py
"""
from datetime import datetime
import enum
from werkzeug.security import generate_password_hash, check_password_hash
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
# ========== 枚举定义 ==========
class ArticleStatus(enum.Enum):
draft = 'draft'
published = 'published'
archived = 'archived'
private = 'private'
# ========== 关联表 ==========
article_tags = db.Table(
'article_tags',
db.Column('article_id', db.Integer, db.ForeignKey('articles.id'), primary_key=True),
db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True),
db.Column('created_at', db.DateTime, default=datetime.utcnow),
)
user_follows = db.Table(
'user_follows',
db.Column('follower_id', db.Integer, db.ForeignKey('users.id'), primary_key=True),
db.Column('followed_id', db.Integer, db.ForeignKey('users.id'), primary_key=True),
db.Column('created_at', db.DateTime, default=datetime.utcnow),
)
# ========== 用户模型 ==========
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(256), nullable=False)
nickname = db.Column(db.String(80))
avatar_url = db.Column(db.String(500))
bio = db.Column(db.Text)
is_active = db.Column(db.Boolean, default=True, server_default='true', nullable=False)
is_admin = db.Column(db.Boolean, default=False, server_default='false', nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=db.func.now(), nullable=False)
last_login = db.Column(db.DateTime)
# 关系
articles = db.relationship('Article', backref='author', lazy='dynamic', foreign_keys='Article.author_id')
comments = db.relationship('Comment', backref='author', lazy='dynamic')
profile = db.relationship('UserProfile', backref='user', uselist=False, cascade='all, delete-orphan')
followers = db.relationship(
'User', secondary=user_follows,
primaryjoin=(user_follows.c.followed_id == id),
secondaryjoin=(user_follows.c.follower_id == id),
backref=db.backref('following', lazy='dynamic'),
lazy='dynamic'
)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def follow(self, user):
if not self.is_following(user):
self.followers.append(user)
def unfollow(self, user):
if self.is_following(user):
self.followers.remove(user)
def is_following(self, user):
return self.following.filter(user_follows.c.followed_id == user.id).count() > 0
@property
def display_name(self):
return self.nickname or self.username
def to_dict(self):
return {
'id': self.id,
'username': self.username,
'nickname': self.nickname,
'avatar_url': self.avatar_url,
'bio': self.bio,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f'<User {self.username}>'
class UserProfile(db.Model):
__tablename__ = 'user_profiles'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), unique=True, nullable=False)
phone = db.Column(db.String(20))
location = db.Column(db.String(100))
birthday = db.Column(db.Date)
preferences = db.Column(db.JSON, default=dict)
# ========== 分类模型 ==========
class Category(db.Model):
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
slug = db.Column(db.String(50), unique=True, nullable=False, index=True)
description = db.Column(db.Text)
sort_order = db.Column(db.Integer, default=0)
parent_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now())
children = db.relationship('Category', backref=db.backref('parent', remote_side='Category.id'), lazy='selectin', order_by='Category.sort_order')
articles = db.relationship('Article', backref='category', lazy='dynamic')
def to_dict(self, include_children=True):
data = {'id': self.id, 'name': self.name, 'slug': self.slug, 'description': self.description}
if include_children:
data['children'] = [c.to_dict() for c in self.children]
return data
# ========== 文章模型 ==========
class Article(db.Model):
__tablename__ = 'articles'
__table_args__ = (
db.Index('idx_status_created', 'status', 'created_at'),
db.Index('idx_author_status', 'author_id', 'status'),
)
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
slug = db.Column(db.String(200), unique=True, nullable=False, index=True)
summary = db.Column(db.String(500))
content = db.Column(db.Text, nullable=False)
cover_image = db.Column(db.String(500))
status = db.Column(db.Enum(ArticleStatus), default=ArticleStatus.draft, nullable=False, index=True)
view_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
like_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
comment_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False, index=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=db.func.now(), nullable=False)
published_at = db.Column(db.DateTime, nullable=True)
comments = db.relationship('Comment', backref='article', lazy='dynamic', cascade='all, delete-orphan', order_by='Comment.created_at.desc()')
tags = db.relationship('Tag', secondary=article_tags, backref='articles', lazy='selectin')
def to_dict(self, include_content=False):
data = {
'id': self.id, 'title': self.title, 'slug': self.slug, 'summary': self.summary,
'status': self.status.value if self.status else None,
'view_count': self.view_count, 'like_count': self.like_count,
'comment_count': self.comment_count,
'author': self.author.to_dict() if self.author else None,
'category': {'id': self.category.id, 'name': self.category.name} if self.category else None,
'tags': [{'id': t.id, 'name': t.name} for t in self.tags],
'created_at': self.created_at.isoformat() if self.created_at else None,
}
if include_content:
data['content'] = self.content
return data
# ========== 标签模型 ==========
class Tag(db.Model):
__tablename__ = 'tags'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False, index=True)
slug = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.String(200))
created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now())
def to_dict(self):
return {'id': self.id, 'name': self.name, 'slug': self.slug, 'description': self.description}
# ========== 评论模型 ==========
class Comment(db.Model):
__tablename__ = 'comments'
__table_args__ = (
db.Index('idx_article_created', 'article_id', 'created_at'),
)
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
is_deleted = db.Column(db.Boolean, default=False, server_default='false', nullable=False)
like_count = db.Column(db.Integer, default=0, server_default='0', nullable=False)
article_id = db.Column(db.Integer, db.ForeignKey('articles.id'), nullable=False, index=True)
author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
parent_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=True, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, server_default=db.func.now(), nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=db.func.now())
replies = db.relationship('Comment', backref=db.backref('parent', remote_side='Comment.id'), lazy='selectin', cascade='all, delete-orphan', order_by='Comment.created_at.asc()')
def to_dict(self, include_replies=True):
data = {
'id': self.id, 'content': self.content, 'like_count': self.like_count,
'created_at': self.created_at.isoformat() if self.created_at else None,
'author': self.author.to_dict() if self.author else None,
'parent_id': self.parent_id,
}
if include_replies:
data['replies'] = [r.to_dict(include_replies=False) for r in self.replies]
return data
9.10 常用查询实现
python
from sqlalchemy import select, func, and_, or_, desc
from sqlalchemy.orm import selectinload, joinedload
# ========== 1. 首页文章列表(分页) ==========
def get_home_articles(page=1, per_page=10):
"""获取首页已发布文章"""
stmt = (
select(Article)
.where(Article.status == ArticleStatus.published)
.options(
joinedload(Article.author),
joinedload(Article.category),
selectinload(Article.tags),
)
.order_by(Article.is_pinned.desc(), Article.created_at.desc())
)
# 分页
total = db.session.execute(
select(func.count()).select_from(stmt.subquery())
).scalar()
articles = db.session.execute(
stmt.offset((page - 1) * per_page).limit(per_page)
).scalars().all()
return {
'articles': [a.to_dict() for a in articles],
'total': total,
'page': page,
'per_page': per_page,
'pages': (total + per_page - 1) // per_page,
}
# ========== 2. 文章详情 ==========
def get_article_detail(slug):
"""获取文章详情(含作者、分类、标签、评论)"""
stmt = (
select(Article)
.where(Article.slug == slug, Article.status == ArticleStatus.published)
.options(
joinedload(Article.author),
joinedload(Article.category),
selectinload(Article.tags),
selectinload(Article.comments).joinedload(Comment.author),
)
)
article = db.session.execute(stmt).scalars().one_or_none()
return article.to_dict(include_content=True) if article else None
# ========== 3. 按分类查询文章 ==========
def get_articles_by_category(category_slug, page=1, per_page=10):
"""获取分类下的文章(含子分类)"""
# 先找到分类
category = db.session.execute(
select(Category).where(Category.slug == category_slug)
).scalar_one_or_none()
if not category:
return None
# 获取所有子分类ID(含自身)
category_ids = [category.id] + [c.id for c in category.get_descendants()]
stmt = (
select(Article)
.where(
and_(
Article.category_id.in_(category_ids),
Article.status == ArticleStatus.published,
)
)
.options(joinedload(Article.author))
.order_by(Article.created_at.desc())
)
total = db.session.execute(select(func.count()).select_from(stmt.subquery())).scalar()
articles = db.session.execute(stmt.offset((page - 1) * per_page).limit(per_page)).scalars().all()
return {
'category': category.to_dict(),
'articles': [a.to_dict() for a in articles],
'total': total,
'page': page,
}
# ========== 4. 按标签查询文章 ==========
def get_articles_by_tag(tag_slug, page=1, per_page=10):
"""获取标签下的文章"""
tag = db.session.execute(
select(Tag).where(Tag.slug == tag_slug)
).scalar_one_or_none()
if not tag:
return None
stmt = (
select(Article)
.join(article_tags)
.where(
and_(
article_tags.c.tag_id == tag.id,
Article.status == ArticleStatus.published,
)
)
.options(joinedload(Article.author))
.order_by(Article.created_at.desc())
)
total = db.session.execute(select(func.count()).select_from(stmt.subquery())).scalar()
articles = db.session.execute(stmt.offset((page - 1) * per_page).limit(per_page)).scalars().all()
return {
'tag': tag.to_dict(),
'articles': [a.to_dict() for a in articles],
'total': total,
}
# ========== 5. 用户文章列表 ==========
def get_user_articles(username, page=1, per_page=10, include_draft=False):
"""获取用户的文章列表"""
user = db.session.execute(
select(User).where(User.username == username)
).scalar_one_or_none()
if not user:
return None
stmt = select(Article).where(Article.author_id == user.id)
if not include_draft:
stmt = stmt.where(Article.status == ArticleStatus.published)
stmt = stmt.order_by(Article.created_at.desc())
total = db.session.execute(select(func.count()).select_from(stmt.subquery())).scalar()
articles = db.session.execute(stmt.offset((page - 1) * per_page).limit(per_page)).scalars().all()
return {
'user': user.to_dict(),
'articles': [a.to_dict() for a in articles],
'total': total,
}
# ========== 6. 热门文章 ==========
def get_hot_articles(limit=10, days=7):
"""获取最近N天的热门文章"""
from datetime import timedelta
since = datetime.utcnow() - timedelta(days=days)
stmt = (
select(Article)
.where(
and_(
Article.status == ArticleStatus.published,
Article.created_at >= since,
)
)
.order_by(
(Article.view_count + Article.like_count * 5 + Article.comment_count * 3).desc()
)
.limit(limit)
.options(joinedload(Article.author))
)
articles = db.session.execute(stmt).scalars().all()
return [a.to_dict() for a in articles]
# ========== 7. 标签云 ==========
def get_tag_cloud(limit=50):
"""获取标签云"""
stmt = (
select(
Tag.id, Tag.name, Tag.slug,
func.count(article_tags.c.article_id).label('count'),
)
.join(article_tags, Tag.id == article_tags.c.tag_id)
.join(Article, and_(
article_tags.c.article_id == Article.id,
Article.status == ArticleStatus.published,
))
.group_by(Tag.id, Tag.name, Tag.slug)
.order_by(func.count(article_tags.c.article_id).desc())
.limit(limit)
)
result = db.session.execute(stmt).all()
return [{'id': r.id, 'name': r.name, 'slug': r.slug, 'count': r.count} for r in result]
# ========== 8. 分类树 ==========
def get_category_tree():
"""获取完整分类树"""
stmt = select(Category).where(Category.parent_id.is_(None)).order_by(Category.sort_order)
root_categories = db.session.execute(stmt).scalars().all()
return [c.to_dict() for c in root_categories]
# ========== 9. 文章评论列表(嵌套) ==========
def get_article_comments(article_id):
"""获取文章的评论(嵌套结构)"""
stmt = (
select(Comment)
.where(
and_(
Comment.article_id == article_id,
Comment.parent_id.is_(None), # 只查顶级评论
Comment.is_deleted == False,
)
)
.options(
selectinload(Comment.replies).joinedload(Comment.author),
joinedload(Comment.author),
)
.order_by(Comment.created_at.desc())
)
comments = db.session.execute(stmt).scalars().all()
return [c.to_dict() for c in comments]
# ========== 10. 用户Feed(关注的人的文章) ==========
def get_user_feed(user_id, page=1, per_page=10):
"""获取用户关注的人的文章"""
stmt = (
select(Article)
.join(user_follows, Article.author_id == user_follows.c.followed_id)
.where(
and_(
user_follows.c.follower_id == user_id,
Article.status == ArticleStatus.published,
)
)
.options(joinedload(Article.author))
.order_by(Article.created_at.desc())
)
total = db.session.execute(select(func.count()).select_from(stmt.subquery())).scalar()
articles = db.session.execute(stmt.offset((page - 1) * per_page).limit(per_page)).scalars().all()
return {
'articles': [a.to_dict() for a in articles],
'total': total,
'page': page,
}