☰
MySQL UDF实战:用C语言扩展实现精准球面距离计算
2026/10/8 15:13:18 网站建设 项目流程

最近一个地图业务把我逼到了墙角:MySQL 里需要按两个坐标点之间的距离做过滤和排序,翻遍内置函数,没有一个能直接算球面距离的。网上不少同学用一长串SIN、COS、ACOS拼 SQL,能跑,但百万行数据心里实在没底,而且那串表达式塞进WHERE,过两周自己再看都头疼。后来我改用 MySQL UDF(用户自定义函数),自己写一个 C 函数编译成动态库,一次加载到处调用,问题彻底解决了。这篇就把这个完整的 MySQL UDF 实例从头到尾拆开,从函数接口设计、C 源码、编译部署,到后来踩过的坑和性能对比,通通写出来给需要的人参考。

如果你正在被“MySQL 内置函数不够用”困扰,或者只是好奇 UDF 到底怎么玩,这篇文章可以直接照着抄作业。会用一点 C 语言最好,不会也问题不大,把代码复制下来按步骤编译就行。

1. 什么情况下你才需要 UDF:先搞清楚它和存储过程、纯 SQL 的分工

1.1 UDF 到底解决什么问题

MySQL 的内置函数是固定的,但业务需求千奇百怪,总会遇到“这个函数怎么没有”的情况。UDF 就是 MySQL 留给 C/C++ 的扩展口子:你写一个符合约定接口的函数,编译成动态库放到插件目录,再用CREATE FUNCTION ... SONAME注册进去,之后它就和一个普通内置函数一样,能在SELECT、WHERE、ORDER BY里直接调用。

整个过程里,函数是在 MySQL 进程内执行的,不是外部子进程。所以它有一个天然优势:计算逻辑和数据库引擎之间没有进程通信开销,调用非常轻量。这也决定了它的定位——适合做有明确算法逻辑、需要高频调用的计算。我这次做的球面距离计算,就是典型场景。

1.2 UDF、存储函数、纯 SQL 表达式,三者的分工完全不同

很多人会把 UDF 和存储函数搞混。存储函数是纯 SQL 写的,在 MySQL 内部解释执行,适合不复杂的规则复用;UDF 是编译过的机器码,性能高得多,但它毕竟是要你用 C/C++ 维护额外代码,部署成本也高。我整理了一个表,方便你对照着选:

对比维度UDF存储函数纯 SQL 表达式
开发语言C/C++SQLSQL
执行模型编译后的动态库,进程内执行解释执行解释执行
能否在 WHERE/ORDER BY 中使用可以可以,但优化有限制可以
性能高,适合高频计算中等,复杂逻辑会拖慢看优化器脸色
部署成本需要编译、拷贝 .so/.dll、注册直接 SQL 创建无需部署
最适合的场景复杂算法、统一口径、高频调用简单规则复用、流程封装一次性的轻量计算

这里要特别提醒一个点:UDF 的执行过程对优化器来说是黑盒。优化器无法把函数内容下推到存储引擎,也无法改写你的函数逻辑。所以如果数据量很大,千万别指望一个 UDF 能替代索引。正确思路是先用常规条件把数据范围缩到足够小,再在结果集上做精确计算。

1.3 我这个需求是怎么来的

背景是查附近的门店:表里有店铺的经纬度,用户打车过来也带着经纬度,要查十公里内的门店并按距离排序。几万条数据时,全表扫一遍算距离其实也不是不行,但这几个问题是躲不掉的:

  • 每次查询都要算全表所有行的球面距离,纯 SQL 表达式写得越长,解释器开销越大;
  • 全套SIN/COS/SQRT表达式写在业务 SQL 里,换个人接手根本看不懂;
  • 距离计算逻辑分散在多个查询里,口径很难统一。

所以我把距离计算收敛成一个 UDF。这样业务 SQL 就变成了很干净的一行调用,后续再有别的模块要算距离,直接复用同一个函数就行。

2. 手写一个球面距离 UDF:从数学公式到可加载的 .so

2.1 Haversine 公式:为什么选它

球面距离计算最常见的公式是 Haversine 公式。它基于球面三角学,能算两个经纬度点之间的大圆距离。为什么不直接用大圆公式里的acos?因为acos在两点距离很近时,因为浮点精度问题,括号里的值可能超过 1 导致定义域错误。Haversine 用atan2规避了这个坑,在近距离和远距离下都稳定。

核心公式(单位:公里):

a = sin²(Δφ/2) + cos φ1 ⋅ cos φ2 ⋅ sin²(Δλ/2) c = 2 ⋅ atan2(√a, √(1−a)) d = R ⋅ c

其中φ1、φ2是两个点的纬度,Δφ是纬度差,Δλ是经度差,R取地球平均半径 6371.0088 公里。我这里用 6371.0088 而不是常见的 6371,是为了把参考椭球模型带来的精度损失压到最小,实际业务里两者差距很小,用哪个都行。

2.2 UDF 源码解析:init 函数、主函数、deinit 函数

MySQL 官方 UDF 约定,一个 UDF 最少要有 init、主函数,可选加 deinit。init 在函数被调用前执行,用来校验参数数量、类型,也可以分配内存;主函数是核心计算逻辑;deinit 用来释放 init 里分配的资源。

我写的完整源码如下:

#include <mysql.h> #include <math.h> #include <string.h> static double as_double(UDF_ARGS *args, unsigned int idx, int *is_null) { if (args->args[idx] == NULL) { *is_null = 1; return 0.0; } switch (args->arg_type[idx]) { case REAL_RESULT: return *(double *)args->args[idx]; case INT_RESULT: return (double)*(long long *)args->args[idx]; case DECIMAL_RESULT: /* DECIMAL 在多数版本中以字符串形式进入 UDF,这里按字符串解析 */ return atof(args->args[idx]); case STRING_RESULT: return atof(args->args[idx]); default: *is_null = 1; return 0.0; } } my_bool distance_init(UDF_INIT *initid, UDF_ARGS *args, char *message) { if (args->arg_count != 4) { strcpy(message, "distance() requires 4 arguments: lat1, lon1, lat2, lon2"); return 1; } for (unsigned int i = 0; i < args->arg_count; i++) { if (args->arg_type[i] != REAL_RESULT && args->arg_type[i] != INT_RESULT && args->arg_type[i] != DECIMAL_RESULT && args->arg_type[i] != STRING_RESULT) { strcpy(message, "distance() only accepts numeric arguments"); return 1; } } initid->ptr = NULL; return 0; } void distance_deinit(UDF_INIT *initid) { /* 本函数不分配额外内存,占位保留 */ } double distance(UDF_INIT *initid, UDF_ARGS *args, char *is_null, char *error) { int null_flag = 0; double lat1 = as_double(args, 0, &null_flag); double lon1 = as_double(args, 1, &null_flag); double lat2 = as_double(args, 2, &null_flag); double lon2 = as_double(args, 3, &null_flag); if (null_flag) { *is_null = 1; return 0.0; } double phi1 = lat1 * M_PI / 180.0; double phi2 = lat2 * M_PI / 180.0; double dphi = (lat2 - lat1) * M_PI / 180.0; double dlambda = (lon2 - lon1) * M_PI / 180.0; double a = sin(dphi / 2.0) * sin(dphi / 2.0) + cos(phi1) * cos(phi2) * sin(dlambda / 2.0) * sin(dlambda / 2.0); double c = 2.0 * atan2(sqrt(a), sqrt(1.0 - a)); return 6371.0088 * c; }

几个容易忽略的细节:

  • distance_init里我做了参数数量和类型的检查。UDF 注册成功后,如果调用时传入参数不对,init 阶段直接报错,能防止主函数里踩到未定义行为。
  • as_double函数处理了NULL参数的情况。MySQL 里一列如果有NULL,直接传进来,C 里解引用空指针会崩。这里标记is_null,主函数里置*is_null = 1返回 0,MySQL 就会把结果当成NULL处理。
  • initid->ptr = NULL表示不分配私有内存。如果你的 UDF 需要维护跨调用的状态,比如做累加器,那就在 init 里malloc,把指针存到initid->ptr,最后在 deinit 里free掉。

2.3 编译成动态库:命令和常见编译报错

Linux 下编译命令很简单:

gcc -shared -fPIC $(mysql_config --cflags) -o distance.so distance.c -lm

如果系统提示mysql_config: command not found,说明没装开发包。Debian/Ubuntu 系装default-libmysqlclient-dev,CentOS/RHEL 系装mysql-devel或libmysqlclient-devel,装完再试。没有mysql_config也能编译,直接手动指定头文件路径即可:

gcc -shared -fPIC -I/usr/local/mysql/include -o distance.so distance.c -lm

-lm这个参数别漏了。链接数学库。我之前第一次编译时忘了加,报错一长串undefined reference to 'sin'、undefined reference to 'cos'之类,其实就是数学库没链接上。

还有一个容易踩的编译坑是M_PI未声明。部分编译环境在严格模式下不直接暴露M_PI,报错提示M_PI undeclared。最简单的方法是在文件顶部加:

#ifndef M_PI #define M_PI 3.14159265358979323846 #endif

如果 MySQL 8.0 下编译提示my_bool相关类型问题,直接改成bool就行,8.0 的官方接口更推荐用bool。5.7 环境下用my_bool则更稳妥,两边实际都能编译过,遇到报错再换也不迟。

2.4 注册函数和首次调用

先看插件目录在哪:

SHOW VARIABLES LIKE 'plugin_dir';

把编译好的distance.so拷进去,MySQL 运行用户需要有读取权限:

cp distance.so /usr/local/mysql/lib/plugin/ chmod 755 /usr/local/mysql/lib/plugin/distance.so

然后注册函数:

CREATE FUNCTION distance RETURNS REAL SONAME 'distance.so';

这里必须强调一个容易蒙圈的坑:MySQL 里有两个CREATE FUNCTION,一个是 UDF(带SONAME,加载外部 .so),一个是存储函数(不带SONAME,函数体是 SQL)。网上搜“MySQL 创建函数”时,两套教程会混在一起,你注册 UDF 时一定要带上SONAME关键字,否则 MySQL 会认为你想建存储函数,报语法错误或者建出来一个完全不一样的东西。

注册之后直接测试:

SELECT distance(31.2304, 121.4737, 39.9042, 116.4074) AS distance_km;

我这边实测输出约 1068 公里,上海到北京的直线距离,量级完全对得上。

业务 SQL 也变得非常干净:

SELECT shop_id, shop_name, distance(31.2304, 121.4737, lat, lng) AS distance_km FROM shop HAVING distance_km <= 10 ORDER BY distance_km LIMIT 50;

注意HAVING里可以用distance_km这个别名,WHERE里不行。如果你想用WHERE过滤,就必须写完整的distance(...)表达式。

3. 从能跑到跑对:我在验证和排错中踩到的坑

3.1 精度、NULL 和参数类型:为什么测试用例不能只写一条

我写完 UDF 后,第一个测试就是算上海到北京的距离,看着结果正常就以为完事了。后来在真实数据上跑,发现某些行返回的居然是NULL,排查半天才意识到是数据里存在经纬度为NULL或脏数据。这时候主函数里的is_null处理就起作用了,但更重要的是测试用例要覆盖异常场景:

SELECT distance(NULL, 121.4737, 39.9042, 116.4074) AS null_lat, distance(31.2304, 121.4737, NULL, 116.4074) AS null_lon, distance(31.2304, 121.4737, 39.9042, 116.4074) AS normal;

第一列和第二列正确返回NULL,第三列正常返回距离值,这才算过关。

参数类型也值得单独说。UDF 传入参数在 C 接口里有STRING_RESULT、REAL_RESULT、INT_RESULT、DECIMAL_RESULT等类型标识,但DECIMAL_RESULT在不同 MySQL 小版本里表现并不完全一致。我遇到过 MySQL 5.7 按字符串传入、MySQL 8.0 按 double 指针传入的情况,一个不留意就解引用错乱。最稳的写法是像我在as_double里那样,对DECIMAL_RESULT统一走字符串解析,或者干脆在 SQL 调用时显式CAST(x AS DOUBLE),彻底避开这个问题。

3.2 动态库加载失败:符号导出和链接依赖

注册时如果遇到Can't open shared library,不是文件没找到,就是文件格式和 MySQL 进程不匹配。按这个顺序排查:

先确认文件确实在plugin_dir指向的目录里,且运行 MySQL 的系统用户有读权限(chmod 755一般就够了);

然后确认distance.so的位数和 MySQL 一致。MySQL 是 64 位,你却编译出 32 位动态库,加载照样失败,用file distance.so看一眼就知道;

再用nm -D distance.so | grep distance确认对外导出符号里确实有distance和distance_init。有人喜欢用-fvisibility=hidden编译,把多余符号藏掉,结果把 UDF 入口函数也藏了,MySQL 加载后会说function not found一类错误。UDF 的入口函数必须保持默认可见,或者用__attribute__((visibility("default")))显式标记。

如果编译时链接了额外的第三方库,ldd distance.so看一下依赖,目标机器上缺库同样会加载失败。我在一个精简容器环境里就遇到过libm.so.6路径异常的情况,最后重新在容器里编译解决的。

3.3 MySQL 版本差异和 Docker 环境的兼容性

MySQL 5.7 和 8.0 的 UDF 接口相对稳定,同一个 C 源码在两个版本下基本都能编译运行,但我不建议直接把一个环境编译好的 .so 拷贝到另一个环境用。原因很现实:glibc 版本、MySQL 编译选项、头文件细微差异,都可能导致加载异常。尤其是 Docker 容器里跑 MySQL,宿主机编译的 .so 和容器内的 glibc 版本不一致,很容易出现加载时ELF file OS ABI invalid这种诡异报错。最省心的做法是:在离 MySQL 运行环境最近的地方编译,容器里的 MySQL 就在容器内编译。

Windows 上用 MySQL 的话,编译出来是.dll,需要 MSVC 或者 MinGW 环境,并且要链接 MySQL 提供的导入库,过程比 Linux 麻烦不少。如果没有特别强的跨平台需求,UDF 开发建议优先放在 Linux 环境做。

还有一个大版本升级的坑要提醒:MySQL 跨大版本升级后,UDF 动态库可能需要重新编译。5.7 能用的 .so,在 8.0 下不保证还能继续用,升级之前要把这茬纳入回归清单。

3.4 性能实测:UDF 比纯 SQL 表达式快多少

为了说服自己“这点开销不值得造轮子”,我在一台 4 核 8G 的虚拟机上做了简单对比:造了 10 万行随机经纬度数据,分别用 UDF 和纯 SQL 公式版算距离排序,各跑五遍取平均值。

UDF 版单次查询大约 0.9 秒,纯 SQL 公式版大约 1.6 秒,两边算出来的距离值在浮点精度范围内完全一致。差距主要来自 SQL 解释器要反复执行几十个数学函数调用,而 UDF 是把整个计算收敛在一个编译好的 C 函数里。如果数据量继续增长,SQL 公式版的表达式解析开销占比会更明显。

不过这里要说句公道话:性能只是其中一个考量。真正让我决定用 UDF 的是可维护性。纯 SQL 公式版写出来有半屏长,藏在业务代码里,每读一次头大一次;UDF 暴露给业务方的只有一个distance(...),逻辑统一在 C 文件里管理,这才是长期收益最大的地方。

4. UDF 的安全边界、维护节奏和进阶空间

4.1 动态库运行在 MySQL 进程内,权限和责任都要收口

UDF 是编译后的原生代码,跑在 MySQL 进程里。这意味着一旦函数里有段错误、内存越界,崩掉的不只是当前查询,很可能是整个 MySQL 实例。这不是危言耸听,C 代码里的一个空指针、一个数组越界,在 UDF 场景下都是生产事故级别的隐患。

所以我对 UDF 的态度是:代码必须可控、来源必须可信。网上搜到现成的 .so 直接下载这种操作,我是坚决不做的,因为你完全不知道里面编译了什么。就算是你自己写的 C 代码,也要先过源码评审,在测试环境做一轮完整回归,然后才能走到生产。创建和删除 UDF 的权限在生产环境里应该收口给 DBA 统一管理,业务账号只保留调用权限,否则一个普通开发账号把线上 UDF 替换了,后果很难预料。

另外还要留个心眼:SHOW FUNCTIONS能看到当前实例里有哪些 UDF,SELECT * FROM mysql.func能查到注册记录。定期核对一下,确认没有多出来来路不明的函数,是个好习惯。

4.2 卸载、替换和升级的正确姿势

要卸载 UDF 很简单:

DROP FUNCTION distance;

但这里有一个很多人不知道的细节:DROP FUNCTION只是让 MySQL 不再注册这个函数,并不会删除磁盘上的 .so 文件。如果你想把动态库文件也清理掉,需要手动删,建议挑业务低峰期操作,或者在变更窗口里一起做。

如果要更新 UDF 的逻辑(比如调整地球半径、修复一个 bug),流程是:

  1. SHOW FUNCTIONS LIKE 'distance'确认现有函数;
  2. DROP FUNCTION distance;注销旧函数;
  3. 替换 plugin 目录下的 .so 文件;
  4. CREATE FUNCTION distance RETURNS REAL SONAME 'distance.so';重新注册;
  5. 跑一遍回归 SQL,确认新旧行为符合预期。

按这个顺序操作,基本能避免“注册了新的但加载的还是旧代码”这种诡异问题。MySQL 对已加载动态库的处理在不同版本里有点微妙,替换 .so 后最保险的做法是在维护窗口重启实例,确保内存里的旧代码被彻底清掉。

4.3 更高级的玩法:AGGREGATE UDF 和边界判断

标量 UDF 只是入门,MySQL 还支持聚合类 UDF,也就是 AGGREGATE UDF,可以用来实现自定义的聚合函数,比如按组算加权平均、去重后的中位数、更复杂的统计指标。实现起来比标量 UDF 麻烦,需要实现xfunc_clear、xfunc_add、xfunc_remove等一组回调函数,而且必须标记CREATE AGGREGATE FUNCTION ... SONAME ...来注册。如果你只是偶尔需要一个定制聚合,可以先试试 MySQL 内置的窗口函数和 JSON 函数能不能拼出来,实在拼不出再说。

还有一个边界判断要讲清楚:UDF 适合标量计算和聚合计算,但不适合做多行结果集返回,也没有办法让 UDF 主动发起网络请求再等响应。有人试图在 UDF 里封装 HTTP 调用,技术上能做,但会把 MySQL 线程阻塞住,并发一高全库遭殃,这个思路我不建议碰。

我个人在实际操作中的体会是:写 UDF 这件事,很多人一听 C/C++ 就劝退了。但从我这个例子看,只要懂一点 C,整个链路一点都不难——公式想清楚,源码敲进去,编译部署再回归,一个下午足够。回头来看,最大的收获不只是这个distance函数省了多少 SQL,而是以后再遇到 MySQL 函数不够用的场景,我心里清楚还有这条路可以走,而且这条路怎么走、有哪些坑,我已经有数了。如果你也卡在某个内置函数解决不了的场景,不妨给自己留一个下午,照着这篇文章把 UDF 跑通一次,你会发现它真的没有那么神秘。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询