“mysql $的用法”这个词,我在搜索词记录里见过很多次。点进来的人大致分两类:一类是敲完一条mysql命令,终端甩出一行Access denied或者Unknown database,回头一看自己命令里有个$,搞不清它到底属于谁;另一类是刚接手别人写的运维脚本,里面满屏的"$DB_NAME"、-p"$PASS"、WHERE name = '$n',想弄明白这一堆$分别在什么层面生效。说实话,$这个东西在 MySQL 的语境里根本不只有一个身份,它同时可能是 shell 的提示符符号、shell 的变量前缀、SQL 语句里的普通字符,甚至是正则表达式的行尾锚点。这四个身份混在一起,才让“mysql $的用法”成了一个高频疑问。这篇就把 MySQL 基础用法里跟$相关的部分掰开揉碎讲清楚,从最外层 shell 怎么把命令喂给客户端,到里层 SQL 到底认不认这个字符,再到脚本化时怎么把这些坑绕过去。适合已经能敲SELECT但一写脚本就翻车的人,也适合刚接手一堆遗留脚本、想快速看懂别人在干嘛的朋友。
1.$在 MySQL 语境里的三种身份,先分清楚再谈用法
很多人在搜索框里敲下这个问题的时候,脑子里其实只有一个模糊的疑问:为什么我照着教程敲的命令跑不起来。要回答这个疑问,第一件事不是去看 SQL 语法,而是建立一个分层模型——你敲的每一条mysql命令,都要先经过 shell 解析,再交给 mysql 客户端解析,最后交给服务端解析。$在这三层里的待遇完全不同,混着看就必然混乱。
1.1 身份一:终端提示符里的$,跟 MySQL 一点关系都没有
打开一个普通用户的终端,最常见的提示符长这样:$。这个$是 shell 给普通用户用的提示符,root 用户通常是#。不少人第一次看教程,看到命令行写成$ mysql -u root -p,就以为$是命令的一部分,直接把它复制黏贴进终端,结果报command not found。这个误会极其普遍,尤其是在看一些老文档的时候。
进入 mysql 客户端之后,提示符会变成mysql>。这里又有一套自己的符号体系,值得记一下,因为读懂提示符能帮你判断是不是引号没闭合:
| 提示符 | 含义 |
|---|---|
mysql> | 正常等待输入,语句还没结束 |
-> | 上一条语句没写完,还在续行 |
'> | 单引号没闭合 |
"> | 双引号没闭合 |
`> | 反引号没闭合 |
/*> | 块注释没闭合 |
我见过最典型的新手卡点,是敲了一条带单引号的 SQL,忘了收尾,结果后面敲什么都被吞进同一个字符串里,提示符一直停在'>,人还以为客户端卡死了。其实只要看一眼提示符就知道问题在哪。
提示符还能自定义。会话里直接敲prompt \u@\h [\d]>\就变了,\u是当前用户,\h是主机,\d是当前库名。想长期生效就写进配置文件的[mysql]段:
[mysql] prompt="\u@\h [\d]>\ "改完之后,你随时能知道自己连的是哪台机器、哪个库,多实例环境下这个习惯能救命。我就有过在生产库上敲DROP TABLE的前一秒,靠提示符里的库名发现连错了环境的经历。
1.2 身份二:shell 的变量前缀,绝大多数报错都源于此
真正让$变成问题的,是 shell 的变量展开。$PASS、${DB}、$(date +%F)、$1、$?这些写法在 shell 里都有特殊含义,shell 会在把命令交给mysql之前,先完成替换。也就是说,mysql 客户端根本看不到你写的那串$PASS,它看到的是替换后的结果。
这里最坑的一点:未定义的变量在默认情况下会展开成空字符串,而且不报错。你写了-p"$PASS",如果PASS拼错了大小写,命令会变成-p"",客户端拿着空密码去连,服务端返回Access denied。报错信息指向的是密码错误,而真正的问题在拼写,这个坑能把人绕进去很久。
想让它报错而不是静默变空,可以在脚本开头加一行:
set -u开启之后,引用未定义变量会直接终止脚本并提示unbound variable。配合set -e(出错即退出)和set -o pipefail(管道中任一环节失败即失败),三件套一起用,脚本的健壮性会上一个台阶。
1.3 身份三:SQL 语句内部的合法字符与正则锚点
抛开 shell 不谈,$在 MySQL 的 SQL 语法里本身是合法的。它可以出现在不加引号的标识符里,可以作为字符串字面量的一部分,在正则表达式里还是行尾锚点。但它不能用来声明变量——MySQL 不支持$var这种变量写法,那是别的数据库体系的习惯。用户变量是@var,系统变量是@@var,这两者在日常使用中要分清。
把这三层分开之后,再看任何一个带$的命令,你就能快速定位它属于哪一层。下面这条命令里就有三层同时出现:
mysql -u"$USER_NAME" -p"$PASS" "$DB" -e 'SELECT * FROM t WHERE code REGEXP "^A[0-9]+$"'"$USER_NAME"、"$PASS"、"$DB"是 shell 层;'...'单引号包裹的内容 shell 不展开;最里头的^A[0-9]+$是 SQL 正则层的锚点,表示整串必须从 A 开头、以数字结尾。理解了这个分层,剩下的内容就都是细节了。
2. 连上数据库之前:密码和参数里的$会先被 shell 吃掉
连接这一环是$问题最集中的地方。原因很简单——密码通常都带特殊字符,而命令行参数又是 shell 最先处理的部分。这一章的目标是让你彻底搞清楚 shell 的引用规则,以及密码该怎么传。
2.1 单引号、双引号、反斜杠的展开差异实测
shell 里三种引用的行为差异,是必须先背下来的基本功:
| 引用形式 | $是否展开 | 能否包含空格 | 适用场景 |
|---|---|---|---|
| 不加引号 | 是 | 否(按空格拆词) | 基本不用 |
双引号"..." | 是 | 是 | 需要变量展开的普通参数 |
单引号'...' | 否 | 是 | 密码、SQL 正文、含$的字面量 |
反斜杠转义\$ | 否 | 视情况 | 双引号内局部转义 |
记住一句话就够:要展开用双引号,不展开用单引号,混着来就转义。
还有一个隐藏的坑:反引号在双引号内部依然会被执行。下面这条命令看起来是给 SQL 里的字段名加引号,实际上 shell 会先把`id`当成命令执行:
# 危险写法 mysql -e "SELECT `id`, `name` FROM users"如果系统里恰好有id这个命令,它会被执行,输出被拼进 SQL;如果没有,直接报command not found。正确做法是把 SQL 整体放进单引号:
mysql -e 'SELECT `id`, `name` FROM users'或者用$()的反引号替代思路——但这条路上没有优雅解,老老实实用单引号最省事。如果 SQL 里既需要单引号又需要变量展开,那就用 heredoc,后面章节会讲。
2.2 密码里带$的正确写法与免密登录方案
mysql -p后面紧跟密码,中间不能有空格。写成mysql -p $PASS会被解析成“密码为空,$PASS是数据库名”,报Unknown database。这个错误提示看起来完全跟密码无关,实际原因在空格。
密码里带$时,正确写法是用单引号包住:
mysql -u appuser -p'p@ss$w0rd' mydb注意-p和单引号之间没有空格。单引号让 shell 不做展开,客户端拿到的是完整字符串p@ss$w0rd。
如果密码里同时包含单引号,单引号方案就失效了。这时候可以退回到交互式输入,只写-p不带值,让客户端提示你输入:
mysql -u appuser -p mydb # 回车后输入密码,此时密码不经过 shell 解析但脚本里没法交互,所以更彻底的做法是不在命令行写密码。mysql_config_editor是官方提供的小工具,它把连接信息加密存进用户目录下的.mylogin.cnf,之后用--login-path引用:
mysql_config_editor set --login-path=mydb-prod \ --host=127.0.0.1 --user=appuser --password mysql --login-path=mydb-prod mydb -e 'SELECT 1'用mysql_config_editor print --all可以列出配置项,但密码本身显示不出来,这一点比明文写在脚本里强太多。需求前提是你能接受在本机存一份加密文件——共享机器上要谨慎。
还有一个偏门写法是环境变量MYSQL_PWD:
MYSQL_PWD='p@ss$w0rd' mysql -u appuser mydb -e 'SELECT 1'单引号保护了$,功能上没问题。但这个变量在进程环境里可见,同机其他用户通过ps之类的手段可能读到,只建议在自己独占的开发机上用。
2.3 配置文件里写$为什么反而没事
很多人把连接参数写进~/.my.cnf,这里写password = p@ss$w0rd是完全安全的,因为配置文件不经过 shell 解析,$就是普通字符。
但配置文件有它自己的规则:#和;开头的行是注释,值里如果包含#或;,需要加双引号。所以密码里同时有$和#的时候,写成:
[client] user = appuser password = "p@ss$w0rd#2024"这里加双引号是为了保护#,不是为了$。顺带一提,配置文件的权限要收紧到600,否则同机用户能直接读到明文密码:
chmod 600 ~/.my.cnf我自己踩过的坑是:在开发机上一切正常,部署到另一台机器上就Access denied。查了很久才发现对方把密码写在~/.my.cnf里,但那台机器上有个同名文件属于别的用户,同时存在的时候读取顺序不确定。多文件冲突这种事,用mysql --print-defaults看一眼实际生效的参数最直接。
3. 语句内部:MySQL 到底认不认$这个字符
过了 shell 这一关,接下来要问的是:发给服务端的 SQL 里带$,会不会出问题。答案要分位置看。
3.1 标识符里可以有$,但反引号该加还得加
MySQL 对不加引号的标识符有明确的字符限制:字母、数字、_、$,且不能以数字开头。也就是说order$detail这样的表名、user$id这样的字段名,在不加引号的情况下是合法的。
那就意味着可以随便用吗?不是。不加引号的标识符不能包含空格,不能是保留字,也不能以数字开头。为了统一风格、减少意外,我的习惯是只要标识符里出现$,一律加反引号:
CREATE TABLE `order$detail` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order$no` VARCHAR(32) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里补充一个现实情况:$在标识符里出现,很多时候不是设计出来的,而是历史遗留。早年一些应用会把分库分表的后缀拼进表名,比如log$202401。接手这种库的时候,你的所有 SQL 都得加反引号,ORM 里的字段映射也要检查一遍,否则查出来的字段名对不上。
3.2 MySQL 没有$变量,别把别家的习惯带过来
这一点必须强调,因为从别的数据库体系转过来的人特别容易犯。MySQL 里没有$var这种变量语法:
| 场景 | MySQL 写法 | 常见误写 |
|---|---|---|
| 会话变量 | SET @n = 1;SELECT @n; | SET $n = 1 |
| 系统变量 | SELECT @@version; | SELECT $version |
| 存储过程局部变量 | DECLARE v_cnt INT; | DECLARE $cnt INT |
| 预处理占位符 | PREPARE s FROM 'SELECT ?'; | PREPARE s FROM 'SELECT $1' |
别的体系里用$1、$2做位置参数,或者用v_前缀声明局部变量,搬到 MySQL 里全都不认。在 MySQL 里做动态 SQL,标准路子是用户变量加预处理语句:
SET @sql = CONCAT('SELECT * FROM `order$detail` WHERE `order$no` = ?'); SET @no = 'A2024001'; PREPARE stmt FROM @sql; EXECUTE stmt USING @no; DEALLOCATE PREPARE stmt;占位符固定是?,不是$1。这条路上有个约束:预处理语句里能用的动态部分是有限的,表名、字段名可以用CONCAT拼进去,但LIMIT后面的值在早期版本里不支持占位符,8.0 之后才放宽。
3.3 字符串字面量里的$永远安全
如果把$放进单引号包裹的字符串里,它就是个普通字符,不会有任何解析问题:
SELECT '$' AS dollar_sign, 'price$100' AS sample, 'currency$' = 'currency$' AS is_equal;所以真正要操心的从来不是“SQL 认不认$”,而是“shell 会不会提前把它换掉”。把这一层想明白,排查效率能提高一大截。
4. 正则里的^和$:锚定语义与 LIKE 的根本差异
$在 MySQL 里最“正统”的用途,其实是正则表达式的行尾锚点。^表示字符串开头,$表示字符串结尾,两者配合就是精确匹配。这块内容在基础用法里经常被一带而过,但实际项目里写错的人特别多。
4.1 LIKE 天生全串匹配,REGEXP 天生子串匹配
这是最容易混淆的一点。LIKE的语义是整串匹配,%代表任意长度字符,所以WHERE name LIKE 'abc'只匹配名字恰好等于abc的行。而REGEXP的语义是只要字符串中存在一个匹配片段就算命中,WHERE name REGEXP 'abc'会把abc123、xxabcxx全部捞出来。
要让REGEXP表现得像整串匹配,必须手动加锚点:
-- 命中 abc,不命中 abc123 SELECT * FROM t WHERE name REGEXP '^abc$'; -- 命中以 abc 开头的 SELECT * FROM t WHERE name REGEXP '^abc'; -- 命中以 abc 结尾的 SELECT * FROM t WHERE name REGEXP 'abc$';我在做数据清洗时踩过一次:用REGEXP 'error'统计错误日志,结果把no_error_found也统计进去了,数字虚高一截。加上锚点才对。
4.2 8.0 的正则函数家族与大小写开关
MySQL 8.0 引入了 ICU 正则库和一整套函数,比老版本的REGEXP运算符好用很多:
| 函数 | 作用 | 示例 |
|---|---|---|
REGEXP_LIKE(expr, pat, mode) | 判断是否匹配 | REGEXP_LIKE(name,'^a','i') |
REGEXP_REPLACE(expr, pat, repl) | 替换匹配部分 | REGEXP_REPLACE(code,'[^0-9]','') |
REGEXP_SUBSTR(expr, pat) | 提取匹配片段 | REGEXP_SUBSTR(log,'[0-9]{3}') |
REGEXP_INSTR(expr, pat) | 返回匹配位置 | REGEXP_INSTR(txt,'x') |
REGEXP_LIKE的第三个参数可以组合,i是忽略大小写,c是区分大小写,m是多行模式(这个模式下^和$会匹配每一行的开头结尾,而不是整个字符串的首尾),n让.能匹配换行。
这里有个特别隐蔽的坑:当REGEXP_LIKE的第一个参数是二进制字符串时会直接报错。比如某个字段是VARBINARY,或者你的表达式里用了返回二进制串的函数,就会得到Character set 'binary' cannot be used in conjunction with 'regexp_like'。解决办法是先转字符集:
SELECT REGEXP_LIKE(CONVERT(bin_col USING utf8mb4), '^abc$');这个报错信息不算直观,第一次遇到容易懵。
另外注意 8.0 里正则默认是否区分大小写,取决于参数的排序规则。为了行为确定,建议显式指定c或i,别依赖默认值。
4.3 用^...$做精确匹配时索引还能不能用
这是个必须讲清的性能问题。结论:REGEXP基本用不上索引。优化器不会把正则条件改写成范围扫描,所以只要WHERE里出现REGEXP,大概率就是全表扫或者全索引扫。
对比一下LIKE:
| 写法 | 能否走索引 |
|---|---|
col LIKE 'abc%' | 能,走范围扫描 |
col LIKE '%abc' | 不能,左模糊 |
col LIKE '%abc%' | 不能 |
col REGEXP '^abc' | 一般不能 |
col = 'abc' | 能,等值查找 |
如果你的场景是“精确匹配”,那就别用REGEXP,直接用=或者IN。如果是“前缀匹配”,用LIKE 'abc%'。正则留给那些确实需要复杂模式的场景,比如校验格式、提取片段。
真要在正则上做索引加速,8.0 有个变通思路:建生成列 + 索引。
ALTER TABLE t ADD COLUMN code_prefix VARCHAR(8) GENERATED ALWAYS AS (LEFT(code, 8)) STORED, ADD INDEX idx_code_prefix (code_prefix);查询时先让code_prefix = 'ABC12345'走索引把范围缩小,再用REGEXP做精细过滤。这种“先粗筛后精筛”的套路,在大表上效果非常明显,我实测过一个千万级表,从十几秒压到几十毫秒。
5. 把常用查询脚本化:shell 变量驱动 mysql 的四种写法对比
日常真正高频的场景,是把 SQL 写进 shell 脚本里定时跑。这时候$的两层含义会同时出现,写法选错了就是无穷无尽的排查。这里把四种常见写法摆在一起对比。
5.1-e直接拼字符串的适用边界
最简单的方式是-e:
mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -e "SELECT COUNT(*) FROM users WHERE status = 1"这条命令没问题,因为 SQL 里没有需要 shell 保护的特殊字符。但只要 SQL 里出现下面任意一个东西,就得格外小心:
*:不加引号会被当成文件名通配符展开?:同样是通配符,匹配单个字符`:反引号会被当命令执行$:会被当变量展开":会提前结束双引号
其中*这个坑最隐蔽。SELECT * FROM t如果整个-e参数没加引号,shell 会尝试把*展开成当前目录下的所有文件名,SQL 直接变形。虽然加上双引号就没事,但双引号又打开了$和反引号的口子。
结论:-e只适合那种“SQL 写死在脚本里、不含任何动态值和特殊符号”的场景。一旦需要往里塞变量,就该换写法。
5.2 heredoc 加引号包裹的定界符,最省心的做法
heredoc 是我日常最推荐的写法。关键在于定界符要加引号:
DB_NAME="mydb" mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" <<'SQL' SELECT `id`, `name` FROM `order$detail` WHERE `order$no` = 'A2024001' AND code REGEXP '^[A-Z]{2}[0-9]{6}$'; SQL注意<<'SQL'里的单引号。加了引号,heredoc 内部的$、反引号、\全部原样传给 mysql,shell 不碰。这样你就可以放心地在 SQL 里写反引号字段名、写正则的$锚点,完全不用转义。
需要把 shell 变量传进 SQL 时,去掉引号即可,但这时候 SQL 里的$又会展开。折中方案是用占位符加PREPARE,或者先把变量赋给 MySQL 用户变量:
mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" <<SQL SET @start_date = '$START_DATE'; SELECT COUNT(*) FROM orders WHERE created_at >= @start_date; SQL这里$START_DATE是有意展开的,而 SQL 里其他位置的$得自己用\$转义。代码里最好加一行注释说明哪些$是故意展开的,方便后来人。
5.3-N -B拿干净结果做后续处理
脚本里取查询结果继续加工时,输出格式就很重要。默认输出带边框和表头,很难解析。几个参数组合起来能得到干净数据:
| 参数 | 作用 |
|---|---|
-N | 不输出列名 |
-B | 批处理模式,字段用 tab 分隔 |
-r | 关闭转义,原样输出 |
-s | 静默模式,减少冗余提示 |
--default-character-set=utf8mb4 | 避免中文乱码 |
典型用法:
count=$(mysql -N -B -s -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" \ -e 'SELECT COUNT(*) FROM orders WHERE status = 0') echo "待处理订单数: $count"注意-B会对特殊字符做转义,比如换行会变成\n字面量。如果你的字段里有换行,记得加-r关闭转义,否则拿到的数据和期望不符。这个细节我在导出多行文本字段时栽过,数据里全是字面的\n字符串。
5.4 参数化思路:用PREPARE替掉手工拼接
如果 SQL 里的动态值来自外部输入,字符串拼接就有注入风险。稳妥做法是把值交给 MySQL 的预处理机制,让它自己处理引用:
mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" <<SQL SET @keyword = '$KEYWORD'; SET @sql = 'SELECT id, name FROM users WHERE name LIKE CONCAT(''%'', ?, ''%'')'; PREPARE s FROM @sql; EXECUTE s USING @keyword; DEALLOCATE PREPARE s; SQL这里最容易错的是 SQL 里嵌套的单引号——因为在 heredoc 里,''就是两个单引号,表示字符串里的一个引号。这种写法可读性一般,但比手工拼WHERE name = '$KEYWORD'安全得多。
顺带说一句,SQL 里有个QUOTE()函数,可以给你传入的字符串自动加引号并转义:
SELECT QUOTE('it''s ok'); -- 返回 'it''s ok'在必须拼接的场景下,用它做最后一道保险。
6. 一次完整的排错链路:连接突然被拒,最后发现是$作怪
讲原理容易,实战排查才见真章。下面这个案例我印象很深,完整的链路值得复现一遍,因为它把前面几个知识点全串起来了。
6.1 从报错信息反推是哪一层出的问题
现象是这样的:某开发者写了个定时任务脚本,每天凌晨跑一次数据归档,之前一直正常,改了密码之后就再没成功过。手动执行脚本,输出:
ERROR 1045 (28000): Access denied for user 'appuser'@'localhost' (using password: YES)using password: YES这个信息很关键,说明客户端确实把密码发出去了,只是密码不对。如果显示using password: NO,那说明压根没传密码参数。
手敲mysql -u appuser -p然后交互输入同样的密码,能正常登进去。这就排除了“密码本身就错”和“账号权限被改”这两种可能。问题被锁死在脚本这一侧。
6.2 用set -x和printf '%q'把展开结果打出来
打开脚本一看,配置文件长这样:
# env.sh DB_USER=appuser DB_PASS=p@ss$w0rd DB_NAME=mydbDB_PASS=p@ss$w0rd这一行没有加引号,$w0rd被当成变量名,而它从未被定义,于是展开成空字符串。最终DB_PASS的值变成了p@ss——少了后半截,登录自然失败。
验证方法有两个,配合使用效果最好。
第一个是临时打开追踪:
bash -x archive.sh输出里会看到+ mysql -uappuser -pp@ss mydb,密码只剩前半截,一目了然。
第二个是用printf '%q',它会把变量的实际值按 shell 可复用的形式打印出来,不可见字符和特殊字符都会显式转义:
source env.sh printf '%q\n' "$DB_PASS" # 输出:p@ss对比一下正常情况:
DB_PASS='p@ss$w0rd' printf '%q\n' "$DB_PASS" # 输出:p@ss\$w0rd两个输出一对比,问题无处遁形。printf '%q'这个技巧我强烈建议记下来,比echo靠谱太多——echo遇到空变量什么都不打印,你甚至看不出变量是空的还是没定义。
6.3 修复后如何验证不再复发
修复只需要一行:给值加单引号。
DB_PASS='p@ss$w0rd'但光修好不算完,还得防止下次再犯。我的做法是在脚本头部加三行:
set -euo pipefail其中-u就是关键。加了它,再遇到$w0rd这种未定义变量,脚本会在那一行直接退出并报错:
env.sh: line 3: w0rd: unbound variable报错位置精确到行号,比默默连不上数据库强一万倍。
再进一步,把所有含特殊字符的配置值统一用单引号包裹,并在文件头写一条注释说明这个约定:
# 约定:所有变量值一律用单引号包裹,避免 shell 展开 DB_USER='appuser' DB_PASS='p@ss$w0rd' DB_NAME='mydb'最后加一个自检环节,让脚本在真正干活之前先确认能连上:
if ! mysql --login-path=mydb-prod -e 'SELECT 1' >/dev/null 2>&1; then echo "数据库连接失败,终止执行" >&2 exit 1 fi这个前置检查花不了几毫秒,但能让问题在第一时间暴露,而不是等到半夜归档失败了第二天才发现。
还有一个同类坑值得提前防:密码里含\的时候,双引号包裹会触发转义,密码被改写;含!的时候,在交互式 bash 里可能触发历史扩展。统一用单引号,这些麻烦全部消失。
7. 回到基础用法本身:日常高频操作的落地清单
把$这层窗户纸捅破之后,剩下的事情就顺畅多了。最后这一部分回到 MySQL 基础用法本身,挑几个日常复用率最高、但又最容易在细节上栽跟头的点讲。
7.1 建库建表时最容易忽略的几个默认值
建库的时候如果不指定字符集,会跟着服务端默认值走。不同环境默认值可能不一样,5.7 和 8.0 的默认字符集就不同。所以我一律显式写:
CREATE DATABASE IF NOT EXISTS mydb DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_0900_ai_ci;utf8mb4而不是utf8,这一点现在基本是共识了——早期的utf8只支持三字节,存 emoji 会截断甚至报错。排序规则utf8mb4_0900_ai_ci是 8.0 的默认值,ai表示重音不敏感,ci表示大小写不敏感。如果你的业务需要区分大小写(比如订单号、优惠码),记得改成_as_cs结尾的排序规则,或者在建表时单独给字段指定。
建表时有几个细节我每次都检查:
CREATE TABLE `orders` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务单号,区分大小写', `amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00, `status` TINYINT UNSIGNED NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_status_created` (`status`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;金额用DECIMAL不用FLOAT,这个是老生常谈但仍有人踩。主键用BIGINT UNSIGNED而不是INT,考虑到增长空间。时间字段用DATETIME配合默认值和ON UPDATE,省掉应用层维护时间戳的代码。
需要区分大小写的order_no,可以单独指定排序规则:
`order_no` VARCHAR(32) COLLATE utf8mb4_0900_as_cs NOT NULL如果不指定,唯一索引会把ABC和abc判为重复,业务上可能就是个事故。
7.2 查询与索引的最小可用套路
写完SELECT之后,养成一个习惯:在前面加EXPLAIN看执行计划。重点看三列:
| 列 | 关注点 |
|---|---|
type | ALL是全表扫,ref/range/eq_ref相对好 |
key | 实际用了哪个索引,NULL说明没走索引 |
rows | 预估扫描行数,越大越要警惕 |
Extra | 出现Using filesort、Using temporary要留意 |
几个高频优化点:
联合索引要遵守最左前缀。建了(status, created_at),那么WHERE status = 1能用,WHERE status = 1 AND created_at > '2024-01-01'也能用,但单独WHERE created_at > '...'用不上。
不要在索引列上做函数运算。WHERE DATE(created_at) = '2024-01-01'会导致索引失效,改成范围查询:
WHERE created_at >= '2024-01-01 00:00:00' AND created_at < '2024-01-02 00:00:00'这个改写的收益在小表上看不出来,在百万级表上是从秒级到毫秒级的差别。
分页深翻页要避免大OFFSET。LIMIT 1000000, 20会让数据库扫描一百万行再丢掉,改成基于游标:
SELECT id, order_no FROM orders WHERE id > 1000000 ORDER BY id LIMIT 20;前提是排序字段有索引且值唯一,否则会漏数据。
7.3 备份、恢复和日常巡检脚本骨架
备份这件事,mysqldump是最基础的方案,适合中小体量。几个参数值得配好:
mysqldump \ --single-transaction \ --quick \ --default-character-set=utf8mb4 \ --routines --triggers --events \ -u"$DB_USER" -p"$DB_PASS" \ "$DB_NAME" > "backup_$(date +%F).sql"--single-transaction让 InnoDB 表在一个一致性快照里导出,不锁表;--quick逐行读取,避免把整表塞进内存。注意这两个参数对 MyISAM 表不起作用,如果库里还混着老引擎的表,备份期间会有锁。
恢复就简单了:
mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" < backup_2024-01-01.sql日常巡检我习惯跑几条固定查询,写成脚本每天早上看一眼:
-- 连接数与最大连接数 SHOW STATUS LIKE 'Threads_connected'; SHOW VARIABLES LIKE 'max_connections'; -- 当前正在跑的慢查询 SELECT id, user, time, LEFT(info, 80) AS sql_head FROM information_schema.processlist WHERE command <> 'Sleep' AND time > 5 ORDER BY time DESC; -- 表体积 Top 10 SELECT table_name, ROUND((data_length + index_length) / 1024 / 1024, 2) AS mb FROM information_schema.tables WHERE table_schema = 'mydb' ORDER BY data_length + index_length DESC LIMIT 10;这几条查下来,基本能判断出当天有没有异常。连接数接近上限就说明有连接泄漏,慢查询扎堆就说明某个 SQL 或索引出了问题。
写这些脚本的时候,前面讲的$规则就派上用场了:所有变量加单引号包裹,SQL 正文用 heredoc 加引号定界符,结果取用加-N -B -s,再加个set -euo pipefail兜底。这么一套下来,脚本稳定性能提升一大截。
我个人在实际操作中的体会是,$这类符号引发的问题,难的不是修复,而是定位。修复往往就是加个引号的事,但从“数据库连不上”到“某个变量没加引号”之间隔着好几层。所以与其事后排查,不如事前把写法固定下来——配置值一律单引号,SQL 一律 heredoc 加引号定界符,脚本一律开set -u。这三条约定执行下去,同类问题基本就绝迹了。另外那个printf '%q'的小技巧,真的值得贴在显示器边上,它救我的次数比任何调试工具都多。