首页 / 资讯中心 / 文章详情

MySQL视图从建到治理:MERGE、物化与DEFINER避坑

MySQL视图从建到治理:MERGE、物化与DEFINER避坑 ★ FEATURED ARTICLE
一位同行在评审时把一段八十多行的多表 JOIN 包成了视图理由是“以后谁要用都方便”。三个月后那张报表慢了近三倍排查了半天才发现问题出在视图内部的 GROUP BY 让外层查询的谓词没法下推。这类事情我见过不止一次。视图View在 MySQL 里是个门槛很低的语法糖CREATE VIEW 一行就能建起来但要把它用对、用稳涉及查询改写、临时表物化、更新映射、权限身份、版本迁移这一整条链路任何一环没想清楚后面都要花几倍时间来还债。这篇内容讲的是 MySQL 视图从建到用再到治理的完整认识。它适合已经会写 SELECT、准备在项目里引入视图的人也适合接手了一套历史库、发现里面几十个视图互相嵌套、想搞清楚到底哪一层在拖慢查询的人。我会从视图在磁盘上到底存了什么讲起然后把可更新视图的判定规则、EXPLAIN 里怎么分辨合并与物化、DEFINER 迁移的坑、依赖梳理的具体做法一个个拆开中间会给出可以直接复制运行的建表和查询语句。基础一般的读者也能顺着跑一遍有经验的可以直接跳到第五、第六节看性能与权限部分。1. 视图到底存了什么一条被固化的查询语句很多人第一次理解视图时的直觉是“它像一张表数据被放在别处”。这个直觉在 MySQL 里是错的而且这个错误会直接导致后面所有的性能误判。视图在 MySQL 中不保存任何行数据它保存的只是一段经过解析和规范化的 SELECT 定义文本。你查询视图时MySQL 在解析阶段把这句定义文本拿出来要么拼进你的外层查询里一起优化合并算法要么先单独执行一遍把结果放临时表里、再拿临时表当数据源物化算法。两种路径的代价差得非常远。1.1 建一张视图看看数据字典里存了什么先造一个最小环境把过程走一遍。下面这套表结构后面几节都会复用建议直接在测试库跑起来。CREATE DATABASE IF NOT EXISTS shop_demo DEFAULT CHARSET utf8mb4; USE shop_demo; CREATE TABLE users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, nick VARCHAR(32) NOT NULL, phone VARCHAR(20) NOT NULL, city VARCHAR(32) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1 ); CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, pay_status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_created (created_at) );建视图的语法本身没什么好说的关键在于CREATE VIEW的时候就要显式写出ALGORITHM不要依赖默认的UNDEFINED。CREATE ALGORITHMMERGE VIEW v_user_brief AS SELECT id, nick, city FROM users WHERE status 1;建完之后看两样东西。第一样是SHOW CREATE VIEW v_user_brief\G你会看到 MySQL 把定义重写成了带反引号、带库名限定的完整形式原始的空格、大小写、注释全被抹平了。这个细节很重要它意味着视图定义的文本和你写的文本不是一回事做批量替换的时候不能按自己写的原文去匹配。第二样是数据字典SELECT TABLE_SCHEMA, TABLE_NAME, IS_UPDATABLE FROM information_schema.VIEWS WHERE TABLE_SCHEMA shop_demo;IS_UPDATABLE是 MySQL 8.0 提供的能力能直接告诉你这个视图能不能做 INSERT/UPDATE/DELETE省掉了大量试一下就报错的时间。在 5.7 上这个字段可能不存在或者不准确那就只能靠后文第四节列的规则去推。1.2 MERGE 与 TEMPTABLE两条完全不同的执行路径ALGORITHMMERGE的含义是把视图定义当作一个宏和外层查询合并成一条完整的 SQL然后统一生成执行计划。你写SELECT * FROM v_user_brief WHERE city杭州优化器看到的实际等价于SELECT id, nick, city FROM users WHERE status1 AND city杭州city上的索引照常用status上的过滤也能走到。ALGORITHMTEMPTABLE则完全不同先独立执行视图定义的 SELECT把结果写进一张没有索引的临时表再在外层对这个临时表做过滤。上面那个查询如果走物化就是先把所有status1的用户全捞出来落到临时表假设有五十万行再从这五十万里筛杭州。临时表默认没有索引这个动作的代价可以从毫秒级跳到秒级。选择哪一种由ALGORITHM参数和视图定义本身共同决定。就算你写了MERGE只要定义里出现了下面这些结构MySQL 也会退回到物化而且不会给你任何警告。定义中出现的东西能否合并后果聚合函数SUM、COUNT、MAX 等不能必然物化谓词无法下推GROUP BY / HAVING不能先聚合再过滤扫描量放大DISTINCT不能需要去重只能物化UNION / UNION ALL不能多分支结果合并到临时表SELECT 列表里的子查询不能每行一个标量子查询代价高LIMIT不能视图先截断语义可能与预期不符定义里完全没有表引用不能只能物化视图嵌套了另一个不可合并的视图不能逐层物化层数越多越糟这张表是本文里最该记下来的东西。它解释了一个很常见的困惑同样是视图为什么有的查起来和直接写表一样快有的慢十倍。提示不要凭记忆判断能不能合并一律用EXPLAIN看实际的执行计划。MySQL 8.0.16 以后用EXPLAIN FORMATTREE看得最清楚合并成功时输出里直接出现基表名物化时会出现derivedN这样的节点。2. 什么场景值得用视图什么场景是给自己挖坑视图不是必须用的东西也不是用了一定好的东西。在真实项目里我用视图基本只在四种情况其它时候宁可多写几行 SQL。2.1 三种真正划算的用法第一种是收敛字段做脱敏。用户的手机号、身份证、地址这类字段业务方经常需要核对但不需要看到全量。与其在每个查询里写CONCAT(LEFT(phone,3),****,RIGHT(phone,4))不如建一个专供外部查询的视图只暴露id、nick、masked_phone三列然后把这个视图的 SELECT 权限给出去基表权限一点不给。这样即便对方拿到账号也无法直接SELECT phone FROM users。这属于权限设计层面的收益跟性能无关但价值很实在。第二种是屏蔽表结构变更。老系统里一张orders拆成了orders加order_ext两张表但下游有七八个报表代码还在查orders单表。重写这七八处代码风险高、排期长此时建一个同名兼容层的做法是把老表改名成orders_bak再建一个叫orders的视图内部 JOIN 上order_ext并把字段拼回老结构。新代码走新表老代码走视图慢慢迁移。这种视图当胶水的用法在改造期非常省事。第三种是固化复杂口径。比如有效订单的定义是pay_status IN (1,2) AND amount 0 AND created_at 2023-01-01这串条件散落在二十个查询里某天业务改了规则你得去改二十个地方。把它封进一个视图口径就只在一个地方维护。还有一个次要收益是让 SQL 更短但我不太看重这一点。SQL 短不代表可读性好看到v_report_daily这种名字新人根本不知道里面有什么口径、有没有过滤、扫多少行。可读性上的收益往往小于它带来的理解成本。2.2 三类不该用视图解决的问题第一类想让查询变快。这是我见过最普遍的误解。视图不缓存结果、不存数据、不能建索引它唯一可能带来的性能变化是变慢。真要加速用的是索引、汇总表配合定时任务刷新、或者把复杂查询的中间结果落到物理表里。视图在这件事上帮不上忙。第二类想要参数化查询。MySQL 的视图定义里不能带占位符WHERE created_at ?这种写法直接就是不合法语法。有人会想歪招比如在视图里引用用户变量start_date这在不同版本和不同客户端下行为都不稳还会把执行计划缓存搞糟。需要传参就用存储过程或者在应用层拼 SQL。第三类做多表关联的中间层。见过有人把五张表的 JOIN 封成视图v_order_full然后在上面再封v_order_summary再在上面封v_order_report。三层视图嵌套EXPLAIN出来的执行计划长得没法读加一个索引都不知道该加在哪。这种结构一旦出问题排查时间是单层写法的好几倍。3. 从零跑一遍报表视图的完整构建与验证光讲不做容易漏细节。这一段我把一个日汇总报表视图从建表到验证完整跑一遍中途踩的坑也一并说明。3.1 造数据与第一版视图先插一点测试数据。为了模拟量级用递归 CTE 生成一万行订单在 MySQL 8.0 下可以直接跑。INSERT INTO users (nick, phone, city, status) VALUES (alpha,13800000001,杭州,1), (beta,13800000002,上海,1), (gamma,13800000003,杭州,0); INSERT INTO orders (user_id, amount, pay_status, created_at) WITH RECURSIVE seq AS ( SELECT 1 AS n UNION ALL SELECT n1 FROM seq WHERE n 10000 ) SELECT (n % 3) 1, ROUND(10 (n % 500) * 0.1, 2), n % 3, DATE_ADD(2024-01-01, INTERVAL (n % 120) DAY) FROM seq;现在写一个每日已支付订单汇总的视图只统计pay_status1的单子按天聚合。CREATE ALGORITHMMERGE VIEW v_daily_paid AS SELECT DATE(created_at) AS d, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders WHERE pay_status 1 GROUP BY DATE(created_at);这里第一个坑就来了定义里有GROUP BY和聚合函数ALGORITHMMERGE根本做不到MySQL 会静默降级成 TEMPTABLE。你写了 MERGE 也没用也看不到任何提示。验证方式只有一个。EXPLAIN FORMATTREE SELECT * FROM v_daily_paid WHERE d 2024-04-01;输出里如果出现derived2这个节点就说明物化了。这时候你再加WHERE d 2024-04-01这个条件它是在一万行聚合结果之上过滤的没办法提前到基表去减扫描量。对于小表无所谓对于千万级订单表这就是典型的视图一上报表就慢。3.2 写视图定义时容易被忽略的三个细节第一个细节是SELECT *的危害。MySQL 存储视图定义时保存的是文本形式*在每次执行时按基表当时的列展开。这意味着基表后来加了一列视图会自动多出这一列而依赖这个视图的下游代码如果用了INSERT INTO ... SELECT *列数就对不上了。这种故障的诡异之处在于加字段的当天没事第二天定时任务跑的时候才炸。所以建视图时一律显式列名这是个不需要犹豫的习惯。第二个细节是定义里的ORDER BY。视图里的排序在外层查询自己带ORDER BY时会被忽略在与其它表 JOIN 时也可能不生效因为关系模型里行本身是无序的。指望视图帮你排好序是个典型的幻觉。排序永远写在外层。第三个细节是视图的字符集和排序规则。视图的character_set_client和collation_connection取的是创建那一刻会话的值。如果建库时是utf8mb4_general_ci某个人用utf8mb4_0900_ai_ci的会话建了视图视图中字符串比较和排序的行为就会和其它表不一致出现同一份数据查视图结果顺序不同这种奇怪现象。稳妥做法是在建视图的脚本开头统一SET NAMES utf8mb4 COLLATE utf8mb4_general_ci;别让环境差异渗进来。3.3 WITH CHECK OPTION 究竟在防什么带过滤条件的视图如果不加WITH CHECK OPTION是可以往里插看不见的行的。举例CREATE ALGORITHMMERGE VIEW v_active_user AS SELECT id, nick, city, status FROM users WHERE status 1 WITH CHECK OPTION;有了这个子句INSERT INTO v_active_user (nick, city, status) VALUES (x,杭州,0)会直接报CHECK OPTION failed因为插进去的行不满足status1插完就查不到逻辑上自相矛盾。没有这个子句的话这条 INSERT 会成功但插完之后SELECT * FROM v_active_user里找不到它非常容易让人怀疑数据丢了。WITH CHECK OPTION有两个级别CASCADED是默认值会递归检查所有底层视图的条件LOCAL只检查当前视图自己的条件不往下推。多层嵌套视图时才需要区分实际项目里我基本都用默认值因为LOCAL的语义太容易理解错。要注意的是这个子句只对 INSERT 和 UPDATE 生效DELETE 不受影响。4. 可更新视图的边界哪些行能改哪些一改就报错视图能不能写是实际使用中最容易翻车的地方。很多人是通过报错来学习规则的报一次改一次效率极低。把判定规则提前搞清楚能省掉大量试错。4.1 一条硬性原则能不能一对一映射到基表行底层逻辑只有一条视图里的每一行必须能唯一对应到基表里的某一行。满足这条更新才可能成立。由此推出的禁止项很直观。聚合和GROUP BY让一行视图对应多行基表改一行不知道该改谁禁止DISTINCT把重复行合并了改一行同样对不上原行禁止UNION拼了多张表禁止SELECT列表里带子查询值不是从基表直接来的禁止视图里只有字面量SELECT 1 AS a没有表禁止同一个基表在FROM里出现两次比如自连接禁止。还有一个容易被忽略的如果视图的定义算法退化成了 TEMPTABLE那它一定不可更新。原因很直白物化出来的临时表跟你查出来的结果集一样是只读快照往快照里写东西没有意义。所以第 1.2 节那张不能合并的表同时也是不能更新的表两张表重合度很高。实际开发中还有一个坑视图定义本身可更新但依赖的底层视图不可更新那上层也不可更新。嵌套视图的更新能力会逐层衰减不容易一眼看穿。4.2 提前判断而不是等报错最省事的做法是直接查数据字典SELECT TABLE_NAME, IS_UPDATABLE FROM information_schema.VIEWS WHERE TABLE_SCHEMA shop_demo ORDER BY TABLE_NAME;建视图的时候就顺便看一眼把结果为NO的视图在注释里标注只读。团队里最好形成约定只读视图统一加_v或者_readonly后缀写视图不加。名字上就能看出能不能改比翻文档快得多。真的遇到更新报错时错误信息是The target table v_xxx of the UPDATE is not updatable。看到这句不用去搜回到第 4.1 节逐条对照八成能定位到是GROUP BY还是DISTINCT惹的。注意不要试图用触发器给视图做INSTEAD OF逻辑。MySQL 不支持在视图上建触发器这个能力在其它数据库里是有的迁移的时候很容易想当然。5. 性能真相视图不开加速器只增加理解成本这一节是我最想说的部分因为线上真正的性能事故一半以上跟这一条有关。5.1 用 EXPLAIN 分辨合并与物化判断标准非常明确。合并成功的视图EXPLAIN输出的行里只有基表名跟你直接查基表一模一样type、key、rows都一致。物化的视图会出现derived2这样的节点select_type是DERIVED同时外层查询的rows会突然变得很小或者很大而filtered很低。拿第 3.1 节的v_daily_paid举例如果外层的过滤条件没能在基表生效你会看到一个明显信号derived2那行的rows等于聚合后的天数一百多行而它内部其实扫了一万行基表。这说明过滤发生在聚合之后。遇到这个信号优化手段不是给视图加索引加不了而是二选一。第一个选择是把过滤条件提到视图内部没法做的事交给调用方即让调用方直接查基表。第二个选择是退一步用一个真正落地的汇总表替代视图配合定时任务或者事件调度按小时刷新。汇总表能建索引、能加覆盖索引、能做分区这些能力视图一样都没有。报表类需求里我后来基本都走汇总表路线只在数据量小、口径经常变的场景用视图。还有一种情况容易误判视图物化后MySQL 有时会给派生表自动加一个内部索引EXPLAIN里能看到key列出现auto_key0。这不代表视图变快了它只是在临时表上做的补救前提是真的能加上聚合后的SUM列、COUNT列是加不上索引的。5.2 嵌套视图的代价会被放大单层视图的问题还能看出来嵌套之后麻烦在于执行计划会变长到难以阅读。假设有v_a单表可合并被v_b带 GROUP BY物化引用v_b又被v_c带 JOIN 和 WHERE引用。EXPLAIN出来是三层嵌套的派生表每层都有自己的过滤和聚合。你想优化的时候面临一个尴尬局面外层的 WHERE 只能作用在最外层的派生表上中间层物化时已经把全量数据算过一遍了。我的经验是视图嵌套不要超过两层而且中间层必须是可合并的。一旦发现某层带聚合就停下来想想是不是该做成物理表。曾经有一个报表库里有四个视图串成一条链最后一个视图查一次要十七秒把中间两层拆成定时落地的汇总表之后同样的查询降到两百毫秒。这类优化的关键不在 SQL 技巧而在于承认视图不是算力来源。6. 权限、DEFINER 与迁移路上的隐形炸弹视图的权限模型比表复杂一层因为它有两个身份定义视图的人DEFINER和查询视图的人INVOKER。搞不清这一点会出现本地好好的上生产就报权限错的经典故障。6.1 SQL SECURITY 的两种身份建视图时可以指定SQL SECURITY默认是DEFINER。含义是任何有视图 SELECT 权限的账号来查视图内部的基表访问都按定义者创建视图的那个账号的权限来判断。这个默认值的实际作用很实用——你可以只给外部账号视图的 SELECT 权限不给基表的任何权限对方照样查得通而且查不到视图里没暴露的列。CREATE DEFINERshop_app% SQL SECURITY DEFINER ALGORITHMMERGE VIEW v_user_mask AS SELECT id, nick, CONCAT(LEFT(phone,3),****,RIGHT(phone,4)) AS masked_phone FROM users WHERE status 1; GRANT SELECT ON shop_demo.v_user_mask TO report_ro%;report_ro这个账号没有users表的任何权限但能查v_user_mask。这是一个很干净的最小权限设计比在应用层做脱敏牢靠因为应用层脱敏一旦某段代码绕过就漏了。SQL SECURITY INVOKER则反过来按调用者自己的权限判断。用它的时候调用者必须有基表权限等于绕了一圈又回到原样价值不大。真实场景里我基本不碰它。6.2 从测试库导到生产库之后视图打不开这是 DEFINER 最著名的坑。视图定义里写死了DEFINERshop_app%测试库里确实有这个账号生产库里没有或者主机名后缀不同比如shop_app10.%查询视图时直接报ERROR 1449 (HY000): The user specified as a definer (shop_app%) does not exist这个错误在应用启动阶段看不出来因为不查这个视图就不会触发往往要等到某个功能点被点开才暴露。处理方式有两个层面。导出层面mysqldump在较新版本提供了--skip-definer选项能省掉后续处理版本不支持的话就只能对导出的 SQL 做一次文本替换把DEFINER后面的那一段xxxyyy 清掉。导入层面脚本里统一规定创建视图的语句不带 DEFINER交给数据库用执行账号自己填环境之间的账号差异就不会带进 SQL 里。这条约定建议直接写进上线检查清单比事后排查便宜太多。还有一个相关问题定义者账号存在但被锁了、密码过期了视图查询同样会失败。有些团队为了省事用超级账号做 DEFINER一旦这个账号被安全策略处理全库视图集体不可用。所以 DEFINER 一定要指定一个专用的、长期存活的、只具备必要权限的服务账号。7. 一次线上报错的完整排查链路讲一个真实的排查过程比罗列结论有用。某天早上报表页面打不开接口返回的底层错误是Unknown column o.amount in field list错误码 1054但相关订单数据一切正常业务表也没人报错。7.1 从 1054 往回找第一步确认出错对象。页面调的是v_order_report那就先看它的定义SHOW CREATE VIEW v_order_report\G定义里确实引用了orders.amount。再去看orders表结构SHOW COLUMNS FROM orders LIKE amount;结果为空。说明有人把amount改名了或者把金额拆成了amount和currency两个字段挪到了别的表。查看变更记录果然是前一天的版本把金额字段改成了total_amt。问题就在这里视图存的是定义文本基表字段改名时 MySQL 不会帮你同步视图里的引用RENAME COLUMN之后视图会失效。定义文本里那句o.amount就成了悬空引用查询时报 1054。第二步找出影响范围。不能只修v_order_report还得找到所有引用了orders.amount的视图SELECT TABLE_SCHEMA, TABLE_NAME FROM information_schema.VIEWS WHERE VIEW_DEFINITION LIKE %amount%;这个查询是暴力匹配文本会有误报比如别名里带 amount 的但用来圈定候选范围够用。更精准的做法是结合VIEW_DEFINITION LIKE %orders%一起过滤MySQL 规范化后的定义里库名表名都带反引号匹配起来比较稳。第三步评估修复方式。如果只是字段改名用ALTER VIEW重建定义就行不需要 DROP。这里有个顺序讲究先ALTER VIEW改成新字段再确认查询恢复最后才去清理不再使用的旧视图。反过来做容易在中间态把报表彻底打断。第四步补上防护。这类故障的根因不是视图本身而是表结构变更没有评估下游依赖。修完这次的坑我加了一条流程任何改字段名的变更上线前必须跑一遍上面的VIEW_DEFINITION匹配查询输出受影响视图清单作为变更单的附件。7.2 视图治理的几条落地经验第一视图定义里禁止裸写SELECT *。前面讲过原因加字段时会静默改变视图的列集合属于最难排查的一类问题。可以用下面这句把历史遗留的找出来SELECT TABLE_SCHEMA, TABLE_NAME FROM information_schema.VIEWS WHERE VIEW_DEFINITION LIKE %SELECT%*%;结果里的每一条都值得改造。第二给视图加注释。CREATE VIEW支持COMMENT ...子句写清楚用途、口径、负责人、是否可更新。半年后接手的人可能是你自己会感谢这一行注释。注释可以通过information_schema.VIEWS的TABLE_COMMENT字段查出来。第三视图命名带前缀和后缀。我习惯v_前缀表示视图_ro后缀表示只读一眼就能区分v_order_report只读报表和v_order_edit可更新。有了这个约定代码评审时能快速发现这个只读视图怎么被 UPDATE 了。第四定期清理孤儿视图。表被删了、业务下线了视图还留着平时不碍事一旦有人查就报错。可以用information_schema.VIEWS结合业务清单做一次定期比对长期没人访问的结合慢日志或者审计日志判断直接下掉。第五迁移脚本里统一处理 DEFINER 和字符集。把所有视图的创建语句放在单独一个 SQL 文件里文件头部统一SET NAMES语句里统一不写 DEFINER导出的时候统一做文本清理。这套模板一旦固定下来跨环境迁移就基本不会再出问题。我个人在实际操作中的体会是视图最合适的定位是数据访问的接口层而不是查询的优化层。它能帮你把权限收窄、把口径固定、把结构变更的影响隔离开这三件事价值都很高。但它做不了缓存、做不了索引、做不了参数化指望它提速一定会失望。每次准备建视图之前我都会先问自己一句我是想少写点 SQL还是想解决一个真正属于接口层的问题如果答案是前者那我通常会选择老老实实把那段 JOIN 写出来顺便让EXPLAIN告诉我到底慢在哪里。
阅读完成 · 觉得有帮助?
咨询建站