ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MySQL创建用户并授权:权限模型、最小权限与账号治理

MySQL创建用户并授权:权限模型、最小权限与账号治理

权限这件事,我见过太多团队在项目上线前一天才临时抱佛脚:随手GRANT ALL ON *.* TO 'app'@'%',密码设成123456,然后安慰自己"内网没事"。半年后要么是被扫描器撞库,要么是某个离职同事的账号还躺在mysql.user表里能登能删。MySQL创建用户并授权这套动作本身只有几行SQL,但背后牵扯的是身份模型、权限粒度、认证插件、密码策略、账号生命周期管理一整套东西。这篇就把这套流程从头到尾拆一遍,包括语法细节、参数怎么定、为什么这么定,以及那些文档里不会写、只有真在生产库上折腾过才会遇到的坑。不管你是刚接触MySQL的开发者,还是要给团队定权限规范的人,看完应该能直接照着落地。

1. 权限模型先搞懂:别急着敲CREATE USER

很多教程上来就给语法,我觉得顺序反了。你得先知道MySQL眼里的"一个用户"到底是什么,否则后面授权时会出现"我明明授了权为什么不生效"这种玄学问题。这一章把身份模型和权限层级讲透,后面所有操作才有依据。

1.1 一个账号其实是"用户名+来源主机"的组合

这是MySQL最容易让人懵的一点:'app'@'10.0.1.%'和'app'@'localhost'在MySQL眼里完全是两个独立的账号,各自有各自的密码、各自的权限、各自的状态。mysql.user表的主键是(User, Host)这个联合键,而不是单独的User。所以你在Navicat里看到两个都叫app的账号,别以为是显示重复了,那真是两条记录。

连接进来时到底匹配哪一条?MySQL的规则是:先在mysql.user里筛选出Host能匹配客户端IP的所有行,然后按"具体程度"排序——精确IP比带通配符的优先,带通配符的比%优先。举个实际场景,你同时建了:

CREATE USER 'app'@'%' IDENTIFIED BY 'P@ss_for_anywhere'; CREATE USER 'app'@'10.0.1.23' IDENTIFIED BY 'P@ss_for_specific';

这时候从10.0.1.23连进来,走的是第二条,用的是P@ss_for_specific这个密码。这个机制很有用:你可以给绝大多数来源一个通用弱一点的账号,给核心应用服务器单独配强密码和更宽的权限。但反过来也是坑——有人给'app'@'%'授了全部权限,又给某台机器建了'app'@'10.0.1.23'只给了只读,结果发现从这台机器上居然能删数据,就是因为搞混了到底匹配的哪条。排查时永远先跑一句SELECT USER(), CURRENT_USER();,前者是你声称的身份,后者是MySQL实际匹配到的账号,两者不一致就说明匹配到了别的行。

提示:%能匹配任意长度字符串,但不匹配localhost的特殊情况——localhost在MySQL里走Unix socket,和127.0.0.1走的TCP是两条不同的路径。如果你只建了'app'@'%',本地用mysql -u app可能照样连不上,因为localhost的socket连接不走%。要么显式建'app'@'localhost',要么用-h 127.0.0.1强制走TCP。

1.2 权限分四层,粒度越细管理成本越高

MySQL的权限不是一个开关,而是一个从粗到细的层级结构,授权语句里的ON后面写什么,决定了授的是哪一层:

权限层级写法示例作用范围典型用途
全局ON *.*所有库所有表管理账号、备份账号
数据库ON order_db.*指定库的所有表业务读写账号
表ON order_db.t_order单张表临时数据修复
列ON order_db.t_user (phone, email)表中指定列脱敏查询、报表
例程ON PROCEDURE order_db.p_close存储过程/函数精细化调用控制

实际项目里,90%的场景停在"数据库层"就够了。列级权限听起来很美,但维护成本极高——业务加个字段就得改授权,而且很多ORM框架对列级权限的兼容性并不好,查询SELECT *直接报错。表级权限更多是用在临时排查、数据修复这种一次性场景,修完就该回收。全局权限只应该给极少数管理账号,业务账号碰都不要碰。

这里有个判断标准:如果一个权限授出去之后,你能说清楚它最坏情况下能造成多大破坏,那就可以授;说不清楚,就别授。SELECT最坏是泄露数据,UPDATE最坏是改错数据,DROP最坏是删库,FILE最坏是读写服务器任意文件,PROCESS最坏是看到所有连接正在执行的SQL(包括明文密码)。按这个标准过一遍,你会发现业务账号真正需要的权限其实很少。

1.3 root跑业务为什么是个坏习惯

"反正是内网"是最常见的自我说服。但root连接业务库有几个实打实的问题:

第一是爆炸半径。应用如果有SQL注入漏洞,用root连进来,攻击者可以直接LOAD_FILE('/etc/passwd')、可以INTO OUTFILE写webshell、可以DROP DATABASE。用最小权限账号连进来,注入点最多也就影响那一个库的几张表。

第二是审计失焦。慢查询日志、performance_schema里看到的所有操作都挂在root名下,出问题你根本分不清是哪个服务干的。给每个应用、每个环境配独立账号,日志里一眼就能定位来源。

第三是误操作不可控。人在生产库上敲DELETE FROM t_order WHERE ...忘了加条件,root账号没有任何阻拦机制。而如果你用的是只有SELECT的账号,这条语句直接被拒,系统帮你兜了底。

第四是账号治理。root密码往往被复制在十几个地方的配置文件、聊天记录、Wiki里,人员流动时根本收不回来。业务账号可以按服务粒度管理,谁走了一条DROP USER就干净了。

我一般建议的分法是:root只允许从本机socket登录,且改成一个长随机密码存在密码管理器里;日常运维用一个dba_admin账号做DDL;每个应用一个app_xxx账号;监控系统一个只读账号;备份一个带RELOAD, LOCK TABLES, REPLICATION CLIENT, SHOW VIEW, TRIGGER的账号。这套分下来,出事的时候责任边界非常清晰。

2. 建账号的每一步都有讲究

搞清楚了模型,再来看CREATE USER。语法就一行,但主机段怎么写、认证插件选哪个、密码策略怎么配,每个选择都有后果。

2.1 CREATE USER 的标准写法和主机段选择

最基础的写法:

CREATE USER 'app_order'@'10.0.2.%' IDENTIFIED BY 'Zq7#mK2pL9vX';

拆开看三个部分。用户名部分,建议用服务名_环境_角色这种命名,比如app_order_prod_rw、report_bi_prod_ro,一眼能看出是谁在用。MySQL用户名最长32字符,大小写敏感(虽然在某些平台上有坑),别用中文和特殊符号。

主机段是重点。常见的几种写法:

-- 精确IP,最严格 CREATE USER 'app_order'@'10.0.2.31' IDENTIFIED BY '...'; -- 网段通配,适合K8s节点池、应用集群 CREATE USER 'app_order'@'10.0.2.%' IDENTIFIED BY '...'; -- 完全放开,只在特定场景用 CREATE USER 'app_order'@'%' IDENTIFIED BY '...'; -- 本地专用 CREATE USER 'app_order'@'localhost' IDENTIFIED BY '...';

网段通配的粒度要算清楚。10.0.2.%匹配的是10.0.2.0到10.0.2.255,如果你应用集群分布在多个网段,得挨个写或者用更宽的10.0.%。但注意10.0.%会匹配10.0.0.1到10.0.255.254,范围一下子放大256倍。我的经验是:能精确到单IP就精确到单IP,容器化环境节点会漂移才用网段,%只用于确实需要从任意位置连的只读报表账号。

还有一个细节:主机段支持CIDR写法吗?MySQL 8.0.23之后支持在CREATE USER里用/掩码,但更稳的方式还是用%通配,兼容性更好。另外如果主机名含_或%这类特殊字符(比如某些云主机的内网DNS),反斜杠转义要写对,否则会被当成通配符,这个坑很隐蔽。

2.2 认证插件:8.0之后的老客户端兼容问题

MySQL 8.0把默认认证插件从mysql_native_password换成了caching_sha2_password。这个改动带来的最典型报错是:

ERROR 2059 (HY000): Authentication plugin 'caching_sha2_password' cannot be loaded

或者Java项目里的:

java.sql.SQLException: Unknown system variable 'query_cache_size'

(后者其实是驱动版本太老导致的连带问题)。根本原因是客户端驱动版本不足以支持新的认证流程。

正确的处理顺序是:先升级驱动。JDBC用8.0.x以上,Python的pymysql升到1.0以上或mysqlclient升到2.1以上,Go的go-sql-driver/mysql升到1.6以上,基本都能支持。升级驱动是最干净的做法,因为caching_sha2_password在安全性和性能上都更好(它有缓存机制,第二次连接不用走完整握手)。

只有在驱动实在升不上去(比如某个老旧系统锁死了依赖)的情况下,才临时降级插件:

CREATE USER 'legacy_app'@'10.0.3.%' IDENTIFIED WITH mysql_native_password BY 'Zq7#mK2pL9vX'; -- 对已有用户修改 ALTER USER 'legacy_app'@'10.0.3.%' IDENTIFIED WITH mysql_native_password BY 'Zq7#mK2pL9vX';

改完记得确认:

SELECT user, host, plugin FROM mysql.user WHERE user = 'legacy_app';

注意:降级认证插件不只是一个技术选择,也是一个安全折衷。mysql_native_password用的是SHA1加盐的挑战响应,抗离线爆破能力弱于caching_sha2_password的SHA256方案。降级之后务必把密码强度提上去,并且尽快排期升级驱动。另外MySQL 8.4默认已经禁用了mysql_native_password,8.0系列未来也会逐步收紧,这是个迟早要还的债。

2.3 密码策略与账号生命周期参数

光设个密码就完事,等于没做账号管理。CREATE USER后面可以挂一串属性,这些才是让账号"可控"的关键:

CREATE USER 'app_order'@'10.0.2.%' IDENTIFIED BY 'Zq7#mK2pL9vX' WITH MAX_USER_CONNECTIONS 150 PASSWORD EXPIRE INTERVAL 180 DAY FAILED_LOGIN_ATTEMPTS 5 PASSWORD_LOCK_TIME 1;

逐个说。MAX_USER_CONNECTIONS 150限制这个账号的最大并发连接数,这个参数怎么定?简单算法:应用连接池的maximumPoolSize× 实例数 × 1.2 的余量。比如你8个Pod,每个池子20连接,那就是160,再留点余量给运维手动连,设成200比较合适。这个限制的价值在于——当某个服务出现连接泄漏时,它最多占满自己那200个连接,不会把整个实例的max_connections吃光导致全站雪崩。这是我一直坚持给每个应用账号都设的原因,成本极低,收益极高。

PASSWORD EXPIRE INTERVAL 180 DAY让密码180天后过期,这是满足合规要求的常见做法。但要注意,密码过期后应用会直接连不上,报ERROR 1820。所以配套的必须是密码轮换机制——要么用双密码(MySQL 8.0.14支持ALTER USER ... IDENTIFIED BY 'new' RETAIN CURRENT PASSWORD,新旧密码同时有效,应用灰度切换后再丢弃旧密码),要么在过期前主动轮换。RETAIN CURRENT PASSWORD这个特性太好用了,双密码切换的流程后面第4章会具体讲。

FAILED_LOGIN_ATTEMPTS 5 PASSWORD_LOCK_TIME 1是防暴力破解的,连续5次密码错误锁定1天。这个功能8.0.19之后才有,用之前先SELECT VERSION()确认。锁定的账号需要ALTER USER ... ACCOUNT UNLOCK才能恢复,如果碰上应用配置里密码写错导致反复重试,账号会被锁死,排查时优先怀疑这个。

密码本身的强度,靠validate_password组件来强制:

INSTALL COMPONENT 'file://component_validate_password'; SET GLOBAL validate_password.policy = MEDIUM; SET GLOBAL validate_password.length = 12; SET GLOBAL validate_password.mixed_case_count = 1; SET GLOBAL validate_password.number_count = 1; SET GLOBAL validate_password.special_char_count = 1;

MEDIUM策略要求长度、大小写、数字、特殊字符都满足。STRONG还会额外检查密码是否包含字典词。生产环境我建议至少MEDIUM+ 长度12。注意这些是SET GLOBAL,重启失效,要持久化得写进my.cnf的[mysqld]段。

2.4 幂等创建:自动化脚本里怎么写

手工敲一遍没事,但如果是初始化脚本、Ansible playbook、Terraform这种,重复执行会报ERROR 1396 (HY000): Operation CREATE USER failed。处理方式有三种,各有适用场景:

第一种,IF NOT EXISTS:

CREATE USER IF NOT EXISTS 'app_order'@'10.0.2.%' IDENTIFIED BY 'Zq7#mK2pL9vX';

简单,但有个陷阱:如果用户已存在,这条语句什么也不做,包括密码也不会更新。你以为脚本在"确保状态正确",实际上密码可能还是旧的。

第二种,先查后建,逻辑更明确:

SET @exists := (SELECT COUNT(*) FROM mysql.user WHERE user='app_order' AND host='10.0.2.%'); SET @sql := IF(@exists = 0, "CREATE USER 'app_order'@'10.0.2.%' IDENTIFIED BY 'Zq7#mK2pL9vX'", "ALTER USER 'app_order'@'10.0.2.%' IDENTIFIED BY 'Zq7#mK2pL9vX'"); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

第三种,也是最推荐的:创建和改密分开。初始化脚本只负责CREATE USER IF NOT EXISTS,密码由专门的密钥管理系统下发,通过ALTER USER单独执行。这样职责清晰,密码不会硬编码在脚本仓库里,轮换时也不用动初始化逻辑。

顺带说一句,GRANT语句在MySQL 8.0里也支持IF NOT EXISTS吗?不支持。但GRANT本身是幂等的——重复授同样的权限不会报错,所以授权脚本可以放心重复跑。

3. GRANT:把权限切到刚好够用

账号建好了,接下来是授权。这一步的核心原则只有四个字:最小权限。但"最小"到什么程度,得看账号要干什么活。

3.1 最小权限的落地写法

一个典型业务读写账号,标准的授权长这样:

GRANT SELECT, INSERT, UPDATE, DELETE ON `order_db`.* TO 'app_order'@'10.0.2.%';

注意几个细节。库名用反引号包起来,因为order是SQL关键字,虽然加了_db后缀不冲突,但养成习惯没坏处,遇到库名叫user、order、group的时候能救命。

权限列表里没有DROP、没有ALTER、没有CREATE。有人会问:建表怎么办?答案是建表不该由应用账号做。表结构变更(DDL)走独立的迁移流程,用专门的dba_deploy账号执行,应用账号只做DML。这样做的好处是:应用的权限被严格限制在数据操作层面,即使被注入,也删不掉表、改不了结构。

那TRUNCATE呢?这是个大坑——TRUNCATE TABLE需要的是DROP权限,不是DELETE权限。所以你不给DROP,TRUNCATE自然也做不了,这是好事。但反过来,如果有人图省事给了DROP想让它能清表,那这个账号就能删表了,风险等级直接跳一级。

存储过程和函数要单独授:

GRANT EXECUTE ON PROCEDURE `order_db`.`p_close_order` TO 'app_order'@'10.0.2.%';

视图的话,如果应用要查视图,需要视图本身的SELECT权限,加上底层表的SELECT权限(或者视图用SQL SECURITY DEFINER定义,则只需视图权限)。这个细节经常被忽略,表现为"视图建好了但查不了"。

3.2 四类典型账号的授权模板

我把生产环境常见的账号归纳成四类,每类的权限清单可以直接抄:

账号类型命名示例权限清单说明
业务读写app_order_prod_rwSELECT, INSERT, UPDATE, DELETE覆盖95%的CRUD需求
业务只读report_bi_prod_roSELECT+SHOW VIEW报表、BI、数据导出
数据迁移etl_sync_prodSELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP只在迁移窗口临时开,完成后立即回收
监控采集monitor_prodSELECT, PROCESS, REPLICATION CLIENT拿指标,不能碰业务数据

监控账号这里要展开说一下。PROCESS权限能看SHOW PROCESSLIST的完整内容,包括正在执行的SQL原文——如果SQL里有明文密码(比如某些老系统的登录语句),这个权限就能看到。所以监控账号虽然方便,但要限定来源IP,只允许监控服务器连。REPLICATION CLIENT用来读主从位点,REPLICATION SLAVE是用来做从库的,监控别给后者。

数据迁移账号是另一个高危点。很多人图省事,迁移脚本直接用业务账号跑,结果脚本里DROP TABLE IF EXISTS tmp_xxx之类的语句把正式表删了。正确做法是临时授一个带DDL的账号,跑完立刻REVOKE,或者干脆用dba_deploy账号在人工监督下执行。

3.3 库名通配符的坑:下划线要转义

这是个非常隐蔽的坑,我踩过一次,排查了两个小时。MySQL在GRANT的库名部分,_和%是通配符:

-- 本意是只授权 order_db 这个库 GRANT SELECT ON `order_db`.* TO 'app'@'%';

上面这条因为加了反引号,是精确匹配,没问题。但如果你写成:

GRANT SELECT ON order_db.* TO 'app'@'%';

order_db里的_会被当成"任意单个字符",于是orderXdb、order1db这些库也会被匹配到。要精确匹配必须转义:

GRANT SELECT ON `order\_db`.* TO 'app'@'%';

注意反引号加反斜杠的组合。查询SHOW GRANTS时你会看到order\_db,这是正常的。判断某个授权到底是不是精确的,看mysql.db表里Db字段的值——如果存的是order_db(带反斜杠转义后的形式可能显示不同),需要结合实际版本确认。稳妥的做法:库名里带下划线的,授权时一律反引号加转义。

3.4 FLUSH PRIVILEGES 什么时候才需要

网上教程几乎每篇都在GRANT后面加一句FLUSH PRIVILEGES,这其实是个流传极广的误解。

GRANT、REVOKE、CREATE USER、DROP USER、ALTER USER这些语句,MySQL会自动更新内存里的权限缓存,不需要执行FLUSH PRIVILEGES。真正需要它的场景只有一种:你绕过SQL接口,直接修改了mysql.user、mysql.db这类系统表,比如:

UPDATE mysql.user SET authentication_string = '' WHERE user = 'app'; FLUSH PRIVILEGES; -- 这里必须执行

再比如用INSERT INTO mysql.user手工造账号(极不推荐)。除此之外,日常授权加了FLUSH PRIVILEGES只是多做一次无谓的全量权限重载,在大实例上有轻微的锁开销。

还有一种情况:你修改了GRANT相关的配置变量,比如partial_revokes,也需要重载。但这个场景很罕见,碰到时MySQL会明确提示。

3.5 8.0的角色机制:多账号统一权限

如果你的系统有十几个微服务,每个服务一个账号,权限还都一样,一个个授会疯。MySQL 8.0的角色(Role)就是解决这个的:

-- 1. 建角色 CREATE ROLE 'role_app_rw'; -- 2. 给角色授权 GRANT SELECT, INSERT, UPDATE, DELETE ON `order_db`.* TO 'role_app_rw'; GRANT SELECT ON `user_db`.* TO 'role_app_rw'; -- 3. 把角色给账号 GRANT 'role_app_rw' TO 'app_order'@'10.0.2.%'; GRANT 'role_app_rw' TO 'app_pay'@'10.0.2.%'; -- 4. 设置默认角色(关键,不设的话连上来角色不生效) SET DEFAULT ROLE 'role_app_rw' TO 'app_order'@'10.0.2.%';

第4步是最容易漏的。角色授予后,默认是不激活的,新连接进来角色处于休眠状态,SHOW GRANTS能看到但权限不生效,表现就是"明明授权了还是Access denied"。SET DEFAULT ROLE ALL TO 'app_order'@'10.0.2.%'可以一次性设置该账号所有角色的默认激活状态。

角色也可以嵌套,role_app_rw可以GRANT给role_app_admin,形成权限继承树。但嵌套层次不宜超过两层,否则排查权限来源时会很痛苦——SHOW GRANTS只显示直接授予的部分,想知道最终生效的权限得用SHOW GRANTS ... USING role_name。

顺带说,角色的本质也是一个"账号",只不过account_locked为Y不能登录。所以mysql.user表里会看到它们,别误删。

4. 验证、回收与账号治理

授权完成不等于结束。真正把权限管住,靠的是验证机制和定期盘点。

4.1 SHOW GRANTS 看得见的和看不见的

验证授权结果的标准命令:

SHOW GRANTS FOR 'app_order'@'10.0.2.%';

输出类似:

GRANT USAGE ON *.* TO `app_order`@`10.0.2.%` GRANT SELECT, INSERT, UPDATE, DELETE ON `order_db`.* TO `app_order`@`10.0.2.%`

那个USAGE不是权限,是占位符,表示"这个账号存在但没有任何全局权限"。看到它别慌。

如果账号有角色,要看角色带来的最终权限:

SHOW GRANTS FOR 'app_order'@'10.0.2.%' USING 'role_app_rw';

更实用的验证方式是让应用真的连一次,跑几条代表性SQL:

-- 应该成功 SELECT COUNT(*) FROM order_db.t_order LIMIT 1; INSERT INTO order_db.t_order (...) VALUES (...); UPDATE order_db.t_order SET status = 2 WHERE id = 1; DELETE FROM order_db.t_order WHERE id = 1; -- 应该失败,验证越权被拦住 DROP TABLE order_db.t_order; SELECT * FROM user_db.t_user; CREATE TABLE order_db.t_test (id INT);

这个"负向测试"很重要。很多人只测了能做的,没测不能做的,结果发现权限给宽了也没察觉。我习惯在授权脚本后面跟一段验证SQL,把该失败的都跑一遍,确认返回Access denied才算完。

4.2 REVOKE 的正确姿势与残留权限

回收权限的语法和授予对称:

REVOKE INSERT, UPDATE, DELETE ON `order_db`.* FROM 'app_order'@'10.0.2.%';

有个细节:REVOKE ALL PRIVILEGES, GRANT OPTION FROM ...只回收权限,不删账号,账号还在,只是变成USAGE状态。要彻底删干净得用:

DROP USER 'app_order'@'10.0.2.%';

这里有个大坑:REVOKE对全局权限和库级权限的处理方式不一样。如果你先给了ON *.*的全局SELECT,又给了ON order_db.*的库级SELECT,然后REVOKE SELECT ON order_db.*,结果这个账号仍然能查order_db——因为全局的SELECT还在。权限是取并集的,回收必须一层层从上往下查。排查时先看SHOW GRANTS里有没有*.*的行,有的话优先处理它。

另一个坑是partial_revokes。8.0.16之后支持"部分回收"全局权限,比如给了SELECT ON *.*,然后REVOKE SELECT ON mysql.*,这样就能排除系统库。这个特性默认关闭,需要SET PERSIST partial_revokes = ON。不开的话,库级的REVOKE对全局授权无效。

4.3 账号盘点:把僵尸账号揪出来

生产库跑两三年,mysql.user里躺着一堆没人认领的账号是常态。定期盘点用这几条SQL:

-- 列出所有账号及最后活跃情况 SELECT user, host, plugin, account_locked, password_expired, password_last_changed FROM mysql.user WHERE user NOT IN ('mysql.sys','mysql.session','mysql.infoschema') ORDER BY password_last_changed; -- 找出从没改过密码的老账号 SELECT user, host, password_last_changed FROM mysql.user WHERE password_last_changed < DATE_SUB(NOW(), INTERVAL 365 DAY); -- 找出权限过宽的账号 SELECT user, host FROM mysql.user WHERE Super_priv = 'Y' OR Grant_priv = 'Y' OR File_priv = 'Y';

第一条里password_last_changed是重要线索,但注意它只记录通过ALTER USER修改的时间,直接用SET PASSWORD或改系统表的情况可能不准确。更可靠的方式是结合performance_schema看实际连接记录,但那需要开启相应的consumer,有一定开销。

盘点出来之后,处理流程建议是:先标记,发通知给可能的使用方,观察一周日志确认没有连接,再ACCOUNT LOCK锁一周,最后才DROP USER。直接删的后果可能是某个半夜跑的定时任务突然挂了,第二天早上才发现。

4.4 密码轮换:用双密码做到零停机

第2章提到的RETAIN CURRENT PASSWORD,具体流程是这样:

-- 第一步:设置新密码,同时保留旧密码 ALTER USER 'app_order'@'10.0.2.%' IDENTIFIED BY 'NewP@ss2024#Xk' RETAIN CURRENT PASSWORD; -- 此时旧密码和新密码都能登录,应用可以灰度切换 -- 第二步:确认所有实例都已经用上新密码后,丢弃旧密码 ALTER USER 'app_order'@'10.0.2.%' DISCARD OLD PASSWORD;

这个机制的价值在于,多实例应用滚动更新配置文件的窗口期内,新旧密码都能用,不会出现"改完密码一半实例连不上"的事故。如果没有这个特性(老版本MySQL),替代方案是建一个临时账号app_order_v2,切完再把老账号删掉,效果类似但更麻烦。

轮换周期怎么定?合规要求严格的场景是90天,一般业务180天足够。频繁轮换的边际收益递减,而且每次轮换都是一次操作风险。我的建议是凭据泄露风险高的场景缩短周期,普通内部系统180天,且必须有自动化流程,靠人工记日历迟早会漏。

5. 常见问题与排查实录

理论讲完了,这一章全是实战。下面这些错误码和现象,基本覆盖了权限相关问题的90%。

5.1 连不上:从错误码倒推原因

错误码报错内容常见原因处理方式
1045Access denied for user密码错/账号不存在/主机不匹配确认user@host全匹配,检查密码
1130Host is not allowed to connect没有匹配该来源IP的账号补建对应host的账号
1129Host is blocked同IP失败次数过多被拉黑FLUSH HOSTS或查max_connect_errors
1820You must reset your password密码已过期ALTER USER ... IDENTIFIED BY
3118Access denied, account is locked账号被锁定ALTER USER ... ACCOUNT UNLOCK
1226User has exceeded max_user_connections并发连接超限调大限制或排查连接泄漏
1203Too many connections实例级连接耗尽调max_connections,查谁占满了

1045是最常见的,但它的报错信息故意模糊(不告诉你到底是账号不存在还是密码错),这是安全设计。排查顺序:先SELECT user, host FROM mysql.user WHERE user='xxx'确认账号存在且host匹配,再用mysql -u xxx -p -h 正确IP手工连一次排除应用配置问题,最后才怀疑密码。

1130的典型场景:你在本机用mysql -h 192.168.1.10 -u app -p连,但账号建的是'app'@'localhost'。这时候报1130。解决要么建'app'@'192.168.1.%',要么从服务器本机连。

1129这个错误特别坑,它说的是"这个客户端IP之前失败太多次,被max_connect_errors机制拉黑了"。很多人第一反应是去查账号密码,其实完全无关。解决:在服务器上FLUSH HOSTS清空黑名单。根治要把max_connect_errors调大(默认100),或者修复那个一直在失败重试的应用。

5.2 连上了但没权限:三个排查方向

**方向一:匹配到了别的账号。**这是最高频的原因。跑SELECT CURRENT_USER();看实际匹配的账号。比如你以为是'app'@'10.0.2.31'(有完整权限),实际匹配到了'app'@'%'(只有只读),那当然INSERT失败。这种情况要么删掉%的那条,要么调整host顺序。

**方向二:角色没激活。**如果用了Role机制,SHOW GRANTS能看到角色但不生效,八成是漏了SET DEFAULT ROLE。验证方法:手动SET ROLE 'role_app_rw';再跑一次SQL,如果成功了就是这个问题。

**方向三:权限层级被覆盖。**前面说过,全局权限和库级权限是并集关系,REVOKE库级权限对全局授权无效。另外partial_revokes没开的情况下,没法在全局授权上做减法。

还有一种隐蔽情况:表名大小写。Linux上lower_case_table_names=0时,表名严格区分大小写。授权时写的表名和实际表名大小写不一致,权限就匹配不上。这个在从Windows迁移过来的项目里特别常见。

5.3 常见问题速查表

现象最可能的原因快速验证解决
新建账号立刻能连,改密码后连不上密码写错/特殊字符转义手工mysql -u -p连一次用单引号包密码,检查#、@等字符
授权后应用仍报无权限角色未激活/匹配到别的hostSELECT CURRENT_USER()SET DEFAULT ROLE或清理重复账号
GRANT报错1396账号不存在查mysql.user先CREATE USER再GRANT
密码过期导致凌晨告警PASSWORD EXPIRE到期查password_expired字段用双密码机制轮换
监控账号看不到慢SQL缺PROCESS权限SHOW GRANTS补授PROCESS,限来源IP
从库同步报权限错误复制账号缺REPLICATION SLAVE查复制状态GRANT REPLICATION SLAVE ON *.*
导入dump文件失败DEFINER指向不存在的用户看报错里的用户用--no-create-info或改DEFINER

最后一条值得展开。用mysqldump导出的文件里,视图、存储过程、触发器会带DEFINER='someuser'@'somehost',导入到新环境时如果这个用户不存在,就会报ERROR 1449。处理方式有两种:导入时用sed把DEFINER去掉,或者在新环境先把对应的用户建出来(哪怕是空权限的占位账号)。我一般选后者,因为DEFINER在权限模型里是有意义的,硬删掉可能导致执行时的权限语义变化。

5.4 几条踩出来的经验

第一条,给账号加注释。MySQL 8.0的CREATE USER支持COMMENT '订单服务生产读写',这个注释会存在mysql.user的User_attributes字段里。半年后你自己都记不清app_x7是干什么用的,有个注释能省多少事。盘点时一条SQL就能导出所有账号的用途说明。

第二条,别在业务账号上用WITH GRANT OPTION。有人为了让某个服务能给别人授权,加了这个选项,结果这个账号具备了权限扩散能力——它能把自己有的权限授给任意其他账号,相当于一个特权放大器。真需要这种场景,用dba_deploy专门做,日志里看清楚是谁授的。

第三条,改密码前先确认没有硬编码在配置中心之外的地方。我见过密码写在Dockerfile的ENV里、写在K8s的Secret里、写在某个shell脚本里、还写在某个运维同事的记事本里,四份。改完三份,第四份导致两周后一个定时任务静默失败。改密码前先grep -r一遍代码仓库和配置仓库,比改完再救火省心得多。

第四条,权限变更要有变更记录。MySQL的general log能记录所有语句,但开销大不适合常开。折中方案是把所有授权操作收敛到一个运维脚本,每次执行追加写入一个审计文件,记录谁在什么时间给谁授了什么。这个习惯在出了安全事件需要溯源时,价值无法估量。

第五条,也是最实际的:把上面这套流程固化成SQL模板。我团队里维护了一份provision_user.sql,参数化用户名、host、库名、角色类型,执行时替换变量。新手不用记语法,照着模板走就不会踩那些边角坑。模板比文档管用,文档没人看,模板是直接跑的。

这套东西写下来,核心其实就一句话:MySQL的账号不是一个"用户名",而是"用户名+来源主机+权限集合+认证方式+生命周期策略"的完整实体,每个维度都得显式定义,默认值往往不是你想要的那个。把这些维度想清楚了再动手,比事后排查省太多力气。

返回列表