
1. 临时表到底是干什么的从一次线上慢查询说起上周帮数据团队排查一个报表接口查询逻辑很简单就是把订单表、用户表、商品表三张表关联后做聚合。问题是订单表有八千多万行每次跑这段SQL数据库都要把三张表完整关联一遍生成一个两千多万行的中间结果再去做分组聚合。接口响应时间从最初的三秒一路飙到四十多秒最后直接把生产库的连接池打满了。我当时第一反应不是去优化JOIN顺序也不是给关联字段加索引当然这些都做了而是先问了一句话这个中间结果真的有必要在一条SQL里算完吗这就是临时表的出场时机。很多开发者对临时表的认知停留在“存储过程里用来存中间数据的一个变量”但实际上临时表在日常数据查询、报表开发、数据清洗、分步计算里都是非常趁手的工具。它的核心价值在于把一个复杂的大查询拆成多个简单的小步骤每一步的结果落在一个临时表里后续步骤只和这个临时表打交道而不是一遍遍去全表扫描几千万行的原始大表。换句话说临时表就是把“重复计算”转化为“一次计算多次使用”的中间缓存层。1.1 临时表解决的核心问题用生活化的方式理解普通表是仓库里固定的货架你放上去的东西会长期存在谁都能来取临时表更像你在工位上临时铺开的一张白纸算完这单业务随手就扔掉不影响别人。从工程角度讲临时表主要解决了三类问题第一类是复杂查询的分析拆解。当一个业务需求需要五六个子查询嵌套、三四层JOIN、两次GROUP BY时不管是写的人还是后来维护的人看到那段SQL都想离职。用临时表把每个逻辑步骤拆开每一步的结果清晰落地排查问题的时候只需要检查中间每一层的数据定位效率高得多。第二类是重复扫描大表的性能问题。比如你需要在多个子查询里反复使用同一个过滤条件近三十天、指定渠道、指定状态如果写成子查询嵌套数据库很可能对同一张大表扫描多次。而先把过滤后的结果导进一张临时表之后所有操作都基于这个缩小后的数据集扫描成本直接下降一个数量级。第三类是跨查询、跨存储过程的数据传递。有些需求必须先算出A结果再根据A结果算B结果最后把B结果和C结果合并。这种场景用全局临时表或者会话级临时表就能在不同批次之间共享中间数据避免把中间值写成JSON或者落成物理文件。1.2 临时表的生命周期与存储位置搞清楚临时表的生命周期是正确使用它的前提。根据数据库类型不同临时表一般分为三个层级会话级临时表只在创建它的会话连接内可见会话结束自动销毁也是日常使用最频繁的一类。事务级临时表只在当前事务内可见事务提交或回滚后自动清理主要用于保证事务隔离性。全局临时表所有会话都可见但引用它的最后一个会话结束后才会被清理用来做跨任务的共享中间数据。存储位置上SQL Server的临时表默认放在tempdb系统数据库中MySQL的临时表默认使用内存存储引擎超出参数设定的阈值后自动落盘到临时文件PostgreSQL的临时表落在临时schema中同样不参与物理持久化。这也是为什么临时表创建、写入、清理的速度通常比普通表快——它本身就是为“用完即走”设计的。2. 三大主流数据库创建临时表的方法与差异不同数据库在临时表的语法和行为上万万不能一概而论。我见过有人在SQL Server里写CREATE TEMPORARY TABLE报错也见过MySQL用户跑到PostgreSQL里写SELECT ... INTO #temp翻车。下面把三类常见数据库的创建方法逐一拆开讲。2.1 SQL Server本地临时表、全局临时表和表变量SQL Server的临时表体系是最丰富的主要有三种形态很多人容易搞混。第一种是本地临时表表名以#开头例如#tmp_order。它只对创建它的会话可见会话断开后自动删除。如果是在存储过程里创建的存储过程执行结束后也会被自动清理。CREATE TABLE #tmp_order ( order_id INT PRIMARY KEY, user_id INT NOT NULL, order_date DATETIME NOT NULL, amount DECIMAL(10,2) ); INSERT INTO #tmp_order SELECT order_id, user_id, order_date, amount FROM dbo.orders WHERE order_date DATEADD(DAY, -30, GETDATE());这种写法适合存储过程中分步计算每一步的中间结果先落进#临时表后续步骤直接引用。第二种是全局临时表表名以##开头例如##tmp_global_order。全局临时表对所有会话可见但只有创建它的会话断开后服务器才会检查是否还有其他会话正在引用如果没有才真正删除。实际工作中全局临时表用得不多主要场景是多个会话协同处理同一批中间数据比如一个Session负责生产数据、另一个Session负责消费。CREATE TABLE ##tmp_shared_data ( batch_id INT, data_value VARCHAR(200) );第三种是表变量严格来说它不叫临时表但在使用场景上和临时表高度重叠。表变量用DECLARE table TABLE (...)声明生命周期被限定在声明它的批处理或存储过程内部。DECLARE tmp_order TABLE ( order_id INT, total_amount DECIMAL(10,2) ); INSERT INTO tmp_order SELECT order_id, SUM(amount) FROM dbo.orders GROUP BY order_id;表变量和本地临时表的区别我在后面选型对比时专门说这里只需要记住小数据量、逻辑简单的场景用表变量数据量较大、需要索引和统计信息的场景用#临时表。SQL Server还有一条非常实用的快捷语法SELECT ... INTO。它能在创建临时表的同时直接写入数据一步到位不需要先建表再插入。SELECT user_id, COUNT(*) AS order_cnt, SUM(amount) AS total_amount INTO #tmp_user_order_stat FROM dbo.orders WHERE order_date DATEADD(DAY, -30, GETDATE()) GROUP BY user_id;这条语句执行完毕后#tmp_user_order_stat就被创建出来并写入了数据列类型根据SELECT结果的推导自动生成。缺点是列上不会自动建索引如果后续要频繁关联或过滤建议补上索引。2.2 MySQLCREATE TEMPORARY TABLE与连接绑定MySQL创建临时表的语法是CREATE TEMPORARY TABLE关键就在TEMPORARY这个关键字。CREATE TEMPORARY TABLE tmp_order_filtered ( id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2), INDEX idx_user_id (user_id) ) ENGINE InnoDB; INSERT INTO tmp_order_filtered SELECT id, user_id, amount FROM orders WHERE create_time NOW() - INTERVAL 30 DAY;MySQL临时表有几个必须注意的特性临时表仅对创建它的连接可见。你用一个连接创建了临时表另开一个连接去查询直接报“Table doesnt exist”。这一点天然限制了它不能跨连接共享好处是同一连接的多个查询之间可以随时访问。如果临时表和普通表同名同一连接内优先访问临时表。如果你在代码里有这样的逻辑先创建一张名为orders_tmp的临时表数据库里恰好也有一张orders_tmp那么在临时表被删除之前你在这个连接里读到的orders_tmp永远是临时表。我见过有人因为这个问题线上数据怎么查都对不上排查半天才发现是临时表把普通表“遮住”了。所以命名上尽量加个前缀或者后缀比如tmp_orders_202501并在用完立即DROP。连接断开时临时表自动销毁。不管你有没有执行DROP只要连接关闭MySQL会顺手把该连接下的所有临时表清理掉。这也是连接池环境里比较安全的一点断连即回收。MySQL还支持在内存中创建临时表只要指定ENGINE MEMORY即可适合放几百MB以内的中间结果速度极快超过tmp_table_size或max_heap_table_size限制后会自动转换为磁盘上的临时表性能会有所下降。2.3 PostgreSQLON COMMIT的行为控制PostgreSQL的临时表语法和MySQL有些神似但它额外提供了事务行为的精细控制这一点很多人不知道。CREATE TEMPORARY TABLE tmp_order_stat ( user_id INT NOT NULL, order_cnt INT, PRIMARY KEY (user_id) ) ON COMMIT DROP;这里的ON COMMIT有三个可选项对应三种完全不同的生命周期ON COMMIT PRESERVE ROWS事务提交后临时表结构保留数据行保留适合跨多个事务复用中间数据。ON COMMIT DELETE ROWS事务提交时临时表中的数据清空但表结构保留适合每个事务都要写入新中间结果的场景。ON COMMIT DROP事务提交时临时表整个删除适合只在当前事务内使用的临时数据。如果你在事务里用完临时表就不管了这个选项能自动帮你清理避免残留。另外PostgreSQL创建临时表时如果不加TEMPORARY关键字创建的就是普通表。需要注意一个细节临时表的搜索优先级高于普通表如果同名当前会话里访问到的是临时表。这一点和MySQL的行为类似都属于需要留意的坑。还有一个我很常用的技巧PostgreSQL的CREATE TEMP TABLE ... AS SELECT可以在建表的同时写入数据一步完成效率很高。CREATE TEMP TABLE tmp_active_users AS SELECT id, name, email FROM users WHERE last_login_at NOW() - INTERVAL 7 days ON COMMIT PRESERVE ROWS;这条语句执行后tmp_active_users的字段类型会自动从SELECT结果推导出来适合快速生成中间分析表。2.4 三种创建方式的对比把上面的核心差异整理成一张表方便平时查阅对比维度SQL ServerMySQLPostgreSQL创建语法CREATE TABLE #tmpCREATE TEMPORARY TABLE tmpCREATE TEMPORARY TABLE tmp作用范围本地临时表仅当前会话全局临时表跨会话仅当前连接可见仅当前会话可见自动清理时机会话断开 / 存储过程结束连接断开事务提交按ON COMMIT规则是否支持跨会话共享全局临时表##支持不支持不支持但可通过UNLOGGED TABLE模拟建表写数据一步完成支持SELECT INTO #tmp支持CREATE TEMPORARY TABLE AS支持CREATE TEMP TABLE AS默认存储位置tempdb内存/临时文件临时schema3. 临时表、表变量与CTE选型时机的真实边界临时表虽然好用但绝不是所有中间结果都应该用临时表。我和很多同事聊过这个话题发现大多数人的困惑集中在临时表、表变量、CTE公共表表达式到底什么时候该用哪个。这里给出我的实际经验。3.1 临时表 vs 表变量表变量在SQL Server里一直被新手拿来当临时表用但两者的性能特性和适用场景差异很大。表变量最大的优势是轻量、开销低声明即用不产生额外的事务日志不需要数据库对象的创建审批有些公司的生产环境禁止开发人员创建临时表但表变量不受限。它在内存中维护小数据量下性能很好。但是表变量有几个天生短板没有统计信息优化器无法准确估算行数。当表变量里装了几万行数据再做JOIN时优化器默认它只有一行生成的执行计划往往非常糟糕。不能显式创建普通索引SQL Server里可以通过主键或唯一约束间接建索引但受到很多限制。只存在于当前批处理中跨存储过程传递不方便。反观临时表有明确的统计信息、可以灵活创建索引、支持事务回滚数据量大时性能远优于表变量。我的选型标准很简单数据量在几千行以内逻辑简单用表变量数据量上万需要JOIN、需要索引、需要统计信息用临时表。3.2 临时表 vs CTE公共表表达式CTECommon Table Expression是现代SQL中非常流行的一种中间结果组织方式语法简洁可读性高最典型的就是WITH ... AS子句。很多开发者有“一个WITH走天下”的习惯但那是有代价的。CTE本质上是一个内联视图或物化结果取决于数据库优化器的执行策略。在MySQL 8.0之前CTE不会被物化每次被外部引用时都可能重新执行一遍在SQL Server里CTE同样可以被内联展开也可能被物化到工作表完全由优化器决定。这意味着你写了一个很重的CTE并在主查询里引用了它三次实际执行时可能被扫描了三次。用临时表就不存在这个问题数据先算完落地后续多次引用都只读取一次结果。举一个实际例子。你需要统计“活跃用户在最近30天内的下单金额排名”并把这个结果和“活跃用户的注册信息”做关联展示。写成CTE是WITH active_users AS ( SELECT user_id FROM users WHERE last_login_at NOW() - INTERVAL 30 DAY ), order_summary AS ( SELECT user_id, SUM(amount) AS total_amount FROM orders WHERE create_time NOW() - INTERVAL 30 DAY GROUP BY user_id ) SELECT u.user_id, u.name, s.total_amount FROM active_users a JOIN order_summary s ON a.user_id s.user_id JOIN users u ON a.user_id u.id ORDER BY s.total_amount DESC;这段SQL看起来清晰但如果orders表有几千万行active_users和order_summary各自产生的数据集都很大优化器可能会选择重复扫描订单表导致性能失控。用临时表改写后CREATE TEMPORARY TABLE tmp_active_users AS SELECT user_id FROM users WHERE last_login_at NOW() - INTERVAL 30 DAY; CREATE TEMPORARY TABLE tmp_order_summary AS SELECT user_id, SUM(amount) AS total_amount FROM orders WHERE create_time NOW() - INTERVAL 30 DAY GROUP BY user_id; SELECT u.user_id, u.name, s.total_amount FROM tmp_active_users a JOIN tmp_order_summary s ON a.user_id s.user_id JOIN users u ON a.user_id u.id ORDER BY s.total_amount DESC;第一步、第二步的中间结果落在临时表里第三步只需要读两个缩小后的数据集性能表现稳定得多。3.3 具体选型清单根据我自己的项目经验整理了一个简单的选型清单查询只被引用一次、逻辑不复杂、数据量中等优先CTE代码简洁不产生额外存储。同一个中间结果被多次引用、数据量较大用临时表把“算一遍”变成“查多次”。数据量很小但需要跨查询复用用表变量SQL Server或临时表MySQL/PG。需要为中间结果创建索引、且后续还有复杂的多表关联必须用临时表。只需要在当前事务内部完成计算事务结束就拉倒用临时表加ON COMMIT DROPPG或手动清理。为了避免事务日志压力、且数据规模不大优先表变量或内存临时表。4. 创建临时表容易踩的坑清单这部分是重点。我见过不少人在表结构设计上看起来没问题一上线就翻车问题大多出在下面几个细节点上。4.1 同一个会话里临时表重名在MySQL和SQL Server中如果同一个连接里已经存在一张名为tmp_a的临时表再次执行CREATE TEMPORARY TABLE tmp_a不会像普通表那样直接报错而是会覆盖原有的临时表定义。这在分步执行的存储过程或脚本里容易引发诡异问题第一步创建的tmp_a还没用完第二步又创建了一张同名的tmp_a把前面的数据清空了后续引用的数据就不对了。我的习惯是每次创建临时表之前先执行一次DROP TEMPORARY TABLE IF EXISTS tmp_xxx确保当前会话里没有同名残留。DROP TEMPORARY TABLE IF EXISTS tmp_order_stat; CREATE TEMPORARY TABLE tmp_order_stat (...);4.2 索引和统计信息缺失导致执行计划畸变使用SELECT INTO #tmp或CREATE TEMP TABLE AS创建的临时表默认是不带索引的。后面如果和别的表做关联临时表作为驱动表或被驱动表时优化器可能会选择全表扫描数据量一大就很慢。解决办法是在临时表数据写入完成后立刻检查是否需要在关联字段上补索引CREATE INDEX idx_tmp_order_user ON #tmp_order(user_id);另外PostgreSQL的临时表默认不会自动收集统计信息。如果你发现临时表数据量很大但查询计划显示的行数估算严重偏低可以手动执行一次ANALYZE tmp_table来刷新统计信息。4.3 事务提交后临时表内容突然消失PostgreSQL里如果创建临时表时写了ON COMMIT DELETE ROWS事务一旦提交表里的数据会被清空。有些同事习惯在事务里先算好中间数据提交后再去查询关联结果结果发现临时表里空空如也。排查了很久才发现是事务提交时把数据清了。解决方式很简单确认临时表需要跨事务使用时明确指定ON COMMIT PRESERVE ROWS。4.4 连接池环境下临时表“残留”Java、Python、Go等语言都会用数据库连接池比如HikariCP、druid、psycopg2的连接复用。连接池里的连接并不会在执行完SQL后关闭而是归还给池子供下次使用。这带来一个问题一个请求里创建了临时表连接归还到池中如果代码异常没有走DROP下一个拿到这个连接的请求可能会读到上一次残留的临时表数据。我在一个Go项目里遇到过一次同一个连接被复用后查询里定义了局部临时表但异常分支没有删除结果下一个请求里同样的SQL有机会访问到旧数据。虽然在PostgreSQL中临时表是会话级的连接复用的问题尤为突出。更麻烦的是如果连接池里多个连接各自创建了同名的临时表而业务代码里又用到了全局临时表语义那么第二个连接可能根本看不到第一个连接里创建的表造成“表找不到”的报错。解决方案是临时表必须在业务代码的 finally 块中显式删除不要寄希望于连接断开自动清理。连接池的长连接特性决定了临时表的生存周期可能会被无限延长。defer db.Exec(DROP TABLE IF EXISTS tmp_res)4.5 临时表空间不足MySQL和SQL Server的临时表都存储在特定的临时表空间或tempdb中。如果并发任务很多、每个任务都写了大临时表很可能把临时表空间塞满。SQL Server的典型报错是“The database tempdb has reached its size quota”MySQL会报“No space left on device”。预防手段是两个一是业务上尽量控制临时表的数据量二是监控tempdb或临时表空间的使用情况及时扩容。在MySQL里可以通过tmp_table_size和max_heap_table_size参数调大内存临时表的上限让更多临时表留在内存里而不是落盘但也要防止内存被耗尽。5. 结合慢SQL优化场景临时表的正确使用姿势临时表不是银弹用错了地方反而让SQL更慢。这里说几个我自己验证过有效的优化套路。5.1 拆分重查询的常见套路在复杂报表查询里一个经典模式是先过滤大表再关联小表。但很多SQL把过滤逻辑写在JOIN条件或子查询的最深处优化器不一定能按预期顺序执行。用临时表可以强制改变执行计划的结构。比如有个需求统计每个省份的活跃用户数和成交金额。原始SQL可能是在一次大查询里完成所有JOIN和聚合但订单表和用户表都是大表一次JOIN的成本极高。拆成临时表后-- 第一步先过滤出最近90天的有效订单数据量从千万级降到百万级 CREATE TEMPORARY TABLE tmp_valid_orders AS SELECT user_id, province_id, amount FROM orders WHERE order_status paid AND order_date NOW() - INTERVAL 90 DAY; -- 第二步过滤出活跃用户只保留最近30天有登录的 CREATE TEMPORARY TABLE tmp_active_users AS SELECT id, province_id FROM users WHERE last_login_at NOW() - INTERVAL 30 DAY; -- 第三步分别聚合再关联 SELECT u.province_id, COUNT(DISTINCT u.id) AS active_user_cnt, COALESCE(SUM(o.amount), 0) AS total_amount FROM tmp_active_users u LEFT JOIN tmp_valid_orders o ON u.id o.user_id GROUP BY u.province_id;最关键的变化是大表只被扫描一次。原始SQL中订单表可能被扫描了两次一次过滤、一次JOIN拆成临时表后订单表只扫描一次生成的结果集缩小了十倍以上后续JOIN的代价就小多了。5.2 临时表上建立索引的时机临时表上的索引不要急着建也不要彻底不建。正确的节奏是创建临时表、写入数据确认数据量级。如果只有几千行索引意义不大后续查询涉及JOIN、WHERE、GROUP BY字段时再在这些字段上补建索引索引数量不要贪多临时表本身生命周期短索引建太多反而浪费写入开销。举个例子上面的tmp_valid_orders如果后续要反复按user_id关联那么user_id上建索引收益很大。但如果后续只是全量聚合索引就是多余的。5.3 用完即清删除临时表与连接复用不管数据库能不能自动清理临时表业务代码里都应该显式删除。原因前面说过连接池环境下连接是复用的临时表的生命周期远超预期。尤其在使用全局临时表时最后一个使用连接的会话结束才会清理如果不显式删tempdb或临时表空间迟早被撑爆。推荐的删除方式DROP TABLE IF EXISTS tmp_valid_orders;不同数据库删除临时表的语法略有差异MySQL支持DROP TEMPORARY TABLESQL Server用DROP TABLE #tmpPostgreSQL用DROP TABLE tmp即可。为了兼容我通常都用DROP TABLE IF EXISTS提前判断是否存在。5.4 临时表在ETL和数据清洗中的应用最后补充一个很容易被忽略的场景ETL数据清洗。我之前做数据迁移时经常需要把源库导出的数据先清洗一遍再写入目标库。临时表在这里的用法是先把源数据导入临时表在临时表上做去重、格式校验、非法值过滤最后从临时表里SELECT需要的字段写入正式表。这种做法的好处是清洗逻辑和正式表物理隔离中间过程不影响线上业务临时表可以随便DROP重建反复调试配合索引可以高效定位脏数据。关于连接断开自动清理的一个补充测试最后分享一个技巧如果你不确定当前数据库的临时表在连接池环境下什么时候被清理可以在本地写个小脚本验证。以Python PostgreSQL为例用一个长期存在的连接测试import psycopg2 conn psycopg2.connect(databasetest_db, userpostgres) cur conn.cursor() cur.execute(CREATE TEMPORARY TABLE tmp_test (id int) ON COMMIT PRESERVE ROWS) cur.execute(INSERT INTO tmp_test VALUES (1), (2), (3)) conn.commit() # 执行完不关闭连接手动查询 cur.execute(SELECT COUNT(*) FROM tmp_test) print(cur.fetchone()) # 输出 (3,) # 模拟连接复用时再次尝试访问该表 cur2 conn.cursor() cur2.execute(SELECT COUNT(*) FROM tmp_test) print(cur2.fetchone()) # 输出 (3,)因为连接没断 cur.close() # 不执行 DROP直接关闭连接 conn.close() # 重新建立一个连接 conn2 psycopg2.connect(databasetest_db, userpostgres) cur3 conn2.cursor() try: cur3.execute(SELECT COUNT(*) FROM tmp_test) except Exception as e: print(表不存在, e) # 输出异常因为旧连接的临时表已经随连接断开而清理这段测试验证了一件重要的事临时表的生命周期完全绑定在连接上。连接不断临时表就一直存在连接断开后无论你是否显式DROP临时表都会被清理。但连接池场景里连接不断所以显式删除才是保证数据隔离的唯一可靠手段。SQL中临时表的创建方法本质上不复杂真正复杂的是在不同数据库、不同业务场景下做出合适的选择并管理好临时表的生命周期。希望这篇总结能让你在项目里少踩几个坑。