☰
数据库入门实战:从建表、SQL到索引与事务一次讲透
2026/10/22 10:29:55 网站建设 项目流程

写这个“数据库——1”系列的第一篇之前,我犹豫了很久,不知道应该从哪切入。数据库这个领域,往外延伸是分布式、云原生、大数据,往里深挖是存储引擎、缓冲区、锁和事务,任何一个方向都能单独写出一本书。作为系列第一篇,我决定务实一点,把最常用的地基部分讲透:数据库到底解决什么问题、表结构怎么设计、SQL怎么上手、索引是怎么回事、事务为什么不能省。我默认你至少写过一点SQL,但不需要你懂底层原理。这篇读完之后,你自己去建一张表、写几条查询、分析一次慢SQL,心里应该就有底了。

1. 数据库到底解决什么问题

1.1 为什么不能一直用文件存数据

我见过不少本来应该用数据库的项目,最后死在文件读写上。印象最深的是某内部工具系统,最开始用本地文件存用户配置,单机、个人使用,一切正常。后来功能多了,多台终端同时读写同一个文件,开始出现配置丢失和写一半的问题。再后来数据量涨到几千条,程序启动时要一次性加载所有内容,操作一次要等好几秒。项目最后不得不把存储层整个重写,迁移到数据库。

很多人对数据库的第一印象就是“一个存数据的地方”。这个说法没问题,但不够准确。文件也能存数据,表格工具也能存数据,数据库之所以成为几乎所有系统的标配,是因为它集中解决了三件事:数据组织方式、查询能力、并发与一致性保证。文件方案听起来简单,一旦并发、体量、权限这些问题叠加在一起,自研存储的维护成本会远远超过你的想象。

数据库的核心价值不是“存得下”,而是“取得快、改得准、并发时不出错”。理解这一点,你才能理解后面所有的设计选择。

1.2 关系型和非关系型怎么选

工作里最常见的两类数据库,一类是以MySQL、PostgreSQL为代表的关系型数据库,一类是以Redis、MongoDB为代表的非关系型数据库。很多人选型的时候靠跟风,看到别人用MongoDB自己也用,看到别人上Redis缓存自己也上,最后发现核心业务在非关系型数据库里硬着头皮搞事务,开发效率反而更低。

维度关系型(MySQL / PostgreSQL)非关系型(Redis / MongoDB)
数据模型二维表,强约束结构键值、文档、列族、图
典型场景订单、账户、库存、ERP缓存、日志、画像、消息
事务能力ACID支持完善偏弱,部分弱事务
一致性强一致多为最终一致
扩展方式垂直扩展为主,分布式复杂天然支持水平扩展

我的选型经验只有一句话:先问业务需不需要事务和强一致。订单、账户、支付这类场景,哪怕逻辑再简单,也老老实实用关系型;用户行为日志、爬取数据、消息流水这类弱约束、高增长的场景,再考虑文档型或键值型。记住,数据库选型没有绝对的对错,但用错场景的代价往往在半年之后才会显现,那时候再迁移就很痛了。

2. 建表之前必须想清楚的三件事

写代码最忌讳键盘一敲就把表建了。建表是一个反悔成本极高的操作,生产环境里改字段类型、加非空约束,都可能导致锁表和长时间不可用。所以我每次建表之前,都会强制自己过三个问题:主键用什么、字段类型怎么定、索引要不要先规划。

2.1 主键选型:自增ID还是UUID

主键可能是整个表里最基础也最容易忽略的决策。很多系统直接用一个自增整数做主键,简单、查询快、索引紧凑。但这里有一个隐藏问题:分布式环境下,多个写入节点各自生成自增ID,冲突几乎不可避免。这时候需要全局唯一的ID方案,最常见的两个替代品是UUID和雪花ID。

方案优点缺点
自增BIGINT插入性能好、索引占用小多节点写入会冲突
UUID字符串生成不依赖数据库、全局唯一36字符、占用大、无序写入导致页分裂
雪花ID8字节整数、趋势递增、全局唯一需要引入ID生成服务

我的建议很简单:单机、内部系统直接自增BIGINT,够用且省心;需要分布式唯一ID时,不要图省事用UUID字符串做主键,优先考虑雪花算法。字符串主键会让二级索引空间膨胀,写放大明显,业务量大了之后你会非常难受。

2.2 字段类型:长度、精度和默认值

字段类型通常是新手的重灾区。手机号用VARCHAR(20),金额用FLOAT,时间用字符串,这些都是我实际看到过的写法。手机号的问题是号码可能带前缀、短号,VARCHAR(11)不一定够。金额的问题更严重,FLOAT是浮点数,二进制无法精确表示十进制小数,存钱会出现0.1加0.2不等于0.3的情况,计费系统一旦用浮点数,迟早对不上账。

字段设计有几个基本原则。金额用DECIMAL,并且明确精度,比如DECIMAL(10,2);时间用DATETIME或TIMESTAMP,别用字符串;布尔值在MySQL里用TINYINT(1),PostgreSQL原生BOOLEAN;字符串长度不是越大越好,VARCHAR(255)和VARCHAR(65535)的存储开销、索引支持完全不一样。每次写DDL的时候多花三十秒想想类型选型,后期能省下几百倍的调试时间。

2.3 索引的初步设计:冗余和权衡

很多开发者建表时不建任何索引,等线上慢查询爆发了再补,结果发现数据已经几百万行,加索引要锁大半天。建表时就该想清楚,哪些列会成为查询条件。一个基本参考:出现在WHERE、JOIN、ORDER BY里的字段,都是加索引的候选对象。

但这里有一个反方向误区:索引不是越多越好。每个索引都要占磁盘空间,每次写入都要同步维护所有索引,索引过多会严重拖慢写操作。我见过一张表建了二十几个索引的表,写一次要维护几十个B+树,写入性能一塌糊涂。正确做法是宁可少建两个,也要把高频查询路径上的索引建对。不常用的索引,留着就是负担。

3. SQL实操:从建库到联表查询一次讲透

SQL语法本身不复杂,但真正写得好的人不多。问题往往出在只会写单表查询,一遇到统计、联表就逻辑混乱。这一节把日常最高频的SQL操作完整过一遍,以MySQL语法为主,原理同样适用于其它关系型数据库。

3.1 建库建表:DDL的基本姿势

先看一个建表示例:

CREATE DATABASE IF NOT EXISTS shop DEFAULT CHARACTER SET utf8mb4; CREATE TABLE `user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(64) NOT NULL, `email` VARCHAR(128) NOT NULL, `status` TINYINT NOT NULL DEFAULT 1, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有几个容易被忽略的细节。字符集用utf8mb4而不是utf8,因为MySQL的utf8最多存3字节,遇到emoji会直接报错。ID用BIGINT UNSIGNED,而不是INT,因为INT最大只能到21亿,业务量稍大就满了。NOT NULL和DEFAULT要显式声明,避免隐式默认值带来的脏数据。每个表都建议带一个创建时间字段,排查线上数据问题时几乎是必备信息。

3.2 插入更新删除:DML的注意事项

DML操作最大的坑永远是那句忘写WHERE的UPDATE或DELETE。我工作以来听过太多次“我把整个表的数据都更新了”的现场事故。写UPDATE和DELETE的第一个习惯,就是先写WHERE条件,确认条件能选中预期的行,再考虑执行。开发环境测试时,先跑一遍SELECT验证条件范围,这是最便宜的安全措施。

插入数据时,批量插入效率远高于逐条插入。一条INSERT语句带多组VALUES,比循环调用几千次单条INSERT快一个数量级。但批量插入也不要一次插太多,我一般控制在500到1000行一批。更新多条记录时,尽量包在一个事务里,要么全部成功,要么全部回滚,避免只更新了一半导致的脏数据。

3.3 查询和聚合:WHERE、GROUP BY、HAVING

查询语句里有一个执行顺序经常被搞混:FROM先执行,然后WHERE,然后GROUP BY,然后HAVING,最后才是SELECT、ORDER BY和LIMIT。理解这个顺序,你就能明白为什么不能在WHERE里使用SELECT里定义的别名,也能明白HAVING和WHERE的本质区别。

WHERE是在分组之前过滤原始行,HAVING是在分组之后过滤聚合结果。举个订单统计的例子:想统计每个用户的订单总额,但只保留下单次数大于10次的用户,正确写法是先WHERE过滤掉无效订单,再GROUP BY user_id,最后HAVING COUNT(*) > 10。老手写这类SQL几乎不用想,新手最容易犯的错就是用错了WHERE和HAVING的位置,导致统计结果对不上账。

关于COUNT,提一个高频疑问:COUNT()和COUNT(1)性能基本相同,它们在多数引擎里都是统计行数;但COUNT(column)只统计该列非NULL的行。所以如果你想知道一张表有多少条记录,写COUNT()就好,不要去写COUNT(主键)然后期待性能更好,现代优化器早就把这个问题处理掉了。

3.4 联表查询:INNER JOIN和LEFT JOIN的区别

联表查询的核心是搞清楚两种JOIN的语义。INNER JOIN只返回两边匹配上的行,左表存在但右表没有对应记录,这些行不会出现。LEFT JOIN则保留左表的全部行,右表没有匹配时用NULL填充。这个区别,写一次性报表时可能无感,但在业务SQL里一旦用错,统计数字差了十倍你都发现不了。

LEFTOUTER JOIN里有一个更隐蔽的坑:把右表的过滤条件写在WHERE中,会悄悄改变连接语义。比如左连接出所有用户和他们的订单,然后WHERE o.status = 'paid',这时没订单的用户或订单没支付的用户就被过滤掉了,LEFT JOIN实际变成了INNER JOIN。右表的过滤条件应该写在ON子句里,除非你明确就是要过滤。这个细节我在代码评审时反复讲过,每次都能救到人。

4. 索引才是查询性能的命根子

查询慢,九成是索引问题。这一节不讲太高深的原理,只把最核心的B+树概念和最左前缀原则讲清楚。

4.1 为什么索引能大幅提速

用字典打比方:没有索引的查询等于从第一页开始逐页翻字典,找什么都是全表扫描;索引相当于拼音目录,先定位到字所在的大区,再精确到具体页。数据库最常用的索引结构是B+树,叶子节点保存索引值,并且叶子节点之间形成有序链表,这样既能等值定位,也能高效做范围查询。

这里要区分两类索引。主键索引在InnoDB里是聚簇索引,叶子节点直接保存整行数据,查询走主键能一次到位;二级索引的叶子节点保存的是主键值,查询时先找到主键,再回表查整行数据,这个过程叫回表。理解了回表,你就明白为什么“不要用SELECT *”在索引场景下是有道理的:你需要什么列,就用覆盖索引把列带出来,避免多余的回表。

4.2 复合索引与最左前缀原则

复合索引是最实用也最容易出错的索引类型。假设表上有索引(a, b, c),这个索引真正能高效支撑的查询组合是:a单独、a和b一起、a和b和c一起。如果查询条件里没有a,只有b或c,那么这个复合索引基本派不上用场。这就是最左前缀原则。

举个例子。订单表上有索引(user_id, status),查询WHERE user_id = 123很好用,WHERE user_id = 123 AND status = 'paid'也很好用,但单独的WHERE status = 'paid'走不了这个索引。所以设计复合索引时,要把区分度高、最常出现在WHERE里的字段放最前面,这是索引设计里优先级最高的决策之一。

4.3 如何用EXPLAIN定位索引失效

任何SQL写完之后,都应该养成跑一次EXPLAIN的习惯。我见过太多的线上慢查询,EXPLAIN一跑,问题立刻现形。重点看几个字段:

EXPLAIN SELECT user_id, total_amount FROM orders WHERE user_id = 1024;

type字段非常直观,从好到差大致是:system、const、eq_ref、ref、range、index、ALL。看到ALL就是全表扫描,基本可以断定这条SQL要出事了。key字段显示实际用的索引,rows字段是优化器预估扫描行数,Extra里出现Using filesort和Using temporary通常意味着排序和临时表,这些都是性能隐患。

实际排查过的一个案例:某报表查询从两百毫秒恶化到八秒,EXPLAIN之后发现type=ALL,rows预估扫了两百多万行,Extra还带了Using filesort。问题的根源是查询里对索引列做了函数操作,导致索引失效。改写SQL让函数操作移到常量的那一侧,再配合一个合适的复合索引,查询恢复到了三百毫秒以内。

5. 事务与并发:数据正确性的最后防线

很多业务逻辑出问题,不是SQL写错,而是事务用的不对。事务是数据库和文件方案最本质的分水岭,理解它,你才算真正用上了数据库的能力。

5.1 一次扣款聊聊ACID

用最经典的转账场景演示。账户A给账户B转100元,数据库要做两步:A扣100,B加100。如果这两步之间发生异常,A扣了钱B没收到,业务方马上就炸了。事务要保证的就是这两个操作要么都成功,要么都不发生。这就是原子性。

事务的四个特性是原子性、一致性、隔离性、持久性。一致性说的是事务执行前后数据都处于合法状态;隔离性是多个事务并发时不互相干扰;持久性是一旦提交,数据不会因为宕机而丢失。初学者不需要把术语背得滚瓜烂熟,但需要记住:只要涉及多步写入操作并且数据之间有依赖,就必须放进同一个事务里处理。

5.2 隔离级别怎么理解

并发事务会带来三个经典问题:脏读、不可重复读、幻读。脏读是读到别的事务未提交的数据;不可重复读是同一个事务里两次读同一行,结果不一样;幻读是同一个事务里两次查询,结果集凭空多出几行。为了应对这些问题,SQL标准定义了四级隔离级别。

隔离级别脏读不可重复读幻读
读未提交可能可能可能
读已提交不可能可能可能
可重复读不可能不可能可能(InnoDB实际可防)
串行化不可能不可能不可能

MySQL默认的隔离级别是可重复读,PostgreSQL默认是读已提交。大部分互联网业务用默认级别就好。隔离级别越高,并发性能越差,串行化因为加锁太狠,几乎只在极端一致性场景中使用。选级别的时候考虑清楚,不要让所有事务都跑到串行化,否则并发能力会断崖式下降。

5.3 死锁与锁等待的处理经验

死锁两个经典场景。场景一:连接一先锁A再锁B,连接二先锁B再锁A,两边互相等待,谁都不让。场景二:批量更新时,两个事务更新同一批数据但顺序不一致。排查死锁最直接的工具是看引擎状态,MySQL里执行SHOW ENGINE INNODB STATUS,能把最近一次死锁涉及的事务和锁全列出来。

我踩过的一个真实坑是批量更新订单状态时,代码里用了ORDER BY order_id本身没有问题,但另一个任务更新时排序字段不一致,两个任务并发跑,偶发死锁。当时的解决方法是统一所有批量更新的排序顺序,同时把每个事务处理的记录数控制在较小范围,让事务快速提交,锁等待概率就大幅下降了。经验就是:锁这东西,等得越久越容易出事,事务越短、加锁顺序越统一,死锁概率越低。

6. 常见问题与排查技巧实录

这一节是我平时排查问题的记录,遇到了好的排查思路,我会顺手记下来。这里放几个出现频率最高的场景。

6.1 线上慢查询排查清单

慢查询排查的顺序基本固定:先确认慢在哪里,再通过执行计划找原因,最后对症下药。第一步开启慢查询日志并设置阈值,例如超过两秒的SQL全部记录;第二步把慢SQL拿出来跑EXPLAIN,看type、key、rows和Extra;第三部根据结论加索引或改写SQL。

排查时有一个容易忽略的点:SQL本身不慢,但并发量上来之后整体变慢,这往往不是索引问题,而是数据库连接和CPU被打满,慢查询只是现象不是原因。这类问题需要顺着连接数、线程数、锁等待时间一层层往上查,千万别一头扎进SQL优化里出不来。

6.2 连接池配置的坑

应用连接数据库,几乎都通过连接池管理,而不是每次请求都新建连接。连接池配置最大的坑是把最大连接数调得巨大,表面上是为了冗余,实际效果是数据库连接数被打爆,数据库进程直接拒绝服务。连接是宝贵的资源,每个连接背后都有线程、内存和可能的锁状态。

经验值:连接池上限参考机器CPU核心数,通常核心数乘以2左右起步,再根据实际压测结果微调。另外,事务内的数据库操作要尽快释放,尤其避免在事务里做远程调用或长时间等待,一个长事务占住连接和锁,后面的请求全部排队,事故就是这么来的。连接池代码要用完了记得归还,别让资源泄漏积累成故障。

6.3 误删数据的应急处理

没有备份的误删,严格来说是事故,不是故障。真遇到了DELETE忘了WHERE或者UPDATE全表更新,正确的处理顺序是:先停止应用的写入,避免二次破坏,然后根据备份和二进制日志恢复数据。在MySQL里,开启binlog后可以用mysqlbinlog解析日志,定位到误操作语句之前的位置,再把后续数据重放回去。

这个流程平时看着平淡,真出事时非常考验手速和心态。所以我的建议一直是:生产环境必须开启binlog,必须做定时备份,而且备份要定期做恢复演练,确认备份文件真的能恢复。没做过恢复演练的备份,等于没有备份。趁着还没出事,把这件事当成线上基本盘里的头等大事来抓。

从建表到索引,从SQL到事务,这一篇覆盖的是数据库日常使用中最高频的几块内容。我个人在实际操作中的体会是,数据库的知识光靠看永远记不牢,必须自己建一张表、填几万行数据、刻意写几条慢SQL,再用EXPLAIN亲手把问题揪出来,这个过程走一遍,比看十篇教程都有用。这个系列之后我还会继续写,下一篇大概率会专门深入锁与隔离级别在并发场景下的实际操作,也可以聊索引底层结构和B+树的更多细节。你可以按自己的需要挑着看。最后分享一个小习惯:每次建表之前先画一遍逻辑模型,写完SQL先跑一遍EXPLAIN,把这两个动作变成肌肉记忆,至少能帮你避开一半的线上问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询