最近整理自己的学习清单时,我注意到一个现象:搜索“数据库的基本操作”这个关键词的人非常多,关联热搜里既有“数据库增删改查”“mysql基本操作”“sqlite3基本操作”这类入门问题,也有“数据库死锁”“数据库并发锁”“数据库设计”“数据库同步软件”这种看起来一点也不“基础”的进阶词。这说明很多人对“基本操作”的理解是割裂的——以为会几条 SQL 就算会数据库,等真正写项目时才发现问题全都堆在一起。
这篇文章我不打算只罗列命令,而是想把“数据库的基本操作”这一条线完整串起来:从建表设计到底层的数据读写,再到事务锁、索引优化、多库迁移,按一个真实项目的推进顺序来走一遍。无论你是正在做数据库课程设计的学生、刚转行写业务代码的开发新人,还是负责维护某个老系统的工程师,这篇文章都可以当作一份可以照着做、可以复盘的操作手册。
1. 热搜词背后的真实需求:“数据库基本操作”到底指什么
1.1 搜索热度暴露出的知识断层
先看这些热搜词的特点。入门类关键词几乎集中在“增删改查”上,这很正常,因为大多数人第一次接触数据库都是从 INSERT、SELECT、UPDATE、DELETE 这四句开始的。可一旦进入真实项目,“数据库设计”“数据库死锁”“mysql数据库连接池”“数据库开启审计引起索引争用”“时序数据库的数据库结构怎样设计”这些词马上就会跳出来。它们看起来是独立话题,其实全是“基本操作”在不同阶段的延伸。
用一句话概括:基本操作不是四个动词,而是一条完整的链路。这个链路包括结构设计(建表)、数据读写(CRUD)、一致性保障(事务与锁)、性能控制(索引与执行计划)、跨环境适配(迁移与同步)。如果你只背住了四个动词,而对其他环节一无所知,那在实际工作中几乎寸步难行。我见过太多简历上写着“熟悉数据库基本操作”的候选人,一到现场让写一条带关联查询和分页的 SQL 就卡壳,更别提解释死锁原理了。
1.2 本篇文章的定位和读者范围
接下来的内容以关系型数据库为主,语法示例优先使用 MySQL,同时会穿插 SQLite 和 PostgreSQL/国产数据库的差异点。这样选择是因为目前市面上绝大多数课程设计、后台管理系统、中小型业务系统都用 MySQL;而 SQLite 零配置、单文件特性特别适合本地工具和移动端;国产数据库(达梦、人大金仓、GaussDB)在信创项目里越来越常用。三者都讲清楚,你基本能覆盖绝大多数开发场景。
适合阅读这篇文章的人有三类。第一类是我上面说的新手,需要一个完整的入门路径,而不是零散知识;第二类是已经在写业务代码,但对事务、索引、锁这些概念一知半解的开发者;第三类是给了你一个旧系统,需要做数据库维护、上线、迁移的人。读完你会对“操作”二字有更具体的认知——它不是把 SQL 敲出来,而是知道每一个操作在数据库内部会发生什么,以及为什么这样做最稳。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 建表并非随便写几句 SQL:字段类型、约束与规范化
2.1 为什么建表才是真正的第一步
很多人处理“数据库基本操作”时,第一个动作就是写 INSERT,这是本末倒置。数据库是所有业务数据的地基,表结构没设计好,后面所有查询、更新、统计都会很难受。举个最常见的反面例子:所有字符串一律 VARCHAR(255),金额用 FLOAT,时间用 VARCHAR 存。这种表刚导入几条数据时看着没问题,一旦数据到几万行,按时间范围查询就慢,金额经过计算还会出现 0.30000000000000004 这种浮点误差。所以建表之前,一定要先把字段类型想清楚。
设计表结构的本质是回答三个问题:这份数据是什么类型的?它会怎么被查询?它有多重要?字段类型解决前两个问题,约束和索引解决第二个,外键、日志、备份策略解决第三个。很多课程设计项目不需要考虑并发,但一旦上了生产环境,这三个问题一个都躲不过。
2.2 一份可照抄的用户表设计
我直接给一个比较规范的用户表设计,并解释每一处为什么这样写。
sql复制CREATE TABLE `user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`username` VARCHAR(64) NOT NULL COMMENT '用户名',
`email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱',
`phone` VARCHAR(32) DEFAULT NULL COMMENT '手机号',
`balance` DECIMAL(12, 2) NOT NULL DEFAULT 0.00 COMMENT '账户余额',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 0禁用',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
主键我用 BIGINT UNSIGNED 而不是 INT,原因很简单:INT 最大到约 21 亿,对有些系统来说并不算多,一旦接近上限再改主键类型就是大工程。BIGINT 在 InnoDB 下做聚簇索引没有任何性能负担。余额字段我强烈建议用 DECIMAL(12,2),而不是 FLOAT/DOUBLE。浮点数在计算机里本身是近似值,涉及钱时丢一位分都很难解释。DATETIME 用默认值自动填充,避免每次插入都要手工写时间。username 上加了唯一键,这是业务上非常常见的“一个用户名只能注册一次”的需求。
2.3 主键选择的坑:自增、UUID 还是雪花 ID
主键是表设计里争议最多的地方。自增主键写起来最简单,插入性能也最好,因为自增是顺序写,B+ 树不需要频繁分裂。但它的缺点是会暴露业务量,比如订单号从 12345 连续增长,竞对很容易猜出你的日订单量。UUID 主键不会暴露业务量,但字符串类型做主键会带来两个问题:存储空间变大,随机插入会导致聚簇索引频繁分裂,重做日志和缓冲池的压力都更大。雪花 ID 是一个折中方案,靠时间戳+机器号+序列生成一个不重复且趋势递增的 BIGINT,很多中大型系统都优先用它。
我的建议是:如果你做的是中小型项目,直接自增 BIGINT 主键,省心又稳定;如果你的系统未来会做分库分表,或者需要各端离线生成主键,那从一开始就不要用自增,选雪花 ID 类的方案。主键选择是那种“改动成本极高”的决策,一旦数据量上来再改,等于重写整张表。
2.4 表结构不是一锤子买卖:ALTER TABLE 的常见用法
项目迭代过程中,表结构一定会有变化。热搜里有“mysql数据库修改结构”,说明很多人都在这上面查过资料。最常用的几个修改语句我给你列出来:
sql复制-- 增加字段
ALTER TABLE `user` ADD COLUMN `age` TINYINT UNSIGNED DEFAULT NULL AFTER `phone`;
-- 修改字段类型或默认值
ALTER TABLE `user` MODIFY COLUMN `phone` VARCHAR(20) NOT NULL DEFAULT '';
-- 修改字段名
ALTER TABLE `user` RENAME COLUMN `phone` TO `mobile`;
-- 删除字段
ALTER TABLE `user` DROP COLUMN `age`;
这里有一个非常容易被忽略的细节:修改字段类型时,如果表里已经有大量数据,MySQL 往往需要重建表,期间会锁住表或者使用在线 DDL 的算法,但无论哪种都会对线上造成一定影响。生产环境做这类操作,最好评估数据量、选择业务低峰期,并且先备份。一个小技巧:ADD COLUMN 可以通过 AFTER 指定新列的位置,但如果你不确定,加在表末尾也完全不丢功能,别为了美观去折腾位置。
3. 增删改查:CRUD 执行细节与常见陷阱
3.1 INSERT:批量插入与防重才是重点
INSERT 的语法很简单,但执行细节直接影响性能。最基础的两种写法:
sql复制-- 单条插入
INSERT INTO `user` (`username`, `email`) VALUES ('zhangsan', 'zs@example.com');
-- 多条批量插入
INSERT INTO `user` (`username`, `email`) VALUES
('lisi', 'ls@example.com'),
('wangwu', 'ww@example.com'),
('zhaoliu', 'zl@example.com');
批量插入不是节省了几行代码,而是大幅减少了客户端与数据库之间的网络往返次数和日志写入次数。我在项目里导入一批一万行的 Excel 数据时,逐条 INSERT 可能要几十秒,改成每 500 行打包一次插入,通常可以压到 2 秒以内。如果你的数据里可能有重复记录,可以用下面两种处理方式:
sql复制-- 忽略重复:INSERT IGNORE
INSERT IGNORE INTO `user` (`username`) VALUES ('lisi');
-- 重复则更新:ON DUPLICATE KEY UPDATE
INSERT INTO `user` (`username`, `phone`) VALUES ('lisi', '13800138000')
ON DUPLICATE KEY UPDATE `phone` = VALUES(`phone`);
这里有个坑:ON DUPLICATE KEY UPDATE 在并发量大、批量条数很多时,会产生比普通 INSERT 更多的锁竞争。如果你在做一个高并发写入的系统,不要随便把大事务包着一次上百万行的批量操作,建议拆成小批次。另外,MySQL 8.0.20 之后 VALUES() 函数标记为过时,推荐用别名写法,不过老版本项目里 VALUES() 还是随处可见,能看懂就行。
3.2 SELECT:不只“查出来”,更要“查得快”
SELECT 是平时用得最多的操作。第一个原则:不要写 SELECT *。在生产环境数据表动辄几十个字段,如果你只需要 id、username、email,却把整行数据全部拉出来,既浪费网络带宽,又浪费数据库的 IO。更麻烦的是,一旦某天表里加了一个 TEXT 大字段,原来的接口速度会直接崩掉,而这种问题很难排查。
第二个原则是注意 WHERE 条件的写法。最常见的索引失效场景有两种。一种是对索引列做函数运算,比如 WHERE YEAR(created_at) = 2024,这会导致该列上的索引完全失效;另一种是模糊查询把通配符放在最前面,比如 WHERE username LIKE '%zhang%',这也会让数据库放弃索引走全表扫描。你需要模糊搜索时,要么接受全表扫描,要么考虑搜索引擎方案,不要指望数据库索引替你解决所有问题。
第三个容易踩的坑是分页。很多系统喜欢这样写:
sql复制SELECT id, username FROM `user` ORDER BY id LIMIT 100000, 20;
这个写法在数据量小的时候没什么问题,但一旦偏移量到十万甚至百万,数据库依然要先把前十万行扫出来再丢弃,性能会急剧下降。更推荐的做法是基于主键或唯一键做游标分页,比如传入上一页最后一条记录的 id,然后查询 id > 100000 ORDER BY id LIMIT 20。这个方案在“下一页”这种场景下非常高效,唯一的麻烦是用户不能随便跳页。
3.3 UPDATE:先确认影响行数再动手
UPDATE 最怕的是忘写 WHERE。一条“UPDATE user SET status = 0”就会把整张表的用户全部禁用,这类事故在开发环境很常见,在线上也不是没有。我的习惯是:在 MySQL 客户端执行前,先跑一条等价的 SELECT 看影响范围,再执行 UPDATE。
另外,MySQL 有一个很实用的安全机制:在命令行登录时加上 --safe-updates,或者在 MySQL 5.7+ 中开启 sql_safe_updates。开启后,不带 WHERE 或没有使用索引的 UPDATE/DELETE 会被拒绝执行。当然,开发环境也会需要全表更新,但那种情况下你大概率知道自己要做什么,不需要临时去改全局配置。
还有一个经验:UPDATE 影响到的行数,最好在应用层读出来。比如用 UPDATE 修改会员等级,可以先 SELECT 查出哪些 id 会受到影响,确保没有多算少算,再执行 UPDATE。因为行锁一旦加上,事务提交前其他请求都会被阻塞,如果 WHERE 条件写得不够精确,锁范围扩大,线上接口的响应时间很快就会报警。
3.4 DELETE:别把清理数据和删表搞混
DELETE 和 TRUNCATE 都可以清空数据,但区别很大。DELETE 是一行一行删,支持 WHERE,会产生大量 binlog 和 undo log,速度慢;TRUNCATE 是直接重建表,速度极快但不能按条件删除。DROP 则会连表结构一起删掉。遇到外键约束时,DELETE 的删除顺序也有讲究。假设你有父表 orders 和子表 order_items,直接删除 orders 里的单据,会被外键约束拦截;正确做法是先删关联的子表记录,再删父表记录。
对于很多业务系统,我更推荐“软删除”而不是物理删除。也就是加一个 deleted 字段,查询时统一带 WHERE deleted = 0。原因很简单:数据删了很难恢复,而软删除能保留历史痕迹和二次审计的可能。代价是每一条查询都要记得过滤该字段,否则统计数字会出错。这个方案并不完美,但确实能解决大量误操作问题。
3.5 CRUD 常见操作速查
| 操作 | 示例 | 最容易出问题的点 |
|---|---|---|
| INSERT | INSERT INTO user(username) VALUES('a') | 重复插入、字段超长 |
| SELECT | SELECT id, username FROM user WHERE status=1 | SELECT * 拉大字段、索引失效 |
| UPDATE | UPDATE user SET status=0 WHERE id=123 | 忘 WHERE 导致全表更新 |
| DELETE | DELETE FROM user WHERE id=123 | 删错数据、外键限制、无法恢复 |
这张表看起来简单,但每一项在真实项目里都有对应的踩坑案例。基本操作从来不是背语法,而是在这些边界条件下做出正确选择。
4. 事务与并发锁:为什么“基本操作”会出大问题
4.1 从一次转账说起
如果你只是单机本地测试,可能很难体会到事务的重要性。但把场景放到真实业务里,比如用户转账:账户 A 扣 100 元,账户 B 加 100 元。这两条语句必须绑定成一个整体,要么全部成功,要么全部失败。如果第一条执行成功后第二条失败,钱就凭空消失了。事务的 ACID 特性就是用来解决这类问题的。关系型数据库的“基本操作”从来不是单独一条 SQL 在跑,而是多条 SQL 组成一个原子性工作单元。
下面是最常用的事务写法:
sql复制START TRANSACTION;
UPDATE `account` SET `balance` = `balance` - 100 WHERE `account_id` = 1;
UPDATE `account` SET `balance` = `balance` + 100 WHERE `account_id` = 2;
COMMIT;
-- 如果中途异常,执行 ROLLBACK;
4.2 隔离级别:并发越高,越需要平衡
多条 SQL 放进事务后,并发访问时会出现脏读、不可重复读、幻读的问题。数据库用隔离级别来约束这些现象。下表是标准 SQL 的定义:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 |
| 读已提交 | 不可能 | 可能 | 可能 |
| 可重复读 | 不可能 | 不可能 | 可能 |
| 串行化 | 不可能 | 不可能 | 不可能 |
MySQL 的 InnoDB 默认使用可重复读,并借助间隙锁在一定程度上消除了幻读;Oracle 和 PostgreSQL 默认使用读已提交。我面试候选人的时候,很喜欢问“为什么 MySQL 默认是可重复读而 Oracle 默认是读已提交”,因为这个问题能看出一个人是否真的理解数据库设计。实际操作中,如果你对一致性要求不是极端高,使用数据库默认隔离级别通常是最稳妥的,不要随便调高到串行化,那会极大降低并发能力。
4.3 锁:为什么更新一条数据会卡住
锁是数据库保证事务隔离的实现手段。InnoDB 里最常用的是行锁和表锁。行锁又分共享锁和排他锁:共享锁允许多个事务同时读同一行,排他锁则是写的时候独占。当两个事务互相持有对方需要的锁,又都无法释放,就会形成死锁。
我复现一个最常见的死锁场景,两个事务都执行相同的两条更新但顺序相反:
sql复制-- 事务A
START TRANSACTION;
UPDATE `user` SET `status` = 0 WHERE `id` = 1;
UPDATE `user` SET `status` = 0 WHERE `id` = 2;
COMMIT;
-- 事务B
START TRANSACTION;
UPDATE `user` SET `status` = 0 WHERE `id` = 2;
UPDATE `user` SET `status` = 0 WHERE `id` = 1;
COMMIT;
当事务 A 锁住 id=1 后去申请 id=2 的锁,而事务 B 锁住 id=2 后去申请 id=1 的锁,两者互相等待,死锁就出现了。InnoDB 会检测死锁并自动回滚其中一个事务,但你的业务逻辑必须考虑“事务被回滚”的可能,否则用户体验会莫名其妙地出错。规避死锁的经验很简单:所有事务尽量按固定顺序访问同一组数据,事务持续时间尽量短,不要在事务中做大批量查询或外部接口调用。
4.4 高频面试题:死锁如何排查
在 MySQL 里排查死锁,第一件事是看 InnoDB 状态。执行:
sql复制SHOW ENGINE INNODB STATUS;
输出日志中会包含最近一次死锁的信息,包括两个事务各自持有哪些锁、等待哪些锁。分析的关键是找到两条 SQL 交叉访问了哪些记录,然后从业务层面统一访问顺序,或者用索引让加锁范围变小。还有一个容易被忽略的点:如果表里没有合适的索引,UPDATE 或 DELETE 会先对整个表加锁,再过滤行,这时候并发度就大幅下降,表面上看起来像死锁,实际上是没有索引引起的锁升级。这也是我把“索引”单独拿出来讲的直接原因。
5. 索引与慢查询:数据量大之后的基本操作“加速器”
5.1 索引的本质:用空间换时间
索引就像一个书的目录,没有它,你要找到一个词就得从第一页翻到最后一页,这对应的是全表扫描;有了目录,你可以直接跳到对应章节。数据库索引最常用的是 B+ 树结构,它的优点是查询、插入、删除的复杂度都在对数级别,而且叶子节点是排好序的双向链表,非常适合范围查询和排序。
不要给每一列都建索引。索引会占用磁盘,会降低写入性能,因为每次 INSERT/UPDATE 都要同步维护索引。通常只在两个地方加索引:第一,WHERE 条件经常用到的过滤列;第二,ORDER BY 或 GROUP BY 涉及排序的列。热搜里有“数据库设计”“慢查询”这些词,说明很多人已经遇到了查询慢的问题,但根本没意识到问题往往出在建表时没有预判查询场景。
5.2 复合索引的最左前缀原则
如果一个表要同时按多个条件查询,最有效的方式是建复合索引。假设我们有一个订单表,经常执行这样的查询:
sql复制SELECT * FROM `order`
WHERE `status` = 1
ORDER BY `created_at` DESC
LIMIT 20;
那么一个合适的复合索引可以这样建:
sql复制CREATE INDEX `idx_status_created` ON `order` (`status`, `created_at`);
复合索引有一个最左前缀原则:查询条件必须从复合索引的最左列开始才会命中索引。比如上面这个索引,能加速 status+created_at 的查询,但如果你只按 created_at 查询,这个索引就用不上。所以设计复合索引时,字段顺序非常关键:区分度高的、等值查询的字段放前面,范围排序类的字段放后面。
我举一个实际的例子。业务系统里有一张订单表,经常按“用户ID + 创建时间”查最近订单,但开发同学把索引建成了 (created_at, user_id)。结果 EXPLAIN 一看,user_id 字段的等值条件没法匹配到索引最左列,查询几乎退化成全表扫描。把字段顺序改成 (user_id, created_at) 后,执行时间从 800 毫秒降到 15 毫秒。这就是最左前缀原则最直观的体现。
5.3 用 EXPLAIN 看一条 SQL 到底走了什么路径
当一条查询很慢,别靠猜,直接看执行计划。MySQL 中在 SQL 前面加 EXPLAIN 即可:
sql复制EXPLAIN SELECT id, username FROM `user` WHERE username = 'lisi';
重点看几个字段:type 表示访问类型,从好到差大致是 system > const > eq_ref > ref > range > index > ALL;key 表示实际命中的索引;rows 表示预估扫描的行数。最怕看到 type=ALL 且 rows 很大,这意味着全表扫描。如果过滤条件的列上有唯一索引,type 一般能到 const 或 ref。
我遇到过一条慢查询,表里 200 万行数据,SQL 很简单,就是按一个业务编号查一条记录,但耗时 3 秒多。EXPLAIN 一看 type=ALL,rows=200 万,因为业务编号列忘了建索引。加上一个普通索引后,查询时间直接降到 10 毫秒。这个案例在技术群里讲了无数次,每次都有人拍大腿:原来索引才是“基本操作”里最值钱的操作。
5.4 覆盖索引与索引失效的完整清单
除了普通索引,覆盖索引也是一个很好的优化点。如果查询的列本身就在索引里,数据库就不需要回表查原始行数据,IO 少很多。比如你的表上有复合索引 (status, created_at),而查询只是 SELECT status, created_at FROM order WHERE status=1,那么直接走覆盖索引即可,性能非常稳定。日常开发里,尽量把高频查询的列加入复合索引,可以顺带减少回表次数。
索引失效的常见情况要形成条件反射。对索引列使用函数或表达式,例如 WHERE DATE(created_at)='2024-01-01';隐式类型转换,例如字符串列和数字比较;LIKE 以 % 开头;复合索引不满足最左前缀;OR 条件中有一个非索引列。遇到这些场景,不用犹豫,索引基本用不上。
5.5 注意:审计功能也会影响索引与锁
热搜词里有一条“数据库开启审计 引起索引争用”,这里多说一句。数据库审计功能会记录所有 SQL 操作,本身会带来额外的磁盘写入和 IO 压力;在并发量高的系统里,审计日志的写入还会与业务数据的写入争抢资源,间接放大锁冲突和索引页竞争。我并不是说不要开审计,而是在开启前要充分评估业务高峰期的负载,必要时把审计日志单独放到不同的磁盘或实例上,避免影响核心业务的基本操作。
