写了个博客管理系统,一切正常,程序一关,数据全没了------这是我初学时最真实的经历。
初学编程的时候,我们最先接触的是变量。用 Python 写一个简单的博客系统,可能会这样存数据:
python
posts = [
{
"id": 1,
"title": "我的第一篇博客",
"content": "Hello, world!",
"author": "IVEN",
"created_at": "2026-05-01"
},
{
"id": 2,
"title": "Python 学习笔记",
"content": "今天学了列表和字典...",
"author": "IVEN",
"created_at": "2026-05-02"
}
]
看起来挺完整的,对吧?有标题、有内容、有作者,甚至还有个 id 字段。增删改查都能做:
python
# 新增文章
posts.append({"id": 3, "title": "新文章", ...})
# 查询文章
post = [p for p in posts if p["id"] == 1][0]
# 删除文章
posts = [p for p in posts if p["id"] != 2]
但这里有一个致命的问题:这些数据只存在于内存中 。一旦程序结束,或者电脑重启,posts 这个变量所占用的内存就被操作系统回收了。下次再运行程序,posts 又变回空列表。
这不是 bug,这是特性------内存本身就是临时存储。但对于一个"管理系统"来说,数据不能持久化,就等于没有数据。
文件读写:持久化的第一步
既然内存靠不住,那最简单的想法就是:把数据写到硬盘上。
Python 的 json 模块可以很方便地把 dict/list 序列化成 JSON 字符串,存到文件里:
python
import json
# 保存数据
with open("posts.json", "w", encoding="utf-8") as f:
json.dump(posts, f, ensure_ascii=False, indent=2)
# 读取数据
with open("posts.json", "r", encoding="utf-8") as f:
posts = json.load(f)
这样程序重启后,数据还在。看起来问题解决了一半。
但很快你会发现新的问题。
查询麻烦
假设博客多了,你想按作者筛选文章:
python
# 遍历整个列表,时间复杂度 O(n)
iven_posts = [p for p in posts if p["author"] == "IVEN"]
数据量小的时候无所谓,但如果有几万篇文章,每次查询都要遍历全表,性能堪忧。而且你想按发布时间排序、模糊搜索标题,都得自己写算法。
并发冲突
如果两个用户同时操作这个系统,都往 posts.json 里写数据:
css
用户 A 读取 posts.json → 修改 → 写入
用户 B 读取 posts.json → 修改 → 写入(覆盖了 A 的修改)
文件没有锁机制,最后写入的人 wins,数据就丢了。自己实现文件锁?可以,但很复杂,而且性能很差。
单机限制
posts.json 存在服务器本地磁盘上。如果你的应用要部署到多台服务器做负载均衡,每台服务器都有自己的文件副本,数据无法共享。一台机器上的新文章,另一台机器读不到。
扩展困难
文件大了之后,读写速度直线下降。而且磁盘空间满了,要么加硬盘,要么手动迁移文件,没有弹性扩容的能力。
文件读写的本质:它解决了"数据不丢"的问题,但没有解决"高效、安全、共享"的问题。
关系型数据库:正确的持久化方案
文件读写的问题,本质上是把数据管理的职责交给了程序员自己。查询要自己遍历,并发要自己加锁,扩展要自己想办法。
而数据库,就是专门解决这些问题的独立服务。
连接数据库,用 SQL 操作数据
以 MySQL 为例,我们先创建数据库和表:
sql
-- 创建数据库
CREATE DATABASE blog;
-- 选择数据库
USE blog;
-- 创建文章表
CREATE TABLE posts (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
content TEXT,
author VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
然后插入数据:
sql
INSERT INTO posts (title, content, author)
VALUES ('我的第一篇博客', 'Hello, world!', 'IVEN');
INSERT INTO posts (title, content, author)
VALUES ('Python 学习笔记', '今天学了列表和字典...', 'IVEN');
查询数据:
sql
-- 按 ID 查询(利用主键索引,O(log n))
SELECT * FROM posts WHERE id = 1;
-- 按作者筛选
SELECT * FROM posts WHERE author = 'IVEN';
-- 按时间排序
SELECT * FROM posts ORDER BY created_at DESC;
-- 模糊搜索标题
SELECT * FROM posts WHERE title LIKE '%Python%';
在 Python 中操作:
python
import pymysql
# 建立连接
conn = pymysql.connect(
host='localhost',
user='root',
password='your_password',
database='blog'
)
try:
with conn.cursor() as cursor:
# 插入
sql = "INSERT INTO posts (title, content, author) VALUES (%s, %s, %s)"
cursor.execute(sql, ("新文章", "内容...", "IVEN"))
conn.commit()
# 查询
cursor.execute("SELECT * FROM posts WHERE author = %s", ("IVEN",))
results = cursor.fetchall()
for row in results:
print(row)
finally:
conn.close()
关键转变 :数据不再存在程序内存里,而是交给一个独立运行的数据库服务管理。程序只是通过 SQL 语言告诉数据库"我要做什么",具体怎么存、怎么查、怎么保证安全,都由数据库处理。
核心概念:从 dict 到表
文件里的 JSON 和数据库的表,看起来都是"一行一条记录",但底层完全不同。
| 概念 | JSON 文件 | 数据库表 |
|---|---|---|
| 结构定义 | 没有,随便加字段 | 严格的 schema,列名和类型固定 |
| 唯一标识 | 自己维护 id 字段,无约束 |
内置 PRIMARY KEY,自动唯一且非空 |
| 查询方式 | 遍历整个文件 | 利用索引,B+ 树查找,O(log n) |
| 并发控制 | 无,自己实现 | 内置锁机制,事务隔离 |
| 数据完整性 | 无约束,可能存脏数据 | 外键约束、非空约束、唯一约束 |
主键:数据的身份证
sql
id INT PRIMARY KEY AUTO_INCREMENT
主键有两个作用:
- 唯一标识:每条记录有且只有一个 id,不会重复
- 聚簇索引:数据按主键顺序物理存储,按 id 查询极快
对比 JSON 文件里的 id 字段,那只是你自己写的一个数字,没有任何机制保证它不重复、不为空。
外键:表与表的关系
博客系统不只有文章,还有作者。如果每篇文章都存作者名字,作者改名了怎么办?
sql
-- 作者表
CREATE TABLE authors (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE
);
-- 文章表,用 author_id 关联作者
CREATE TABLE posts (
id INT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
content TEXT,
author_id INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (author_id) REFERENCES authors(id)
);
FOREIGN KEY 就是外键,它建立了两张表之间的关系:
- 插入文章时,
author_id必须是authors表里存在的 id - 删除作者时,如果还有文章引用他,数据库会阻止(或级联删除)
这就是关系型数据库的核心------数据按关系组织,避免冗余,保证一致性。
索引:为什么查询这么快
没有索引的查询,数据库也要遍历全表(全表扫描)。但给 author_id 加上索引:
sql
CREATE INDEX idx_author ON posts(author_id);
数据库会额外维护一个 B+ 树结构,按 author_id 排序。查询时从树根往下找,时间复杂度从 O(n) 降到 O(log n)。
代价是:插入、更新、删除时需要维护索引树,会慢一点。所以索引不是越多越好,要根据查询模式权衡。
事务:要么全成功,要么全失败
假设博客系统有个功能:发布文章时,同时给作者的文章计数 +1。
sql
-- 插入文章
INSERT INTO posts (title, content, author_id) VALUES ('标题', '内容', 1);
-- 更新作者文章数
UPDATE authors SET post_count = post_count + 1 WHERE id = 1;
如果第一条执行成功,第二条执行失败(比如作者被删了),数据就乱了:文章存在,但计数没更新。
事务就是解决这个问题的:
sql
START TRANSACTION;
INSERT INTO posts (title, content, author_id) VALUES ('标题', '内容', 1);
UPDATE authors SET post_count = post_count + 1 WHERE id = 1;
COMMIT; -- 两条都成功,才提交
-- 或
ROLLBACK; -- 任何一条失败,全部回滚
ACID 特性:
- Atomicity(原子性):要么全做,要么全不做
- Consistency(一致性):事务前后,数据始终合法
- Isolation(隔离性):多个事务并发执行,互不干扰
- Durability(持久性):一旦提交,数据永久保存
文件读写要实现这些?几乎不可能。
范式与反范式:设计的权衡
第一范式 :每个字段原子不可分。比如不能把多个标签存成一个字符串 "python,sql,database",而应该拆成关联表。
第二范式 :非主键字段必须依赖整个主键。比如联合主键 (post_id, tag_id) 的表里,不能存 post_title,因为 post_title 只依赖 post_id。
第三范式 :非主键字段不能传递依赖。比如文章表里存了 author_id,就不该再存 author_name,因为 author_name 依赖 author_id,不是直接依赖文章主键。
但实际项目中,为了查询性能,经常会反范式 ------故意冗余一些字段,避免联表查询。比如文章列表页显示作者名,如果每次都 JOIN authors 表,数据量大时性能差。这时可以在 posts 里冗余一个 author_name,用空间换时间。
没有绝对正确的范式级别,只有适合业务场景的设计。
ORM:对象与表的桥梁
手写 SQL 很直观,但代码里充斥着字符串拼接和参数处理,容易出错也不优雅:
python
# 手写 SQL 的问题
sql = "SELECT * FROM posts WHERE author = %s AND created_at > %s"
cursor.execute(sql, ("IVEN", "2026-01-01"))
更麻烦的是,查询结果是一堆元组,要手动转成对象:
python
row = cursor.fetchone()
post = {
"id": row[0],
"title": row[1],
"content": row[2],
# ... 字段多了很容易对不上
}
ORM(Object-Relational Mapping)就是解决这个问题的:用操作对象的方式,代替手写 SQL。
先手写 SQL,再理解 ORM 的价值
以 SQLAlchemy 为例,先定义模型类:
python
from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey, TIMESTAMP
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
from datetime import datetime
Base = declarative_base()
class Author(Base):
__tablename__ = 'authors'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
email = Column(String(255), unique=True)
# 关系:一个作者有多篇文章
posts = relationship("Post", back_populates="author")
class Post(Base):
__tablename__ = 'posts'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(255), nullable=False)
content = Column(Text)
author_id = Column(Integer, ForeignKey('authors.id'))
created_at = Column(TIMESTAMP, default=datetime.now)
# 关系:一篇文章属于一个作者
author = relationship("Author", back_populates="posts")
然后操作数据就像操作 Python 对象:
python
# 创建会话
Session = sessionmaker(bind=engine)
session = Session()
# 新增:创建对象,添加到会话,提交
new_post = Post(title="ORM 真香", content="不用写 SQL 了...", author_id=1)
session.add(new_post)
session.commit()
# 查询:面向对象的方式
post = session.query(Post).filter(Post.id == 1).first()
print(post.title) # 直接访问属性
print(post.author.name) # 通过关系访问关联对象
# 更新:修改对象属性,提交
post.title = "修改后的标题"
session.commit()
# 删除:删除对象,提交
session.delete(post)
session.commit()
核心转变:从"操作 SQL 字符串"变成"操作 Python 对象"。表结构映射成类,记录映射成对象,关系映射成属性。
ORM 的坑:便利背后的代价
ORM 不是银弹,不懂底层 SQL 很容易踩坑。
N+1 查询问题
假设你要查所有文章及其作者:
python
posts = session.query(Post).all() # 1 次查询,查出所有文章
for post in posts:
print(post.author.name) # 每次访问 author,都发 1 次查询
如果有 100 篇文章,总共发了 101 次查询(1 次查文章 + 100 次查作者)。这就是 N+1 问题。
解决方案是预加载:
python
from sqlalchemy.orm import joinedload
# JOIN 一起查出来,只发 1 次查询
posts = session.query(Post).options(joinedload(Post.author)).all()
懒加载的陷阱
默认情况下,post.author 是懒加载的------第一次访问时才发查询。这在循环里就是灾难。
可以配置成急加载 或选择性加载,根据场景权衡。
复杂查询的局限
ORM 擅长单表 CRUD 和简单关联,但复杂统计、子查询、窗口函数,手写 SQL 更直接:
python
# ORM 写这个很别扭:按月份统计文章数
# 不如直接手写 SQL
result = session.execute("""
SELECT DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*)
FROM posts
GROUP BY month
""")
我的建议:用 ORM 处理日常 CRUD,复杂查询回退到原生 SQL。两者结合,各取所长。
Redis:缓存层的配合
数据库解决了持久化,但磁盘 I/O 始终是瓶颈。MySQL 的一次查询,即使走索引,也要毫秒级。
而内存操作是纳秒级,差了几个数量级。
内存 vs 磁盘:性能对比
| 操作 | 耗时 | 类比 |
|---|---|---|
| L1 缓存读取 | 1 ns | 从口袋拿东西 |
| 内存读取 | 100 ns | 从房间拿东西 |
| 磁盘 I/O | 10 ms | 从北京到上海取东西 |
| 网络请求 | 150 ms | 从中国到美国取东西 |
数据库查一次 10 ms,看着很快,但高并发下累积起来就是灾难。Redis 把热点数据放内存,查询降到 100 ns 级别。
缓存热点数据
博客系统的首页,通常会展示最新文章列表。这个查询很频繁,但数据变化不频繁:
python
import redis
import json
r = redis.Redis(host='localhost', port=6379, db=0)
def get_latest_posts(limit=10):
# 先查缓存
cached = r.get('latest_posts')
if cached:
return json.loads(cached) # 缓存命中,直接返回
# 缓存未命中,查数据库
posts = session.query(Post).order_by(Post.created_at.desc()).limit(limit).all()
result = [{"id": p.id, "title": p.title} for p in posts]
# 写入缓存,设置 5 分钟过期
r.setex('latest_posts', 300, json.dumps(result))
return result
缓存策略:
- 读数据:先查 Redis,命中直接返回;未命中查数据库,再写入 Redis
- 写数据:先更新数据库,再删除缓存(或更新缓存)
- 过期时间:根据业务设置,平衡实时性和性能
我项目中的实际用法
在我做的情侣备忘录项目里,用户登录后需要验证 Refresh Token 的有效性。这个验证很频繁,但 Token 数据不常变,就存在 Redis 里:
python
# 双令牌机制:Access Token 短期有效,Refresh Token 长期有效
# Refresh Token 的有效性存在 Redis,支持后端主动失效
r.setex(f"rt:{user_id}", 7*24*3600, refresh_token_value)
这样即使数据库挂了,已登录用户的 Token 验证不受影响,也减轻了数据库压力。
总结:思维升级
从初学到现在,我对"数据存储"的认知经历了几次转变:
| 阶段 | 存储方式 | 认知水平 |
|---|---|---|
| 初学 | 内存变量(dict/list) | 数据随程序生灭 |
| 进阶 | JSON/CSV 文件 | 数据能持久化,但管理粗糙 |
| 成熟 | 关系型数据库 | 数据是独立服务,专业管理 |
| 进阶 | 数据库 + 缓存 | 分层存储,各取所长 |
核心转变:从"数据存在我的程序里"到"数据存在于独立的服务中,我的程序只是使用者"。
这个转变带来的好处:
- 解耦:程序重启、扩容、迁移,数据不受影响
- 专业:查询优化、并发控制、数据安全,交给数据库处理
- 弹性:数据库可以独立扩容,云厂商提供托管服务
- 协作:多个服务共享同一份数据,通过 SQL 标准接口交互
数据是应用的核心。理解怎么存、怎么查、怎么保证安全和性能,是从"会写代码"到"能建系统"的关键一步。