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

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

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

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

相关推荐
RemainderTime11 分钟前
Spring Boot脚手架集成Sa-Token实现生产级RBAC权限管理
java·spring boot·后端·系统架构
llz_1123 小时前
web-第二次课后作业
前端·后端·web
红尘散仙9 小时前
我把终端小说阅读器接上了 AI Agent:TRNovel 现在能用 skill 生成书源了
人工智能·后端·rust
卷毛的技术笔记11 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·人工智能·后端·python·spring·ai编程
会编程的土豆11 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
喵个咪11 小时前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
basketball61612 小时前
Go 语言从入门到进阶:4. 数组和MAP使用方法总结
开发语言·后端·golang
qq_25183645712 小时前
SpringBoot+Vue 共享电池柜管理系统 完整实现 前后端分离项目实战 完整代码
vue.js·spring boot·后端
zhangxingchao12 小时前
AI 大模型核心六:量化、Workflow 与 Agent、多轮 RAG
前端·人工智能·后端
IT_陈寒13 小时前
Vite打包时遇到的坑,原来问题出在这里
前端·人工智能·后端