全栈开发必看:从内存变量到关系型数据库的完整旅程

写了个博客管理系统,一切正常,程序一关,数据全没了------这是我初学时最真实的经历。

初学编程的时候,我们最先接触的是变量。用 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 标准接口交互

数据是应用的核心。理解怎么存、怎么查、怎么保证安全和性能,是从"会写代码"到"能建系统"的关键一步。

相关推荐
掘金者阿豪1 小时前
Go 语言操作金仓数据库(下篇):SQL 执行、类型映射与超时控制
后端
MacroZheng1 小时前
横空出世!IDEA最强MyBatis插件来了,功能很全!
java·后端·mybatis
codebetter1 小时前
X86 Windows Docker Desktop 运行 arm64 容器
后端
掘金者阿豪1 小时前
Go 语言操作金仓数据库(上篇):环境搭建与连接管理
后端
何陋轩1 小时前
Spring AI Function Calling:让AI调用你的Java方法
人工智能·后端·ai编程
alwaysrun1 小时前
Rust之异步框架Tokio
后端·编程语言
Csvn1 小时前
日志系统
后端·python
CodeSheep1 小时前
中国编程第一人,一人抵一城!
前端·后端·程序员