ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL用户管理实战:权限边界、账号生命周期与安全基线

MySQL用户管理实战:权限边界、账号生命周期与安全基线

干数据库运维这些年,我见过太多把 MySQL用户管理 当“附属功能”的操作:建库建表挺熟练,一说到账号权限就只记得 create user 和 grant 两条命令,剩下的全靠临时搜。真正等到线上跑起来,才发现权限设计是个可以让人凌晨三点起来救火的坑。MySQL用户管理看上去只是几个SQL,实际上背后藏着一整套账号生命周期、权限边界、认证机制和日常审计的逻辑。这篇不打算把官方文档抄一遍,我更想从实际干活的角度,把创建账号、授权、回收、改密、排障和安全基线这些事讲清楚。不管你是刚接手MySQL的新人,还是已经写了很久SQL但从来没认真管过权限的同学,这篇下来应该能少踩几个坑。

1. 动手建账号之前,先想清楚权限边界

1.1 先给账号分角色

一个MySQL实例通常不是只有一个人在用,尤其当一个实例同时服务多个业务线时,账号如果混着用,出问题连排查方向都没有。我在实际管理中最常用的划分是三种角色:管理账号负责建库、授权、调参数和变更,相当于机房的钥匙管理员,数量必须少,使用场景固定;应用账号给后端服务使用,只开放业务正常运行所必需的增删改查操作;只读账号给数据分析、临时排查或者报表拉数用,连 insert、update 都不应该给。把账号按角色分开,最大的好处不是审计好看,而是让“权限边界”变得可讨论。出了问题,至少能根据账号名判断这次操作是哪个环节发出的。

光笼统地讲角色不够,还应该把角色落到主机维度。我见过不少团队把所有账号都建在'%'下面,觉得这样最方便,哪里都能连。但实际上,主机限制是MySQL账号非常重要的一个安全边界。同一个用户名,配不同的 host,完全可以拆成管理入口和内网入口两组账号。后面我会专门讲 user+host 的存储逻辑,这里先记住一条原则:能在 host 上锁死的地方,就不要靠密码单独防护。

1.2 最小权限不是一句口号,要落到具体SQL

很多同学把“最小权限”理解成“不要给root”,以为只要不用超级管理员就行。其实不是。最小权限的意思是:只授予完成当前任务所必需的最小操作集合,多一个 update 都不行。比如一个只负责查询报表的账号,select 就够,不需要 insert;一个给普通导数的账号,如果有 select 和 insert 就够,就不该给它 delete。这类约束落到SQL上,就是用明确的权限列表代替权限集合。

看一个例子:应用服务需要读写appdb库,但不允许它删库、改表结构、删除数据。那么正确的授权应该是:

GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'app_user'@'192.168.10.%';

而不是简单粗暴地:

GRANT ALL PRIVILEGES ON appdb.* TO 'app_user'@'192.168.10.%';

这两者差别很大。前者把操作限制在查询、插入、更新、删除四种DML上,后者则把建表、删表、索引管理、权限管理等也一并交了出去。你可能会说,反正都是开发写的代码,给 ALL 也没什么。但现实是:只要权限给得宽,就一定会有人在不恰当的时机用上它。比如一次误操作把整张表 drop 掉,或者因为连接串写错,把线上库当成测试库清理。把权限收敛到 DML,至少能把这类事故的爆炸半径切小一圈。

1.3 动手之前先回答五个问题

我自己在任何环境创建账号前,都会先过五个问题,不管这个环境是刚装的测试库还是正式库:

  • 这个账号是给谁用的:应用、人,还是临时脚本?
  • 它从哪里连:本机、应用服务器IP段,还是办公网?
  • 它需要访问哪些库、哪些表,需要哪些操作类型?
  • 它需要活多久:永久账号、季度账号,还是随项目结项就删除?
  • 权限变更有没有记录:谁在什么时间、基于什么原因加了这个权限?

这套自问清单不是为了走流程,而是为了避免用户管理变成“谁说话好用就多开权限”的糊涂账。我遇到过不少线上事故,最后复盘的时候都会发现:那个出问题的账号,多余的一项权限是某次调试顺手加上的,调试完了没人收。所以我的建议是,每个账号创建之前至少花两分钟回答上面五个问题,然后在权限台账里留一行记录。后面做审计和安全整改的时候,这五分钟能省出好几个小时。

2. 账号在系统里到底怎么存:mysql.user与权限叠加逻辑

2.1 一个MySQL账号其实是“用户名+主机”的组合

如果只看 create user 这行命令,可能觉得账号就是个字符串。但实际上,MySQL判断一个连接属于哪个账号,靠的是两个信息:用户名和来源主机。'u1'@'localhost'和'u1'@'%'在MySQL眼里是两个完全不同的账号,密码可以不同,权限可以不同,甚至同名账号可以同时存在。

这一点在排障时特别重要。假设你新建了'monitor'@'192.168.10.%',然后用应用服务器连接却报权限不足,第一反应可能是密码错了,但更可能是:库里同时存在一个'monitor'@'localhost',MySQL优先匹配了它,而它又没有对应权限。查询当前实例上的账号组合,用下面这条SQL:

SELECT user, host FROM mysql.user;

你会看到一排排相同的 user 对应不同的 host,这就是账号的真实构成。平时管理账号时,任何针对账号的操作都要写全 user+host,只写用户名很容易操作错对象。我习惯把所有账号对的 user 和 host 放在一个表里维护,权限变更时先查出完整格式,再决定动哪个。

2.2 权限不是一张表,而是多个层级叠加

账号信息主要存在mysql库里,但权限并不会全部塞进一张表。MySQL把权限按作用范围拆成了几个层级:全局权限在mysql.user表,数据库级权限在mysql.db表,表级权限在mysql.tables_priv表,列级权限在mysql.columns_priv表,存储过程和函数相关的权限在mysql.procs_priv表。当执行 grant 语句时,MySQL会根据授权对象自动决定写入哪张表。

多层权限并不是“覆盖关系”,而是“叠加关系”。校验权限时,只要任意一个层级允许这个操作,就能通过。比如某个账号在全局权限里没有 insert,但在appdb库权限里有 insert,那它照样可以往 appdb 的普通表插数据。反过来,如果在表级权限里被精确限制,又会影响更宽的范围。理解这一点,才能解释为什么有时候你 revoke 了一个数据库级权限,却发现账号还是能操作:很可能是同一个操作权限在别的层级仍然存在。

下面这张表是我自己做权限盘点时经常对照的:

权限层级存储表授权命令示例影响范围
全局mysql.userGRANT SELECT ON....所有库所有表
数据库mysql.dbGRANT SELECT ON appdb.* ...指定库下所有对象
表mysql.tables_privGRANT SELECT ON appdb.orders ...指定表
列mysql.columns_privGRANT SELECT (name) ON appdb.users ...指定列

2.3 flush privileges 究竟要不要执行

只要讨论MySQL用户管理,几乎一定会碰到“flush privileges”这个话题。很多人习惯在 grant 之后执行一次 flush,认为这样权限才能生效。这个习惯在一些老版本的环境里很常见,也确实是历史遗留习惯带来的。

准确说法是:如果你用 create user、grant、revoke、drop user 这类账号管理语句修改权限,MySQL默认会同步刷新授权表缓存,不需要额外执行 flush privileges。真正需要 flush 的场景,是有人直接写了 INSERT、UPDATE、DELETE 操作mysql.user或mysql.db授权表,比如用一段脚本手工改密码字段,这种绕过SQL管理语句的修改不会触发自动刷新,必须执行 flush privileges 才能让运行内存里的权限信息与表保持一致。

所以我的结论很简单:能用官方账号管理语句做的事,绝对不要手工改授权表;如果已经手工改了,改完必须立刻 flush privileges。至于那些“grant完顺手flush一下”的操作,多做一次不会出大问题,只是没必要。排障时如果认为权限没生效,不要急着 flush,先执行SHOW GRANTS FOR 'user'@'host';看看当前授予情况,再判断是缓存问题还是权限本身没给对。

3. 建号、授权、改密、回收:常用命令逐条拆解

3.1 创建用户:别让密码出现在撒得到处都是的地方

创建用户的语法本身不复杂:

CREATE USER 'app_user'@'192.168.10.%' IDENTIFIED BY '这里写强密码';

这段SQL的意思很明确:创建一个只允许来自192.168.10.%网段连接的账号,登录密码是后面那一串。比起直接操作 mysql.user 表,这种写法更安全、也更可追溯。指定 host 时,localhost代表本机连接,%代表任意主机,192.168.10.%代表某个内网网段。虽然MySQL支持 host 的部分匹配和IP段写法,但建议在能用整数网段收敛的时候,不要写成%,尤其线上环境。

有个细节值得特别说:MySQL里 CREATE USER 语句里的密码是明文状态。如果直接在命令行里敲,那么 shell 历史记录里就会留下它;如果写在脚本里,脚本本身要做好权限管理。我一般不建议把密码直接写死在SQL脚本然后随便丢给同事,更推荐通过变量注入或者由配置管理工具来渲染,至少也要在脚本完成使命后立即从版本库里移除敏感信息。密码本身还需要满足实例配置的密码策略,比如长度和复杂度,这部分我在第5节再展开。

3.2 授权语句不是背下来就完事,关键在对象层级

创建完账号后,可以给它授权。一条 grant 语句由几个核心部分组成:权限列表、授权对象、目标账号。比如:

GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'app_user'@'192.168.10.%';

这里ON后面的部分是授权对象。appdb.*表示 appdb 库的所有表;如果换成*.*就是全局所有库所有表;如果只给单张表,写作appdb.orders。实际项目中要慎用*.*,特别是给业务账号时。我在检查线上环境时,最怕看到的不是密码弱,而是业务账号带着SELECT, INSERT, UPDATE, DELETE ON *.*这种配置,一旦某个库被脱库或误操作,影响范围会成倍放大。

另外,WITH GRANT OPTION表示允许这个账号把拥有的权限再授予别人。听起来很方便,但绝大多数业务账号完全不需要。一旦给出去,账号就成了权限扩散的源头,后面查“谁能授权”就查不清了。可以把WITH GRANT OPTION看成备用钥匙:只有少数管理账号需要,普通应用账号一律不加。

3.3 修改密码的三种写法和真正的坑

账号总是要改密的,常见是定期轮换、员工离职、密码泄露。MySQL里推荐的做法是:

ALTER USER 'app_user'@'192.168.10.%' IDENTIFIED BY '新密码';

这是官方推荐的写法,能兼容不同认证插件。旧版常见写法SET PASSWORD FOR 'user'@'host' = '新密码';虽然在某些版本还能用,但已经属于被逐步淘汰的语法,新项目不建议再写。最差劲的做法是直接 UPDATE mysql.user 表改 authentication_string 字段,这样既容易写错编码,又可能绕过密码校验逻辑,还容易忘了 flush,百害而无一利。

改密码真正容易踩的坑不在SQL写法,而在改完之后线上应用连不上。这不是SQL写错了,而是业务侧连接池里可能还持有旧连接,或者应用配置里写的是旧密码。改完密码最好同步通知业务方,并留出一个短的切换窗口。还有一点:MySQL 8.0默认使用 caching_sha2_password 认证插件,老版本客户端可能报“Authentication plugin 'caching_sha2_password' cannot be loaded”之类的错,这通常不是密码问题,而是客户端驱动版本太老,后面我会专门讲。

3.4 回收权限和删除账号的顺序要讲究

日常维护中,撤销权限和清理账号比创建账号更容易被忽略。一个账号不再使用时,正确顺序是先把权限收回来,再确认没有活跃连接,然后再 drop 掉。

REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'app_user'@'192.168.10.%'; DROP USER 'app_user'@'192.168.10.%';

先 revoke 再 drop,看起来多此一举,但好处是分开操作能防止误删。如果直接 DROP USER,虽然账号会被移除,但已经建立的长连接不会立刻断开,直到连接结束或进程被杀。如果你要清理的是一个正在被应用使用的账号,先 revoke 会让新连接失败,再配合把已有连接 kill 掉,才能算真正把入口关上。对应地,临时账号用完随手清理,这个习惯能减少很多潜在暴露面。我会在权限核查清单里再展开谈。

4. 新环境上线前的权限核查清单

4.1 先盘点当前有哪些账号

接手一个新环境或者准备新系统上线时,我不会直接开始写业务表,而是先花10分钟盘一盘账号。盘点第一条命令就是:

SELECT user, host FROM mysql.user;

输出里能看到实例当前存在的所有账号组合。需要注意,有些是MySQL内置账号,比如mysql.infoschema、mysql.session等,这些是系统运行需要的,不要误删。真正需要人工判断的是那些看起来像业务账号、运维账号或遗留账号的记录。如果发现某些账号的密码是空的,或者 host 是%,要立刻标红。

盘完账号之后,最好把每个账号的权限也导出留档。我在项目里经常这么干:把mysql.user、mysql.db、mysql.tables_priv三张表里与该业务相关的记录导成一份权限台账,放到专门的运维文档目录里。以后无论做安全审计还是权限变更,都拿这份台账做基线。

4.2 权限初审重点看几项

权限初审最怕平均用力,看到什么都觉得有问题。我给自己定了一套重点检查顺序,基本能覆盖90%的隐患:

检查项查询方式问题现象整改建议
空密码账号SELECT user, host, authentication_string FROM mysql.user;密码字段为空,可直接登录立刻设置密码或删除
过于宽泛的hostSELECT user, host FROM mysql.user;业务账号host为'%'收敛为IP段或localhost
全局大权限SHOW GRANTS FOR 'user'@'host';业务账号带ON.改为库级或表级
授权扩散SHOW GRANTS ...账号带WITH GRANT OPTION移除GRANT OPTION
过期账号SELECT user, host, account_locked FROM mysql.user;已下线的项目账号仍存在停用或删除

这套检查不依赖任何外部工具,一条一条执行就行。线上做权限整改时,务必挑业务低峰期执行,并且先在一个测试账号上模拟一遍。

4.3 几种常见“授权过宽”的收敛思路

权限过宽的场景,总结起来其实就那么几种,但每一种都值得单独处理。

第一种,开发图省事,所有应用共用 root 连接。这种最危险,root 拥有所有库的所有权限,任何连接串泄露都等于整个实例泄露。收敛方式是新建一个只读账号和一个读写账号,把应用连接串切换到业务账号上,root 只保留给管理员在管理主机上使用。

第二种,报表账号被开了 update、delete。只读账号给 select 就够了,最多加 show view。报表任务一般不会写数据。如果某个报表流程确实需要临时写表,也应该通过独立的临时账号完成,任务结束就回收。

第三种,历史账号的 host 是%。有些环境从测试期就一直是这个配置,上线后也没人管。收敛方案是改成应用服务器所在的IP段,管理操作则走限制更严的本机或跳板机入口。修改 host 后要注意紧接着改对应的授权语句,否则权限会落到错误的账号组合上。

5. 密码策略、认证插件与远程连接的隐藏坑

5.1 密码复杂度与过期时间不是可有可无的配置

很多MySQL实例初始化之后,默认密码策略状态比较宽松,很多人就一直用弱密码跑着。等到某个账号因为密码太弱被爆破,后悔就晚了。MySQL提供了密码校验组件,可以强制要求密码长度、大小写、数字和特殊字符。开启之后,创建用户或修改密码时如果强度不够,会直接报错。具体怎么安装组件,不同版本有些差异,但目标是一样的:让弱密码在执行阶段就失败,而不是等出事再查。

除了复杂度,密码过期策略也建议设置。比如希望账号密码90天必须更换,可以这样配置:

ALTER USER 'app_user'@'192.168.10.%' PASSWORD EXPIRE INTERVAL 90 DAY;

这里要特别提醒:密码过期后,应用连接大概率会报错或者登录失败。如果业务方不知道这个策略,突然有一天连接断掉,排查起来会很痛苦。所以设置过期策略前,要么同步给业务方,要么配合密码过期提醒的监控一起做。不要只改策略不通知,否则就是给自己埋一个凌晨告警。

5.2 认证插件选错,连接直接报错

MySQL在识别用户密码时,会使用认证插件。MySQL 8.0默认是caching_sha2_password,比老版本默认的mysql_native_password更安全,主要体现在密码哈希处理和连接握手流程上。但问题也出在这里:一些老语言驱动或者老客户端没有实现新插件,连接时会直接报认证插件不支持的错。

面对这种情况,正确解法是优先升级客户端驱动或连接库,让它们支持新认证插件。如果因为历史原因一时半会儿升不了级,可以考虑在创建账号时显式指定老插件:

CREATE USER 'legacy_app'@'192.168.10.%' IDENTIFIED WITH mysql_native_password BY '密码';

不过我不建议把整个实例的默认认证插件改回旧版,因为那样等于为了兼容性而牺牲整体安全性。尽量在账号级别做定向兼容,并且给这种老插件账号单独标注,尽快纳入升级计划。

5.3 远程访问的管理姿势

远程连接是用户管理里最容易出问题的一环。数据库本身是数据资产,3306端口一旦直接暴露到公网,等于把钥匙挂在门口。安全底线是:3306端口不对外网开放;业务访问通过内网IP段或者跳板机转发;管理操作只允许从跳板机发起。如果实在需要远程直连,至少要用防火墙把源IP限定在固定出口IP。

账号 host 字段也应该配合这个思路。应用账号只给应用服务器所在网段,管理账号只给运维跳板机所在网段,任何人要直连数据库都得先经过跳板机这一层。启用SSL连接是另一个容易被忽略的点,它能在传输层加密应用与数据库之间的通讯,减少数据在链路上被截获的风险。权限给得再准,如果链路是裸奔的,前面所有工作都会打折扣。

6. 翻车现场与日常运维三板斧

6.1 误删账号之后怎么办

用户管理做得再细,也避不开偶尔的手滑。有一次某个环境清理闲置账号,脚本里的 host 写错了,把一个正在用的账号也 drop 了。幸运的是连接已经存在,所以应用没有立刻全挂,但新发起的连接全部失败。这时候如果手里没有权限台账,恢复就比较痛苦:需要重新创建账号、重新授权,还要确认密码策略要求,很难和原来一模一样地复原。

我的建议是,这类操作前先导出一份账号和权限备份:

SHOW GRANTS FOR 'app_user'@'192.168.10.%';

再把所有账号的 grant 结果汇总保存。出了问题,就能照着之前的 grant 信息一条条重建。对清理类操作,先用 SELECT 查一遍要 drop 的账号列表,确认无误后再执行 DROP,这是最基本的保命流程。删除账号本身不可怕,可怕的是删除之前没有留下一张“如果删错了能恢复”的牌。

6.2 三个高频报错的实际排查思路

用户管理相关的报错,我总结下来就三类最常碰到。

第一类是Access denied for user 'xxx'@'ip' (using password: YES)。这句报错只说明一件事:MySQL拒绝了这次连接。原因可能是账号不存在、密码不正确、host匹配不上,或者虽然有账号但权限不足。排障顺序是:先查 mysql.user 里有没有这个账号组合,再看密码是否正确,最后用 show grants 看权限。不要一看到 Access denied 就以为是密码错,有时候是 host 不匹配,导致MySQL匹配到了另一个同名但无权限的账号。

第二类是Host 'ip' is not allowed to connect to this MySQL server。这个报错很直白,是 host 限制挡掉的。处理方式就是确认合法的连接来源IP段,然后修改对应账号的 host,或者新增一个匹配该来源的账号组合。

第三类是Can't connect to MySQL server on ...,问题往往不在用户管理,而在网络层,需要检查3306端口是否监听、防火墙是否放行、目标主机是否能路由到。不要拿着用户权限的问题去查半天网络,排障时要先判断报错属于账号层还是链路层。

6.3 把用户管理脚本化,减少手滑概率

用户管理如果不做流程化,每次都是人肉敲命令,很容易出现权限发错、host写错、忘记回收这类问题。我的习惯是,任何涉及账号的变更都写成一段可重复执行的SQL脚本,放在版本库里走评审流程。比如新增一个应用账号,对应脚本可以长这样:

CREATE USER IF NOT EXISTS 'app_user'@'192.168.10.%' IDENTIFIED BY '强密码'; GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.* TO 'app_user'@'192.168.10.%';

用IF NOT EXISTS可以避免重复执行时报错。权限变更也用同样的思路:先 revoke 再 grant,最后 show grants 验证。整个变更过程用一条条SQL留在记录里,比谁都不记得当初给了什么权限要强得多。流程看起来很笨,但在事故复盘时却能帮大忙。用户管理这种工作,功夫都在平时,真的等账号出了问题再补救,成本远高于一开始就把边界定清楚。

我的个人习惯是,每次接手一个新MySQL环境,第一周做的事里一定有权限摸底,而且会把它做成一份持续更新的台账。给权限容易,收权限难,MySQL用户管理里最值钱的经验,不是会写多少条 grant,而是知道哪些权限不该给、什么时候该收。如果你准备开始整理自己的账号体系,建议从最小的只读账号开始动手,把每个账号的身份、来源、权限和负责人写清楚。后面无论是安全审计还是事故排查,你都会感谢这份朴实无华的清单。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进