
在 PostgreSQL 里写函数算是我日常工作中最高频的操作之一。不管做数据清洗、接口回参组装还是给业务逻辑补一段可复用的查询最终都会落到“写个函数”这一步。这篇文章不打算按官方文档那个思路复述概念我就按一个真实需求从零到上线的过程把我在 PostgreSQL 里写函数的选型、语法、坑点和调试方式都过一遍。适合刚接触 PostgreSQL、想快速上手函数开发的同学也适合写了多年函数、但总被一些诡异报错绕进去的老手。1. 写函数前的三个关键选择1.1 用SQL函数还是plpgsql函数很多人第一次写 PostgreSQL 函数都会纠结一个问题到底用LANGUAGE sql还是LANGUAGE plpgsql。我的答案很简单如果你的逻辑只是“一条查询返回结果”那用 SQL 函数就够了一旦涉及到变量、判断、循环、异常处理老老实实切到 plpgsql。SQL 函数长这样CREATE OR REPLACE FUNCTION get_user_count() RETURNS bigint LANGUAGE sql AS $$ SELECT count(*) FROM users; $$;这种函数的本质就是把一段 SQL 包了一层壳执行计划会被调用方合并优化性能通常不错。但它写不了“先判断再执行”的逻辑也写不了循环最多写几条 SQL 用分号隔开然后返回最后一条的结果。plpgsql 函数就不一样了CREATE OR REPLACE FUNCTION get_active_user_count(mins int) RETURNS bigint LANGUAGE plpgsql AS $$ DECLARE cnt bigint; BEGIN IF mins 0 THEN RAISE EXCEPTION 参数 mins 必须大于 0当前值为 %, mins; END IF; SELECT count(*) INTO cnt FROM users WHERE last_active_at now() - make_interval(mins mins); RETURN cnt; END; $$;注意这里有个DECLARE段声明变量BEGIN...END包住整个过程体INTO cnt把查询结果塞进变量里。这才是“写程序”的状态也是绝大多数业务场景真正需要的写法。我个人的习惯是默认都写 plpgsql除非那个函数真的短到只有一句 SELECT。因为后续需求一旦复杂化SQL 函数改造成 plpgsql 的代价远大于一开始就写成 plpgsql。别小看这个习惯后期维护能省很多事。1.2 语言选型plpgsql之外的选项PostgreSQL 除了 plpgsql还支持 SQL、plpython、plperl、plv8JavaScript甚至可以用 C 写扩展函数。我在实际项目里95% 的情况用 plpgsql 就解决了剩下 5% 才需要特殊语言。plpython 适合需要用到 Python 生态的场景比如复杂的文本处理、调用某个 Python 库。plv8 则可以在数据库里跑 JavaScript某些团队后端全栈是 Node.js习惯用 JS 处理逻辑会选择 plv8。但我必须提醒一句引入这些语言要谨慎等于在数据库进程里埋了额外的运行时版本升级、安全补丁、性能排查都会变复杂。至于 C 写的函数那是扩展开发级别的事绝大多数业务开发根本不需要碰。我给个建议先把 plpgsql 玩熟很多你以为“数据库做不了”的逻辑其实只是你没找到对应写法。见过太多人为了一个字符串处理逻辑引入 plpython结果完全可以用正则表达式函数解决。1.3 环境准备版本选择与安装方式写函数之前至少得有个能跑的 PostgreSQL 环境。热词里提到了 PostgreSQL 17、16 便携版我顺便聊下版本选择。我在生产环境用的比较多的是 16 和 17。PostgreSQL 17 在查询并行、vacuum 性能上又有改进新项目可以直接上 17。16 则是目前最稳妥的长期选择生态兼容性最好。测试或学习环境下载一个便携版比如 ZIP 解压版就够了不污染系统删了也能换版本重来。Windows 上安装时服务启动失败是常见问题多半是端口被占用或者数据目录权限不对安装时换一个端口或者以管理员身份初始化数据目录就能解决。Linux 服务器安装我一般用系统包管理器# Debian / Ubuntu sudo apt update sudo apt install postgresql postgresql-contrib # CentOS / Rocky sudo yum install postgresql-server postgresql-contrib装完之后记得初始化数据目录并启动服务sudo postgresql-setup --initdb sudo systemctl enable --now postgresqlmacOS 上最简单的是用 Homebrewbrew install postgresql17 brew services start postgresql17环境跑通之后再开始写函数不然代码再对也没地方验证。2. 从零写第一个可用的函数2.1 先写一个最简单的SQL函数跑通链路不要一上来就憋大招我第一次写函数就是从“返回当前时间”开始的。这个函数虽然简单但能帮你把创建、调用、删除这条路走通后面所有复杂函数都是在这条链路上衍生出来的。最简单的版本CREATE OR REPLACE FUNCTION now_text() RETURNS text LANGUAGE sql AS $$ SELECT to_char(now(), YYYY-MM-DD HH24:MI:SS); $$;调用SELECT now_text();输出2025-01-18 14:30:25这里有几个关键点OR REPLACE表示如果函数已存在就替换不存在就创建。RETURNS text声明返回类型。AS $$ ... $$里的$$是函数体的定界符避免了函数体里的单引号转义地狱。如果你看过老代码会发现有人用...包函数体遇到字符串里的单引号就要写两次特别痛苦$$真的是一用就回不去的写法。2.2 进阶为plpgsql支持变量、条件与循环函数一旦需要“逻辑”就得用 plpgsql。我举一个实际用过的场景根据用户积分等级计算折扣率这个逻辑在多个接口里都要用。CREATE OR REPLACE FUNCTION calc_discount(level text, amount numeric) RETURNS numeric LANGUAGE plpgsql AS $$ DECLARE rate numeric : 0; BEGIN CASE level WHEN 普通 THEN rate : 0; WHEN 银卡 THEN rate : 0.05; WHEN 金卡 THEN rate : 0.10; ELSE rate : 0.02; END CASE; RETURN amount * (1 - rate); END; $$;调用SELECT calc_discount(金卡, 1000);结果900。这个例子里我用了CASE表达式做条件判断实际业务中条件再复杂一点可以用IF ... ELSIF ... ELSE ... END IF。循环的话最常用的是FOR ... INCREATE OR REPLACE FUNCTION sum_batch(start_at int, end_at int) RETURNS int LANGUAGE plpgsql AS $$ DECLARE total int : 0; i int; BEGIN FOR i IN start_at..end_at LOOP total : total i; END LOOP; RETURN total; END; $$;虽然这种循环用数学公式更高效但它展示了 plpgsql 的循环语法。注意start_at..end_at中间是两个点不是省略号漏写一个点会直接语法报错。2.3 参数和返回值的几种正确姿势函数参数默认都是IN参数但 PostgreSQL 还支持OUT和INOUT。很多新手不知道这个结果为了返回多个值把结果拼成字符串或者 JSON再在外部解析代码很难看。用OUT参数返回多个值的写法CREATE OR REPLACE FUNCTION get_user_stat( uid int, OUT total_orders int, OUT total_amount numeric ) LANGUAGE plpgsql AS $$ BEGIN SELECT count(*), COALESCE(sum(amount), 0) INTO total_orders, total_amount FROM orders WHERE user_id uid; END; $$;调用时不需要写SELECT某个字段直接SELECT * FROM get_user_stat(1001);输出两列total_orders和total_amount。如果需要返回一个结果集而不是单行或单值可以RETURNS TABLECREATE OR REPLACE FUNCTION get_recent_orders(uid int, limit_n int DEFAULT 10) RETURNS TABLE(order_id int, order_time timestamptz, amount numeric) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT id, created_at, amount FROM orders WHERE user_id uid ORDER BY created_at DESC LIMIT limit_n; END; $$;关键语法是RETURN QUERY等于把查询结果直接塞进返回集。配合SELECT * FROM get_recent_orders(1001)使用效果等同于一张表。这里DEFAULT 10给参数设了默认值调用时可以只传一个参数方便同一函数适配不同场景。注意RETURNS TABLE和OUT参数不能混用这是 PostgreSQL 的语法限制。我踩过一次以为两者可以并存结果报错OUT parameters and RETURN TABLE are not allowed together。要返回结果集就用RETURNS TABLE要返回多列单行就用OUT分工明确。3. 让函数更健壮异常处理、游标与动态SQL3.1 异常处理RAISE与EXCEPTION的正确姿势写函数不处理异常等于裸奔。业务数据千奇百怪一个unique_violation或者invalid_text_representation就能把整个任务打断。plpgsql 里最常用的组合是RAISE主动抛错 EXCEPTION捕获异常。我先写一个常见的“插入并返回 ID”的函数业务上经常用来做防重复插入CREATE OR REPLACE FUNCTION add_user_with_check( p_email text, p_name text ) RETURNS int LANGUAGE plpgsql AS $$ DECLARE new_id int; BEGIN INSERT INTO users(email, name) VALUES (p_email, p_name) RETURNING id INTO new_id; RETURN new_id; EXCEPTION WHEN unique_violation THEN RAISE NOTICE 邮箱 % 已存在改为更新操作, p_email; UPDATE users SET name p_name WHERE email p_email RETURNING id INTO new_id; RETURN new_id; END; $$;这个函数的亮点在于EXCEPTION WHEN unique_violation THEN它精准捕获唯一键冲突而不是让整个调用直接报错。RAISE NOTICE会打印一条日志但不会中断函数执行。如果想让调用方感知到异常就用RAISE EXCEPTION它会终止当前事务。捕获具体错误越具体越好。常见错误码有错误名说明unique_violation唯一约束冲突foreign_key_violation外键约束冲突not_null_violation非空约束冲突check_violation检查约束失败undefined_table表不存在division_by_zero除零错误如果实在没法确定错误类型最后加一个兜底分支EXCEPTION WHEN unique_violation THEN ... WHEN OTHERS THEN RAISE NOTICE 未知错误: %, SQLERRM; RAISE; END;SQLERRM是系统变量保存当前错误消息文本。最后那个RAISE没有跟参数代表重新抛出原错误避免把异常吞掉导致数据不一致。3.2 游标与结果集逐行处理有些场景必须逐行处理数据比如把一张表里的数据清洗后写入另一张表既要控制内存占用又要记录每日处理进度。这时用游标比一次性SELECT更稳。在 plpgsql 里我一般用FOR ... IN循环来隐式使用游标不需要显式声明CURSORCREATE OR REPLACE FUNCTION cleanup_logs(days int) RETURNS int LANGUAGE plpgsql AS $$ DECLARE deleted_count int : 0; log_row record; BEGIN FOR log_row IN SELECT id FROM logs WHERE created_at now() - make_interval(days days) ORDER BY id LIMIT 10000 LOOP DELETE FROM logs WHERE id log_row.id; deleted_count : deleted_count 1; IF deleted_count % 1000 0 THEN RAISE NOTICE 已处理 % 条, deleted_count; END IF; END LOOP; RETURN deleted_count; END; $$;这里LIMIT 10000是防呆设计防止一次取太多数据撑爆内存。按主键id循环 DELETE虽然每条 DELETE 都有一条独立事务开销但在清理日志这种低频场景完全可以接受。如果追求性能更好的做法是换成一次性 DELETE 并 LIMIT见后面 5.2 节的讨论。显式游标写法适用于要手动控制打开和关闭的场景类似下面这样DECLARE cur CURSOR FOR SELECT id, name FROM users WHERE status active; BEGIN OPEN cur; FETCH NEXT FROM cur INTO uid, uname; WHILE FOUND LOOP ... FETCH NEXT FROM cur INTO uid, uname; END LOOP; CLOSE cur; END;说实话我在日常开发里很少用显式游标FOR ... IN循环已经涵盖绝大多数场景而且少写不少代码。3.3 动态SQLEXECUTE format() 必备套路动态 SQL 就是“拼 SQL 字符串再执行”在数据迁移、报表配置化、字段名不确定的场景格外重要。plpgsql 里用EXECUTE配合format()防止注入和转义问题。举个例子我需要一个函数根据传入的表名和条件统计行数。表名是外部传入的不能直接写死在 SQL 里CREATE OR REPLACE FUNCTION count_rows_in_table( tbl_name text, condition_col text, condition_val text ) RETURNS bigint LANGUAGE plpgsql AS $$ DECLARE cnt bigint; sql_text text; BEGIN sql_text : format( SELECT count(*) FROM %I WHERE %I %L, tbl_name, condition_col, condition_val ); EXECUTE sql_text INTO cnt; RETURN cnt; END; $$;重点解释format()的三个转换标记%I把参数当标识符处理自动加双引号并处理特殊字符适合表名、字段名。%L把参数当字面量处理自动加单引号并转义适合字符串值。%s不加修饰直接插入适合不会引起歧义的数字或排序方向。用%I和%L之后就算传入users; DROP TABLE users;这样的恶意输入也只会被当成一个普通字符串值不会被注入执行。这是我在生产环境拼动态 SQL 的底线。动态 SQL 执行后拿结果用INTO cnt接住。如果是返回结果集RETURN QUERY EXECUTE sql_text;这个组合把“动态构造查询”和“返回结果集”打通了几乎是报表类和迁移类需求的标准答案。4. 实战写一个处理JSON数据的实用函数4.1 需求场景与设计思路热词里提到“pg json函数”这个点非常实用。PostgreSQL 的 JSON 处理能力一直很强但用多了会发现一个痛点当 JSON 键不存在或者类型不对时-、-操作符虽然不报错却会返回 NULL业务里通常希望给一个默认值。实际业务中我经常面对这样的需求外部系统传入一个 JSON 串里面有个age字段可能是数字可能是字符串也可能不存在。我需要一个函数安全地取出这个字段取不到就给默认值0类型不对也能兜底。4.2 函数实现JSON安全取值默认值考虑为主流使用场景提供两个版本一个处理json类型一个处理jsonb类型然后在一个函数里做类型判断。不过为了简化我通常直接基于jsonb写因为jsonb支持索引且去重了键顺序性能也好。安全取值函数如下CREATE OR REPLACE FUNCTION jsonb_get_text( doc jsonb, key text, default_val text DEFAULT NULL ) RETURNS text LANGUAGE plpgsql AS $$ DECLARE val jsonb; BEGIN IF doc IS NULL THEN RETURN default_val; END IF; val : doc - key; IF val IS NULL THEN RETURN default_val; END IF; -- jsonb 里 null 也是值这里把 null 当成不存在 IF val null::jsonb THEN RETURN default_val; END IF; -- 如果是数组类型取第一个元素 IF jsonb_typeof(val) array AND jsonb_array_length(val) 0 THEN val : val - 0; END IF; -- 尝试转成 text转不了就返回默认值 BEGIN RETURN val # {}; EXCEPTION WHEN OTHERS THEN RETURN default_val; END; END; $$;再说一个常用的jsonb_extract_path_textPostgreSQL 原生就支持路径提取SELECT jsonb_extract_path_text({a:{b:hello}}::jsonb, a, b);输出hello。但它没有默认值功能键不存在就返回 NULL所以包装成函数是合理的。4.3 调用测试与边界情况写完之后一定要把边界情况测一遍。我会执行下面的测试SELECT jsonb_get_text({name:张三,age:28}::jsonb, age, 0); -- 返回 28 SELECT jsonb_get_text({name:张三}::jsonb, age, 0); -- 返回 0 SELECT jsonb_get_text({age:null}::jsonb, age, 0); -- 返回 0 SELECT jsonb_get_text(NULL::jsonb, age, 0); -- 返回 0 SELECT jsonb_get_text({age:[20,21]}::jsonb, age, 0); -- 返回 20数组取第一个元素 SELECT jsonb_get_text({age:true}::jsonb, age, 0); -- 返回 true因为布尔可以转文本这里有个细节val # {}可以把 jsonb 值转成纯文本但不支持对象类型对象类型转文本会报错走EXCEPTION WHEN OTHERS兜底返回默认值。所以传入{age:{nested:1}}时函数返回0不会报错中断调用。这个函数在数据接入层非常好用外部接口传什么妖魔鬼怪都能兜住。我再补一个变体直接返回numeric类型适合统计场景CREATE OR REPLACE FUNCTION jsonb_get_number( doc jsonb, key text, default_val numeric DEFAULT 0 ) RETURNS numeric LANGUAGE plpgsql AS $$ DECLARE v text; BEGIN v : jsonb_get_text(doc, key, NULL::text); IF v IS NULL OR v THEN RETURN default_val; END IF; BEGIN RETURN v::numeric; EXCEPTION WHEN OTHERS THEN RETURN default_val; END; END; $$;这种做法比在业务代码里反复写CASE WHEN干净多了。封装好之后整个团队写 SQL 都省力。5. 常见报错与排查实录5.1 function does not exist函数明明建了却找不到热词里有个很有趣的现象命令行里敲npm、git、claude提示“无法将xxx项识别为 cmdlet、函数、脚本文件或可运行程序的名称”这本质是环境变量 Path 没配好命令找不到。PostgreSQL 也有极类似的报错function xxx does not exist常见于函数建好了调用时却找不到。区别在于PostgreSQL 的“找不到”通常不是函数不存在而是你调用时的函数签名和实际定义对不上。比如SELECT calc_discount(金卡, 1000);但函数定义的第二个参数是numeric这里传入整数1000PostgreSQL 会尝试隐式转换。如果失败或转换规则不明确就会报function calc_discount(text, integer) does not exist。排查思路就三条确认函数名没错大小写是否正确。PostgreSQL 对未加双引号的标识符默认折叠成小写如果你建表时用了大写或驼峰名调用时也会出问题。确认参数类型完全匹配。最稳妥的办法是调用前\df查看函数签名或者在查询里显式转型。确认 schema 搜索路径。如果函数建在自定义 schema 里而当前search_path没有包含它就会找不到SHOW search_path; SET search_path TO my_schema, public; ALTER FUNCTION my_schema.calc_discount(text, numeric) SET search_path TO my_schema, public;这里很容易踩的坑是函数内部如果还引用了其他表或函数也要保证这些对象在search_path范围内不然函数执行时一样报relation does not exist。5.2 性能陷阱别在函数里写逐行循环我在实际项目里听人说过这么一句话“我用 plpgsql 写了个循环处理十万行结果跑了半小时没跑完。”真相是plpgsql 的 FOR 循环如果逐行执行 SQL每一行都可能触发一次查询计划生成和运行性能奇差无比。原则是能在一条 SQL 里完成的事绝不用循环。以 3.2 节的清理日志为例性能更好的写法是批量删除CREATE OR REPLACE FUNCTION cleanup_logs_batch(days int, batch_size int DEFAULT 1000) RETURNS int LANGUAGE plpgsql AS $$ DECLARE deleted_count int : 0; BEGIN LOOP DELETE FROM logs WHERE id IN ( SELECT id FROM logs WHERE created_at now() - make_interval(days days) ORDER BY id LIMIT batch_size ); IF FOUND THEN deleted_count : deleted_count batch_size; COMMIT; ELSE EXIT; END IF; END LOOP; RETURN deleted_count; END; $$;这个写法每一步只批量删除一千条避免长时间锁表也避免一次事务撑爆 WAL 日志。注意COMMIT在函数内部是可以使用的前提是没有在更高层事务里调用这个函数。在生产维护任务中这种方式非常实用。另外函数声明时还要注意稳定性标记。PostgreSQL 默认把函数标记为VOLATILE意思是每次执行结果可能不同比如取当前时间。如果函数实际上是STABLE同一条 SQL 在一个事务内返回相同结果如只读函数或IMMUTABLE纯函数结果只依赖入参建议显式声明CREATE OR REPLACE FUNCTION calc_discount(level text, amount numeric) RETURNS numeric LANGUAGE plpgsql IMMUTABLE AS $$ ... $$;IMMUTABLE标记能让函数在索引表达式和查询优化时提前计算性能提升非常明显。但注意千万不要给涉及表格读取或时间函数的函数乱标IMMUTABLE否则会在索引或物化视图里缓存出错误结果。5.3 函数权限SECURITY DEFINER与属主陷阱PostgreSQL 函数的默认执行权限是SECURITY INVOKER也就是说函数以调用者的权限运行。如果调用者没有表的 SELECT 权限就算函数属主是超级用户调用者一样会因为查不到表而报错。我做过一个权限收口项目做法是给报表用户只开放函数调用权限不开放底层表权限这时就需要SECURITY DEFINERCREATE OR REPLACE FUNCTION get_public_report(day date) RETURNS TABLE(...) LANGUAGE plpgsql SECURITY DEFINER SET search_path public AS $$ ... $$; REVOKE ALL ON FUNCTION get_public_report(day date) FROM PUBLIC; GRANT EXECUTE ON FUNCTION get_public_report(day date) TO report_user;这里有三个坑必须说清楚带SECURITY DEFINER的函数会以函数属主的权限运行。如果函数是可写的调用者有可能越权操作数据必须严格限制谁能调用。函数运行时建议固定search_path否则攻击者可以通过构造同名对象劫持函数内部查询。上面SET search_path public就是干这个用的。SECURITY DEFINER函数内部做的任何数据修改都不会改变调用者的真实权限只是“临时借用”属主权限。审计时要注意区分。我的经验是能不用SECURITY DEFINER就不用迫不得已用了一定要配严格授权和固定search_path。6. 函数的真实应用场景触发器、ETL与运维6.1 用触发器函数自动维护updated_atPostgreSQL 里最经典的函数场景就是触发器函数。业务表几乎都要有updated_at字段每次 UPDATE 都要自动更新这件事用触发器函数做最省心。先创建函数再挂触发器CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at : now(); RETURN NEW; END; $$; CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION set_updated_at();注意两处容易混淆的细节函数返回类型是trigger不是其他类型。创建触发器的语法PostgreSQL 新版本用EXECUTE FUNCTION老版本可能写EXECUTE PROCEDURE两种写法都能用但官方推荐FUNCTION。多张表共用一个set_updated_at()函数只需要每个表挂一个 trigger函数本身不用重复写。这就是“写一次函数多处复用”的典型价值。6.2 函数在增量同步与ETL中的角色热词里还有“postgresql增量同步软件”。增量同步这个事很多团队用工具解决但内部逻辑往往绕不开函数。举几个我参与过的实际场景同步批次调度写一个函数生成批次号保证每次同步任务拿到唯一 ID并且把批次信息写入同步日志表。变更数据捕获在源表上做触发器函数把每次增删改影响的数据行写入一张变更记录表然后由同步任务消费这张表实现增量同步。ETL 清洗从外部接口拉下来的 JSON 数据先调用一遍 4.2 节那种 JSON 解析函数做清洗转换再落进正式表。比如批次号函数CREATE OR REPLACE FUNCTION create_sync_batch(module_name text) RETURNS bigint LANGUAGE plpgsql AS $$ DECLARE batch_id bigint; BEGIN INSERT INTO sync_batch_log(module_name, status, created_at) VALUES (module_name, RUNNING, now()) RETURNING id INTO batch_id; RETURN batch_id; END; $$;这么做的意义在于同步流程里任何一个环节出错都能通过批次号将数据“打回重跑”不会污染目标表。函数本身并不复杂但它把流程的优雅度提升了一大截。6.3 函数调试与版本管理的经验写函数最痛苦的阶段是调试。PostgreSQL 里最方便的调试手段还是RAISE NOTICE我经常在函数里加几个调试输出确认中间变量值RAISE NOTICE 当前 user_id %, 结果 %, uid, result;在 psql 客户端里执行函数时这个 NOTICE 会直接打出来。如果用的是 pgAdmin会在消息面板里显示。注意RAISE NOTICE只是打日志不会改变返回值所以可以放心保留生产环境也可以留着做低级别日志。关于函数版本管理我的建议是所有函数定义都写成 SQL 脚本文件纳入 Git 管理。发布时按文件名排序执行推荐用 migrations 工具或者简单的psql -f批量执行。上线前在测试库跑一遍尤其注意CREATE OR REPLACE FUNCTION只能替换同名同参数类型的函数一旦参数类型变了需要删掉旧函数重建DROP FUNCTION IF EXISTS calc_discount(text, numeric);旧函数不删掉新的CREATE OR REPLACE只是新增了一个重载版本原有的调用可能还会走旧逻辑这个坑我见过不止一次。最后再说点我自己的体会。函数这东西写出来很容易写好很难。我踩过的坑包括但不限于忘写IMMUTABLE导致慢查询、SECURITY DEFINER权限过大引发安全审查、循环十万行跑了半小时、函数重载导致旧调用走错版本。现在我的流程固定了先明确需求和调用方式再选语言然后写最小可用版本最后补异常处理和稳定性标记几乎不会再翻车。如果你正在被 PostgreSQL 函数折磨建议先把你最常用的三五个函数改成统一风格拆成标准模板后面所有新函数都往模板上靠。等真正用顺手了你会发现在数据库里写逻辑比在业务代码里拼 SQL 舒服太多。