写 SQL 入门系列到这一篇,前面几章基本都在围着 SELECT 转圈。不少读者后台问过我同样的问题:表是怎么来的?结构是谁定义的?为什么我往表里插数据老是报错?还有,线上表怎么快速复制一份到测试库?这些问题其实都属于同一个大主题——表操作。这篇我就把三板斧一次讲清楚:定义表(CREATE TABLE)、插入数据(INSERT)、复制表(结构复制、数据复制都算)。这是从“只会查”迈向“真正写库”的分水岭,也是后面理解事务、索引优化、表分区这些高级话题的地基。不管你在用 MySQL、SQL Server 还是 PostgreSQL,底层逻辑完全一样,差异只在写法细节,我会把这些差异点也一并标出来。
1. 先把思路捋清楚:表操作到底在解决什么问题
1.1 表就是 SQL 世界的“容器”,容器没选好后面全是坑
我一直喜欢把数据库表比作搬家用的收纳箱。CREATE TABLE 是去商店选箱子,决定箱子的形状、大小、隔层——对应到数据库,就是有哪些字段、每个字段存什么类型、哪些字段不能为空、哪一列是唯一标识。INSERT 是往箱子里放东西,对应到数据库就是把真实业务数据写进表。复制表则更像你看到邻居家的收纳方案特别好,干脆照着人家箱子的尺寸、隔层设计,甚至把里面的东西一起搬来一套。
这三件事的顺序不能乱。你得先有箱子才能放东西,先有表结构才能插数据,复制也得先确定要复制的源表结构长什么样。很多新手一上来就 INSERT,报错“Unknown column”时才回头去查表结构,这就是流程反了。先想清楚定义,再动手写数据,后面几乎不会遇到这类低级问题。
1.2 设计表之前,先回答四个问题
很多初学者拿到需求就直接开敲CREATE TABLE,字段名随手起,类型凭感觉选。等你真跑起业务,会发现字段不够用、类型存不下、过滤条件查不动,改表结构比刚开始设计多花好几倍时间。我一般会先逼自己回答四个问题,答完再写 DDL:
- 这张表记录的是哪个业务对象?是用户、订单、商品,还是日志?对象决定了表名和核心字段。
- 哪些字段是业务必需的,哪些是可以后面再加的?核心字段第一次建表就放进,扩展字段先留注释,不要一上来堆 30 列。
- 哪些字段会频繁变化?比如用户积分、订单状态,这类字段要单独考虑索引和更新频率,不能和固定信息搅在一起。
- 主要查询场景是什么?是查“某个用户最近 10 笔订单”,还是查“某天全站的订单量”?查询方式决定你要不要加索引、要不要冗余字段。
举例来说,要建一个订单表,我会先列出订单号、用户 ID、商品 ID、数量、单价、总金额、订单状态、创建时间、更新时间这些核心字段,再考虑是否需要支付时间、收货地址、备注等信息。等你把字段想清楚,建表语句基本就八九不离十了。
2. 建表前的“地基”:数据类型与约束
2.1 数据类型选型:这一步选错,后面全乱
数据类型是表定义里最基础也最容易忽视的一环。很多人图省事,不管什么字段都上 VARCHAR,结果日期不能比较、金额出现精度丢失、状态字段大小写混乱。我建议你对常用类型心里有数,哪怕记不住关键字,也要知道“这个类型解决什么问题”。
先看一张主流数据库对照表:
| 用途 | MySQL | SQL Server | PostgreSQL | 说明 |
|---|---|---|---|---|
| 整数(一般) | INT | INT | INTEGER | 用户 ID、数量等常规整数 |
| 整数(大范围) | BIGINT | BIGINT | BIGINT | 雪花 ID、流水号,别用 INT 存,会溢出 |
| 小数(精确) | DECIMAL(10,2) | DECIMAL(10,2) | NUMERIC(10,2) | 金额必须用它,不能用 FLOAT/DOUBLE |
| 定长字符串 | CHAR(n) | CHAR(n) | CHAR(n) | 长度基本不变,如国家代码、性别 |
| 变长字符串 | VARCHAR(n) | VARCHAR(n) | VARCHAR(n) | 用户名、备注等,n 是字符数不是字节数 |
| 大文本 | TEXT / LONGTEXT | NVARCHAR(MAX) | TEXT | 长文章,不要拿它当普通字段滥用 |
| 日期时间 | DATETIME / TIMESTAMP | DATETIME2 | TIMESTAMP | 订单时间、创建时间 |
这里有三条我踩过坑的经验:
第一,金额类字段永远用 DECIMAL,别用 FLOAT。FLOAT 是近似存储,0.1 + 0.2 这种简单算账都会出精度问题,做财务系统更是红线。
第二,VARCHAR(255) 的 255 是字符数,不是字节数。中文一个字符占 3 个字节(UTF-8 下),如果你用 VARCHAR(50) 存 50 个汉字完全没有问题,但如果你按字节理解,很容易把长度设得过大或过小。过小会导致插入中文报“Data too long”。
第三,TIMESTAMP 有时区概念,DATETIME 没有。跨时区业务、全球部署的数据库,优先用 TIMESTAMP(或 PostgreSQL 的 TIMESTAMPTZ),否则同一条记录在不同时区的人看到的时间不一致。
2.2 约束是表规则的“说明书”,但也不是越多越好
约束是 CREATE TABLE 里除了字段类型之外最重要的语法块。它解决的是“数据库层面怎么保证数据合法”的问题,不依赖任何应用代码。
主键(PRIMARY KEY):唯一标识一行,最核心的约束。我强烈建议用自增整数或雪花 ID,不要用业务字段做主键。比如“身份证号”看起来唯一,但用户可能修改、可能没填、可能录入错误,一旦做主键后面想改都难。自增列简单、性能好、永不重复。
外键(FOREIGN KEY):表示表和表的关联关系。学习阶段一定要理解外键的作用:它能防止你插入一个不存在的用户 ID,保证引用完整性。真实生产环境里,很多团队为了写入性能会去掉外键,把校验放在应用层,但那是分布式拆库后的妥协,不是新手该学的偷懒方式。
唯一约束(UNIQUE):保证某列的取值不重复,比如用户名、手机号。它和主键的区别是,一张表只能有一个主键,但可以有多个唯一约束,而且唯一约束允许 NULL(MySQL 里多个 NULL 算不重复)。
非空约束(NOT NULL):业务必需字段必须加。常见反例是建表时不设 NOT NULL,结果插入数据时漏字段,全变 NULL,统计时还要到处IS NULL。
默认值(DEFAULT):没传值时的兜底。比如created_at DATETIME DEFAULT CURRENT_TIMESTAMP,插入时不用管这列,数据库自动填当前时间。
CHECK 约束:MySQL 8.0.16 之后才真正生效,SQL Server、PostgreSQL 一直支持。比如年龄字段 CHECK(age >= 0),从源头挡住不合法的数据写进去。
注意,约束不是越多越好。每个约束都有校验成本,写入越频繁,约束过多对性能影响越大。我见过一张日志表加了 6 个外键,结果每秒写几千条时 MySQL CPU 直接飙红。正确的做法是:核心业务表该加的约束一定加,高频写入的无状态流水表只保留主键和必要非空,把外键这类重校验放给应用层。
3. 表定义实操一条龙:手把手写出可用的 CREATE TABLE
3.1 一个完整建表语句是怎么写出来的
我拿最典型的用户表举例,先写 MySQL 版本:
CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID,自增主键', `username` VARCHAR(50) NOT NULL COMMENT '用户名,登录用', `password_hash` CHAR(64) NOT NULL COMMENT '密码哈希值,绝不存明文', `email` VARCHAR(100) DEFAULT NULL 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户基础表';逐行解释几个关键点:
INT UNSIGNED:无符号整数,ID 不会为负数,等于把可用范围翻了一倍。AUTO_INCREMENT:自增列,每次插入自动加 1,省得手动生成 ID。CHAR(64)存密码哈希:SHA-256 的十六进制串固定 64 位,定长类型性能优于 VARCHAR。DEFAULT CURRENT_TIMESTAMP:创建时间由数据库托底,不依赖应用传参。ON UPDATE CURRENT_TIMESTAMP:这行数据被 UPDATE 时自动刷新更新时间,超级实用。很多新手的updated_at永远不变,就是建表时少了这句。UNIQUE KEY uk_username:用户名唯一,同时自带索引,登录按用户名查时能走索引。
再看 SQL Server 版本,思路一样,语法细节有差异:
CREATE TABLE [dbo].[User] ( [Id] INT IDENTITY(1,1) NOT NULL, [Username] NVARCHAR(50) NOT NULL, [PasswordHash] CHAR(64) NOT NULL, [Email] NVARCHAR(100) NULL, [Status] TINYINT NOT NULL CONSTRAINT DF_User_Status DEFAULT (1), [CreatedAt] DATETIME2 NOT NULL CONSTRAINT DF_User_CreatedAt DEFAULT (SYSDATETIME()), [UpdatedAt] DATETIME2 NOT NULL CONSTRAINT DF_User_UpdatedAt DEFAULT (SYSDATETIME()), CONSTRAINT PK_User PRIMARY KEY ([Id]), CONSTRAINT UK_User_Username UNIQUE ([Username]) );SQL Server 用的是IDENTITY(1,1)而不是AUTO_INCREMENT,自增的起点和步长可以在括号里直接控制,这点比 MySQL 灵活。NVARCHAR是 Unicode 变长字符串,SQL Server 里建议优先用它存中文,避免乱码。约束都用CONSTRAINT 约束名显式命名,方便后面删改和维护。
3.2 建表时最容易踩的 5 个坑
第一,表名或字段名撞了保留字。user、order、group在部分数据库里是保留字。MySQL 里可以用反引号包起来,SQL Server 用方括号,但最省事的办法是起名时避开,比如sys_user、t_order。
第二,字符集没指定。MySQL 5.7 及以前的默认字符集是 latin1,直接建中文表容易乱码。建表语句里明确写DEFAULT CHARSET=utf8mb4,宁可多打几个字,别指望全局配置兜底。
第三,VARCHAR 长度理解错。前面说过,括号里的数字是字符数,不是字节。一个 VARCHAR(10) 在 utf8mb4 下最多能存 10 个汉字,不是 10 个字节。设计长度时要按“最多有多少字符”算,不要按“多少字节”算。
第四,忘记加字段注释。COMMENT这东西看似不起眼,三个月后回来看表结构,没有注释的字段你根本想不起来它是干嘛的。团队协作时,字段注释就是最低成本的文档。SQL Server 没有 COMMENT 语法,但可以用扩展属性或者建表后用sp_addextendedproperty加说明,麻烦归麻烦,还是要养成习惯。
第五,表名大小写问题。MySQL 在 Linux 上默认区分表名大小写,在 Windows 上默认不区分,这就导致开发环境好好的 SQL,部署到 Linux 服务器上就报“Table doesn't exist”。解决办法是统一用小写表名,多个单词用下划线分隔,并且lower_case_table_names=1这种配置要团队统一。
4. 插入数据:INSERT 的三种主流姿势
4.1 单行插入与多行批量插入,效率差了一个量级
表定义好之后,第一件事是往里灌数据。最基础的单行插入长这样:
INSERT INTO user (username, password_hash, email, status) VALUES ('zhangsan', 'a1b2c3...', 'zhangsan@example.com', 1);注意,我没有写id和created_at,它们分别由自增和默认值处理。新手最容易犯的错是把所有字段都往 INSERT 里塞,包括自增主键和自动时间,结果要么报错,要么插进去的时间和预期不符。
多行插入是 MySQL 和 PostgreSQL 非常好用的一个特性:
INSERT INTO user (username, password_hash, email, status) VALUES ('zhangsan', 'a1b2c3...', 'zhangsan@example.com', 1), ('lisi', 'd4e5f6...', 'lisi@example.com', 1), ('wangwu', 'g7h8i9...', NULL, 0);一批 1000 行和一行一行插 1000 次,效率差别非常明显。前者只发起一次网络请求,后者要发起 1000 次,每次都包含 SQL 解析、权限校验、事务开销。我自己实测过,本地 MySQL 插入 10 万行,单条循环插入要几十秒,多行批量插入只要 1 秒左右。SQL Server 从 2008 开始也支持这种VALUES多行写法,但要注意批量大小,我建议不要超过 1000 行一批,超过就拆,不然报文太大反而卡。
还有个小技巧,如果你插入的数据来自一个已经存在的表,完全不用手动敲 VALUES:
INSERT INTO user (username, password_hash, email, status) SELECT real_name, md5_password, email, 1 FROM temp_user WHERE status = 1;这是 INSERT 的第三种主流姿势,也是后面讲表复制的前置技能。每次做这种操作,我先 SELECT 验证一下列数和类型,再改成 INSERT,能省下大量调试时间。
4.2 插入时常见的 4 个“坑”
主键冲突是最常见的。重复插入同一 ID 时,MySQL 报Duplicate entry '1' for key 'PRIMARY'。如果你希望“存在就更新,不存在才插入”,MySQL 可以用ON DUPLICATE KEY UPDATE,SQL Server 可以用MERGE,后面实战再用吧,入门阶段先理解主键冲突的本质是“唯一标识重复”。
第二个坑是外键校验失败。插入引用表数据时,外键列的值必须在被引用表里存在,否则直接报外键约束错误。这是个保护机制,提示你业务数据有依赖问题,不要为了不报错就去删外键。
第三个坑是中文乱码。表现是插入后查出来是???或一堆乱码。原因通常是三个层级的字符集不一致:客户端连接字符集、数据库/表字符集、字段字符集。你用 Navicat 看到乱码,先检查连接的字符集设置,再把表字符集统一为 utf8mb4,基本能解决九成问题。
第四个坑是字符串里的引号。往 SQL 里拼业务文本时,如果文本里带单引号,不处理就会语法错误。这不是 SQL 问题,是拼字符串的姿势不对,正确做法是用参数化查询,让数据库驱动帮你处理转义。顺便说一句,这也是防止 SQL 注入的根本手段,后面安全部分细讲。
5. 表复制实操:从只复制结构到完整克隆
复制表是开发里频率很高的操作。做测试要造数据、开新功能要备份旧表、跨环境要迁移结构,全都能用上。但要分清楚,复制表有两个维度:是只要结构,还是结构+数据一起要;是复制到同库,还是跨库跨服务器。
5.1 只要结构不要数据:CREATE TABLE ... LIKE
MySQL 提供了最直接的语法:
CREATE TABLE user_bak LIKE user;这条语句会完整复制user的字段定义、默认值、自增属性、索引,甚至 COMMENT 都会带过来。唯一不复制的是外键,这是 MySQL 官方文档明确写的:外键不会复制到新表。所以复制完检查一下是否需要手动补外键。
SQL Server 没有 LIKE 这种语法。它的常见做法是:
SELECT TOP 0 * INTO user_bak FROM [user];SELECT ... INTO创建新表并把查询结果插入,TOP 0表示不取任何行,结果就是只复制结构。但它有个坑:它不会复制主键、默认值、索引、约束。新表只是个“长得像”的空表,你得手动ALTER TABLE补主键、补约束。PostgreSQL 对应的是:
CREATE TABLE user_bak (LIKE user INCLUDING ALL);INCLUDING ALL会把默认值、约束、索引统统带上,是最省事的一种。
5.2 结构与数据一起复制:CREATE TABLE AS SELECT
这是我最常用的方式。MySQL 和 PostgreSQL 都是:
CREATE TABLE user_bak AS SELECT * FROM user;SQL Server 用:
SELECT * INTO user_bak FROM user;这条命令执行完,一张包含全部数据的新表就诞生了。效率非常高,因为它是数据库内部操作,不经过应用层一条条读再一条条写。复制百万级数据的表,也就几秒到几十秒的事。
但必须强调一个重点:CTAS 和 SELECT INTO 复制出来的表,本质上只是“数据+裸结构”,主键、自增、索引、默认值、外键这些统统不复制。举个例子,源表id是自增主键,复制出来的新表id只是一个普通 INT 列,不能自增。你需要手动补救:
ALTER TABLE user_bak MODIFY COLUMN id INT UNSIGNED NOT NULL AUTO_INCREMENT, ADD PRIMARY KEY (id); ALTER TABLE user_bak ADD UNIQUE KEY uk_username (username);所以我的习惯是:先复制一张表但不着急用,先把约束、索引补齐了,再对外提供服务。否则应用层还不知道怎么回事,就往非自增的主键列插 NULL,直接报错。
5.3 跨库、跨服务器复制怎么搞
同一个 MySQL 实例里跨库复制最简单:
CREATE TABLE other_db.user_bak AS SELECT * FROM current_db.user;只要账号有权限,直接带库名前缀就行。
跨服务器复制,我常用的手段是mysqldump导出再导入:
mysqldump -uroot -p --single-transaction --default-character-set=utf8mb4 test_db user > user.sql mysql -uroot -p -h目标IP test_db < user.sql--single-transaction是为了 InnoDB 导数据时保持一致快照,避免导出过程中数据变化导致不一致。SQL Server 的跨服务器方案,要么用 SSMS 的导出数据向导,要么配置链接服务器后直接用SELECT INTO,但链接服务器跨网络拉数据时性能一般,大数据量还是走导出文件更稳。
无论哪种方式,导出导入后我都一定会做一件事:对比源表和目标表的行数。数据量差一行都不行,这是检验复制是否完整的底线。可以写一句快速对比:
SELECT COUNT(*) FROM 源库.user; SELECT COUNT(*) FROM 目标库.user_bak;5.4 大表复制的性能优化与一致性
复制千万级甚至亿级的大表,再简单的CREATE TABLE AS SELECT也不能随便跑。两个风险:一是执行时间长,会拖垮源库性能;二是中途失败,留下一张残缺的半成品表。
我的做法是分批处理。按自增主键范围切片:
CREATE TABLE user_part1 AS SELECT * FROM user WHERE id BETWEEN 1 AND 1000000; CREATE TABLE user_part2 AS SELECT * FROM user WHERE id BETWEEN 1000001 AND 2000000; -- 最后把各分区合并或直接按分区使用第二个技巧是“先禁索引,复制完再建”。如果目标表是手工CREATE TABLE建好的,可以先不建索引,数据插完后再统一加索引,比边插边维护索引快得多。这就好比你往书架里塞书,如果每塞一本都要按字母顺序重新排列,肯定慢;先把书胡乱堆满,再花一次时间统一排序,反而快。
一致性方面,如果线上有业务还在写源表,单独跑一条CREATE TABLE AS SELECT无法保证业务一致性。更稳的做法是先复制到临时表,确认无误后使用原子切换:
RENAME TABLE user TO user_old, user_bak TO user;MySQL 的RENAME TABLE是原子操作,要么全部成功,要么全部失败,不会出现切换一半的中间状态。线上结构变更我基本都靠这招。
6. 实操中的问题排查与安全提醒
6.1 高频报错速查表
写表操作这个阶段,新手高频遇到的错误就那么几个,我整理成一个速查表,报错信息里出现类似字样直接对照解决方案。
| 错误类型 | 典型报错 | 原因 | 解决方案 |
|---|---|---|---|
| 字段不存在 | Unknown column 'xxx' in 'field list' | INSERT 的列名和表结构不一致 | DESC 表名查看真实字段名 |
| 主键冲突 | Duplicate entry '1' for key 'PRIMARY' | 插入的主键已存在 | 改业务主键,或用 ON DUPLICATE KEY UPDATE |
| 非空约束违反 | Column 'username' cannot be null | NOT NULL 字段传了 NULL | 检查插入数据,补齐必填字段 |
| 外键失败 | Cannot add or update a child row | 外键列的值在被引用表不存在 | 先查被引用表确认数据存在 |
| 字符集乱码 | 插入后查出来是 ??? | 客户端/表/字段字符集不一致 | 统一 utf8mb4,检查连接字符集 |
| 数据过长 | Data too long for column 'email' | 插入内容超过 VARCHAR 长度 | 加长长度或截断数据 |
6.2 慢 SQL 优化的入门三板斧
热搜词里一直有“慢sql优化”,前面讲的INSERT ... SELECT和CREATE TABLE AS SELECT,在数据量大时也可能慢。当你发现复制操作卡到怀疑人生,第一反应不是加服务器内存,而是用 EXPLAIN 看执行计划。
EXPLAIN SELECT * FROM user WHERE username = 'zhangsan';看三个关键列:type是不是ALL(全表扫)、key有没有走到索引、rows预估扫描多少行。如果type是ALL,大概率是没索引,或者查询条件没有包含索引前缀列。给查询条件加上匹配的索引,性能往往立竿见影。
复制大表如果本身就慢,不要把锅全甩给 SQL。先看源表有没有大事务在写,再看磁盘负载是不是打满了,最后考虑分批方案。慢 SQL 排查是个系统工程,但入门阶段记住“SELECT 慢先看索引,INSERT 慢先看表数量和约束,复制慢先看数据量级”这个方向,基本不会跑偏。
6.3 插入场景的 SQL 注入安全提醒
表操作和安全直接相关的点在插入。很多初学者写代码时会把用户输入直接拼进 INSERT:
# 反面教材,千万别学 cursor.execute(f"INSERT INTO user (username) VALUES ('{username}')")如果用户在输入框里填了'); DROP TABLE user; --,拼出来就成了:
INSERT INTO user (username) VALUES (''); DROP TABLE user; --')一条插入语句,后面跟着一条删表语句,后果可想而知。SQL 注入不只在 SELECT 里存在,INSERT、UPDATE、DELETE 全都可能中招。这也就是热词里“sql注入万能密码绕过”这类攻击能被反复讨论的原因——本质都是拼接 SQL 导致语义被篡改。
正确做法是参数化查询。Python 的 pymysql 写法:
cursor.execute( "INSERT INTO user (username, password_hash) VALUES (%s, %s)", (username, password_hash) )参数化后,驱动会把username当作纯数据传给数据库,再奇怪的输入也不会被当成 SQL 语法解析。这是所有后端语言、所有数据库驱动都支持的机制,没有任何理由去拼字符串。
再补充一个安全习惯:密码永远不能明文存库。至少用 SHA-256 哈希,更好的是加盐后用 bcrypt 这类慢哈希算法。就算表被拖库,攻击者拿到一堆哈希,也比直接拿到明文密码安全得多。
写表操作这部分内容,我自己最大的体会是:建表时多想十分钟,后面能省十个小时。字段类型选错、约束漏加、字符集没统一、注释没写,这些坑在数据量小的时候毫无感觉,等表里躺了几百万行、多个系统都在读写时,每一次修改都是牵一发动全身的调整。所以我的个人习惯是,每一张新表建完,先回头看一遍建表语句,问自己三个问题:类型是不是最合适的?约束有没有覆盖核心逻辑?注释能不能让三个月后的我看懂这张表?都点头了,才开始写 INSERT。这个习惯帮我避了不少坑,分享给你。