ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

库内机器学习全流程实践:数据出库与模型入库的闭环管理

库内机器学习全流程实践:数据出库与模型入库的闭环管理 说到数据出库和模型入库很多人第一反应就是把数据库里的表导成CSV扔给训练脚本跑一版再把预测结果写回库里。这个流程看着简单真上了业务项目就明白有多难受——我见过不少团队卡在“模型上线”这一步模型文件在个人电脑、测试服务器、同事网盘里各存一份谁也说不清线上到底跑的是哪个版本。今天想聊的库内机器学习全流程实践核心就是把“数据出库”到“模型入库”这一段管起来让训练、评估、上线形成一个可回滚、可审计的闭环。不管是做用户流失预测、销量预估还是风控评分这套思路都能直接套用。1. 为什么我坚持让机器学习流程“围着数据库转”先讲个背景。前几年我接手过一个电商客户流失预测项目业务流程一开始就是最原始的做法数据工程师从订单库导出几张表算法工程师手工合并成特征表在本地跑随机森林最后把预测结果塞回数据库。听起来没什么问题可一到正式上线就各种翻车。后来我下定决心把整套流程改造成以数据库为核心的“数据出库—模型入库”闭环才算是真正救了回来。1.1 传统“导出-训练-回填”到底坑在哪儿传统流程最大的问题不是算法而是“数据边界”没人管。CSV文件导出去之后字段约束没了、主键没了、更新策略也没了数据血缘完全断掉。算法工程师拿到的文件可能是三天前的数据工程师自己都记不清这份文件是用哪段SQL生成的。等模型训练完再写一个临时脚本把CSV结果回填到数据库一旦回填任务重复执行没有幂等控制线上直接多出一批脏数据。另一个坑是模型文件本身的散乱。训练好的模型可能是一个pickle文件也可能是一串PMEval结果散落在各自的notebook、U盘、云盘里。没有统一的模型注册表没有版本号没有特征依赖清单。上线时要靠“那个跑得最好的文件在你那吗”这种对话来交接。这种项目前面算法调得再好最后都会死在工程化这个环节上。传统流程还有一个隐性成本全量数据反复拷贝。业务库几个T的数据导出一份CSV要好几个小时传文件又要半天训练完还要把结果文件重新入库磁盘和网络都在烧钱。更麻烦的是大表导出经常把生产库IO打满业务方投诉“数据库怎么又慢了”。这些坑叠加起来团队的大量时间不是在调模型而是在做数据搬运和版本对账。所以后来我把目标定得很清楚数据能不拷走就不拷走模型不能不管理。数据尽量在库内完成特征加工只把训练必需的特征以受控方式“放出库”训练完的模型文件、元数据、指标、特征清单必须全部“入库”统一管理。这整个思路就是标题里那八个字数据出库、模型入库。1.2 库内机器学习到底是什么适合哪些场景所谓“库内机器学习”并不一定要求训练过程必须在数据库引擎里跑完而是指整条链路围绕数据库来做数据存储、特征加工和模型资产托管。我把它分成三个层次。第一层是“完全库内”也就是靠数据库自带的算法包直接在SQL里训练和预测。现在不少数据库都提供了这类能力比如Oracle的OML、达梦的DMML、PostgreSQL的MADlib。这类方案的好处是数据完全不出库权限、安全、事务都沿用数据库既有能力缺点是算法种类有限超参数调优和自定义损失函数很受限制一般适合逻辑回归、决策树这类比较标准的算法。第二层是“半库内”这也是我目前在绝大多数项目里推荐的方式。数据库负责特征宽表的构建和模型文件的托管训练过程放在外部Python等计算环境里但训练脚本只通过受控接口读取特征视图不直接裸连生产表训练完成后模型二进制和元数据写回数据库的模型注册表。这种方案兼顾了算法灵活性和数据治理是我这篇文章要重点讲的。第三层是“纯库外”数据库只当一个数据源特征加工、模型存储、线上推理全部在外部系统完成。这种方式适合数据量特别大、计算需要分布式集群的场景但意味着前面说的所有模型管理问题都要自己另外搭一套体系来解决。很多做机器学习教程和课程环境搭建的人往往只教到“训练脚本怎么写”很少讲“模型怎么放进库里、线上怎么调”。但真实业务里最大的成本恰恰在数据对齐和模型管理。我的经验是如果你的业务数据量在几十GB到几TB这个量级算法以常规机器学习模型为主团队又不想专门养一套MLOps平台那“半库内”这套模式就是性价比最高的选择。2. 整体架构数据出库和模型入库的边界怎么划“数据出库”和“模型入库”这两个动作是整条流水线的两条边界。边界划清楚了后面所有环节都不会乱。我习惯把整条链路分成四层数据源层、特征层、训练层、模型服务层每一层之间的交互都必须走数据库这张“合同”。2.1 数据出库不是把表导出来而是受控地取特征数据出库的第一步是在数据库里把源表加工成特征宽表。这里的关键动作不是写SQL导出文件而是建立一个“特征视图”让算法侧只能看到已经加工好的特征列看不到明细敏感字段。比如订单表、用户表、退款表分散在好几个库里我通常会建一个v_feature_order_customer视图把近30天订单数、近30天消费金额、退款次数、活跃天数这些特征一次性算好。这个视图就是“出库”的闸口。训练脚本只能以只读账号访问这个视图不允许直连底层明细表做全表扫描。这样做的原因有两层一是权限可控算法工程师拿到的永远是最小数据集二是口径统一训练和线上推理都基于同一个视图不会出现“训练时用A逻辑、线上用B逻辑”的经典翻车事故。数据出库的“量”也要受控。训练集不是越大越好尤其深度学习之外的传统机器学习模型在几千万条样本上已经能收敛得很好。我一般会在特征宽表基础上做分层抽样把训练样本控制在500万条以内必要时按时间窗口切分比如只取最近180天数据减少历史分布漂移的影响。这一步看起来简单实际对训练资源和迭代速度影响非常大。出库方式我推荐用ETL调度工具来完成比如DolphinScheduler自带的数据抽取任务或者写一个轻量级Python脚本每次从特征视图抽样到一张带data_version字段的样本表里。这样每次训练用的数据都有版本可追溯重跑和回退都有依据。2.2 模型入库不是存个文件而是沉淀一套模型资产很多人误以为“模型入库”就是把pickle文件塞进数据库BLOB字段。这当然算一种入库但只做到这一步还远远不够。真正的模型入库是让模型成为一条可查询、可审计、可回滚的“记录”同时包含四个部分模型文件本身、算法参数、评估指标、特征依赖清单。模型文件是预测逻辑的本体通常是序列化后的二进制或者ONNX格式算法参数记录的是这次训练用的超参数方便复现评估指标是AUC、准确率、召回率这些验证结果特征依赖清单是模型输入的全部特征列名和顺序。这四个部分缺一不可。没有超参数三个月后你想复现这个模型根本不知道当时怎么调的没有指标你没办法判断新模型是否真的比旧模型好没有特征清单线上服务加载模型后连输入格式都对不齐。入库之后还要给每个模型记录一个生命周期状态我一般用四个状态DEV开发中、VALID验证通过、PROD线上使用、ARCHIVED下线归档。新训练的模型默认是DEV在验证集上跑完指标、经过人工确认后才置为VALID只有VALID的模型才能被切到PROD一旦有更新的模型上线旧模型自动变成ARCHIVED但不会物理删除随时可以回滚。这样一个“模型注册表”就相当于模型的台账。线上服务要什么模型直接查这个表下载模型文件算法团队想对比版本也查这个表看指标和参数。我甚至会把“线上模型是哪个版本”做成一个监控指标避免测试环境模型和线上模型不一致。2.3 落地前先把这两张表设计好要支撑上面的流程数据库里至少要预埋两张表样本特征表和模型注册表。样本特征表建议直接物化因为每次从视图现算特征可能耗时太久物化之后还可以加索引、做分区训练查询会快很多。我的样本特征表结构大概是这样的CREATE TABLE feature_sample ( id BIGINT AUTO_INCREMENT PRIMARY KEY, sample_id VARCHAR(64) NOT NULL COMMENT 样本唯一标识比如客户ID, data_version VARCHAR(32) NOT NULL COMMENT 数据版本如20250601, feature_json JSON NOT NULL COMMENT 特征字段JSON存储, label TINYINT COMMENT 标签列训练目标, sample_type VARCHAR(16) NOT NULL COMMENT train/valid/test, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_sample_version (sample_id, data_version, sample_type) ) COMMENT 机器学习训练样本特征表;模型注册表结构我一般这样设计CREATE TABLE ml_model_registry ( model_id BIGINT AUTO_INCREMENT PRIMARY KEY, model_name VARCHAR(64) NOT NULL COMMENT 模型名称如customer_churn, model_version VARCHAR(32) NOT NULL COMMENT 版本号如v1.2.0, algorithm VARCHAR(64) NOT NULL COMMENT 算法如RandomForest, params_json TEXT COMMENT 超参数字典, metrics_json TEXT COMMENT 评估指标字典, feature_schema_json TEXT NOT NULL COMMENT 特征列顺序、类型清单, model_blob MEDIUMBLOB COMMENT 模型二进制文件, model_status VARCHAR(16) NOT NULL DEFAULT DEV COMMENT DEV/VALID/PROD/ARCHIVED, created_by VARCHAR(64) COMMENT 创建人, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_model_version (model_name, model_version) ) COMMENT 机器学习模型注册表;字段设计里有个容易被忽略的点feature_schema_json一定不能省。它存储的是模型输入特征的列名、类型、顺序线上推理时拿它来校验请求数据防止训练和线上特征不一致。这个字段在传统“导出CSV跑模型”的流程里根本不存在但恰恰是工程化之后最值钱的元数据。3. 实操全过程从一张订单表到线上可调用的模型下面我用一个具体例子串一遍完整流程。假设场景是电商订单库目标是用过去90天行为预测用户未来30天流失概率数据库以MySQL为例达梦、Oracle这些库改一下类型语法也能照搬。3.1 第1步建特征视图和样本抽取把数据按需放出库第一步不是写训练代码而是先在库里创建特征视图。我会把特征逻辑集中在视图层这样业务数据表结构调整时只需要改视图训练脚本不用动。视图样例如下CREATE OR REPLACE VIEW v_feature_order_customer AS SELECT u.customer_id AS sample_id, COUNT(DISTINCT o.order_id) AS order_cnt_90d, COALESCE(SUM(o.amount), 0) AS order_amount_90d, COALESCE(AVG(o.amount), 0) AS order_avg_amount_90d, COUNT(DISTINCT DATE(o.order_time)) AS active_days_90d, COUNT(DISTINCT CASE WHEN r.refund_id IS NOT NULL THEN o.order_id END) AS refund_cnt_90d, DATEDIFF(CURDATE(), MAX(o.order_time)) AS days_since_last_order FROM t_customer u LEFT JOIN t_order o ON u.customer_id o.customer_id AND o.order_time DATE_SUB(CURDATE(), INTERVAL 90 DAY) LEFT JOIN t_refund r ON o.order_id r.order_id GROUP BY u.customer_id;这个视图里只加工特征不加标签。标签是“未来30天是否流失”属于监督学习的目标它依赖未来数据所以不能和特征一起物化必须在训练脚本里单独生成。然后是抽取样本我一般有两种方式数据量小就直接SELECT * FROM v_feature_order_customer打进训练脚本数据量大就先物化一张feature_sample表再用DolphinScheduler定时调度。物化样本的典型做法是写一条INSERT语句并且给样本打上data_version标记比如“20250701”。这样每次训练用的数据版本都可追溯。建议训练样本做一次分层抽样比如按用户最近是否活跃做分层让正负样本比例更稳定避免类别不平衡影响训练效果。3.2 第2步写训练脚本读取特征并完成效果验证训练脚本我习惯放在独立的调度任务里用Python跑。连接数据库时建议使用专门为训练创建的只读账号并且设置连接超时和读取批次大小防止一次查询把数据库内存打爆。读取特征的代码大致是这样import pandas as pd from sqlalchemy import create_engine engine create_engine( mysqlpymysql://ml_read:xxx10.0.0.1:3306/analytics_db?charsetutf8mb4, pool_size2, connect_args{connect_timeout: 10}, ) sql SELECT sample_id, order_cnt_90d, order_amount_90d, order_avg_amount_90d, active_days_90d, refund_cnt_90d, days_since_last_order FROM feature_sample WHERE data_version 20250701 df pd.read_sql(sql, engine, chunksize100000) # 分块读取避免一次性载入过大导致内存溢出 train_data pd.concat(df, ignore_indexTrue)读进来之后做简单的缺失值填充和异常值裁剪然后生成标签列。标签的定义是“从特征截止日起未来30天内未产生有效订单的用户记为1否则为0”。训练时我用随机森林做基线再对比XGBoost按AUC和召回率评估。如果你做的是带物理约束的机器学习项目还可以在损失函数里额外加业务约束正则项这属于模型训练层面的优化和整套库内治理链路不冲突只是需要把自定义训练逻辑封装成标准函数。训练完成后不要急着入库先在验证集上把指标算清楚。我习惯把AUC、精确率、召回率、F1存成一个JSON字典连同超参数一起留作后续入库的元数据。这一步花的时间值得因为后面所有版本对比都要靠这些指标来做决策。3.3 第3步模型序列化写库顺便完成版本登记模型验证通过后就是最关键的“模型入库”。这里有两个动作要一起做一是把模型对象序列化成二进制写入model_blob字段二是把模型版本、算法、参数、指标、特征清单写入同一张注册表。为了让两步操作原子性我会把它们包在一个数据库事务里。import joblib from sqlalchemy import text # 模型序列化为二进制 model_bytes joblib.dumps(best_model, compress3) # 构造元数据 params_json json.dumps(best_model.get_params(), ensure_asciiFalse) metrics_json json.dumps({auc: 0.86, recall: 0.72}, ensure_asciiFalse) feature_schema json.dumps( [{name: col, type: str(train_data[col].dtype)} for col in feature_cols], ensure_asciiFalse, ) insert_sql text( INSERT INTO ml_model_registry (model_name, model_version, algorithm, params_json, metrics_json, feature_schema_json, model_blob, model_status, created_by) VALUES (:name, :version, :algo, :params, :metrics, :schema, :blob, VALID, alice) ) with engine.begin() as conn: conn.execute(insert_sql, { name: customer_churn, version: v1.2.0, algo: XGBClassifier, params: params_json, metrics: metrics_json, schema: feature_schema, blob: model_bytes, })特别注意版本号。我要求每次训练都必须生成一个新版本号不允许覆盖旧版本。“入库”是一次只增不改的操作旧版本永远保留在库里。这样一旦线上效果变差可以立刻回滚到前一个PROD版本。有些团队的模型表没有版本字段一个模型名只能存一条记录新模型直接覆盖旧模型这种做法风险极高等于把模型回滚能力丢掉了。入库之后还需要一个“置为生产”的动作。我会单独写一个更新事务先把当前PROD状态的模型置为ARCHIVED再把新模型的model_status改成PROD。这一步可以由调度任务自动执行也可以由人工在模型管理页面点击确认。稳妥起见我建议先做人工确认毕竟自动切换模型这种事一旦指标判断逻辑有误线上就是事故。3.4 第4步批量打分和实时预测让模型真正跑起来模型入库不是终点能被线上调用才是终点。我一般把推理分成两种批量打分和实时预测。批量打分适合离线场景比如每天凌晨对存量用户算流失概率。调度任务从v_feature_order_customer视图取当天特征加载注册表中model_statusPROD的模型做批量预测结果写回预测结果表。预测结果表一定要带batch_id字段每次任务生成一个批次号这样同一用户被不同批次覆盖时可以通过批次号去重和追踪避免重复回填。实时预测适合在线场景比如用户在App点击页面时实时判断是否触发召回策略。这里我的做法是业务服务启动时从ml_model_registry查询PROD模型把模型文件加载进内存模型版本更新时通过一个配置中心或消息通知触发服务重新加载。不需要每次请求都查数据库否则性能和稳定性都有隐患。如果你做的是向量召回这类场景可以把向量特征单独落到向量数据库里而模型本身的版本管理仍然走注册表这套逻辑。无论是批量还是实时加载模型后第一件事都是校验输入的feature_schema。用注册表里存的列顺序重排输入数据校验类型是否匹配。这一步是我踩过最多坑的地方后面专门展开讲。4. 踩过的坑常见问题与排查技巧实录流程跑顺之后真正考验人的其实是各种边角问题。我把这几年在“数据出库—模型入库”实践中遇到的高频问题整理成一份速查希望能帮你少走弯路。4.1 特征不一致训练时好好的一上线就崩这是最典型的问题。训练脚本里用pandas读到的列顺序是A, B, C线上服务接收到的请求顺序却是C, B, A或者训练时用日期字段做特征线上服务传进来的是时间戳字符串。模型本身不会报警但预测结果会非常离谱。解决办法就是把特征清单变成强制校验。在训练脚本里训练前先保存一份feature_schema入库时写入注册表推理服务加载模型后先用feature_schema重排输入数据再做类型转换。如果输入数据缺列或多列直接抛异常绝不带病预测。还有一个相关问题是时间穿越。我见过有人训练时把未来数据混进特征里比如用用户未来30天的订单数预测未来30天流失概率训练指标高得离谱一上线彻底崩盘。规避方法就是给特征表和标签表分别设置截止日期训练脚本里强制校验标签时间必须晚于特征时间否则报错。4.2 BLOB大字段入库慢、锁表、超时怎么处理模型文件一般几MB到几十MB直接写入BLOB字段在MySQL里并不算慢但如果是在业务高峰期执行或者表里已经有很多大字段记录就容易触发锁等待和超时。我的经验是模型入库任务务必放在低峰期或者单独建一张模型注册表和业务表物理隔离。模型序列化时记得开压缩。joblib.dumps(model, compress3)能把几十MB的文件压到十几MB写入速度提升明显。如果模型文件超过50MB我建议考虑把模型二进制放到对象存储里数据库只存路径和checksum但这需要额外的文件管理机制在项目初期老老实实存BLOB反而是最简单的方案。写入大字段时还有一个坑直接用ORM框架自动提交事务一直不释放导致其他训练任务拿不到锁。改成手写SQLengine.begin()写入完成后立刻提交问题就消失了。还有不要在同一个事务里做“查询旧模型写入新模型更新生产标记”三步操作虽然看着是一个业务动作但事务时间越长锁范围越大拆成两个短事务可靠性反而更高。4.3 权限、编码、调度重跑这些隐蔽坑权限方面我给训练脚本专门建了只读账号权限只开放到特征视图和样本表模型注册表的写入用另一个账号并且只授权INSERT和UPDATE不授权DELETE。这样做是为了防止训练脚本因为一个bug把整个模型表清空。模型表一旦被清空线上服务加载模型就会全面失败那是真正的生产事故。编码问题主要集中在中文元数据上。连接数据库时一定要显式指定charsetutf8mb4否则JSON里的中文变成乱码写入SQL时不要用字符串拼接建议全部用参数化绑定既解决编码问题也防注入。达梦、Oracle的BLOB处理和MySQL略有差异代码里做类型适配时要多看文档尤其是占位符风格不能通用。调度重跑是另一个暗坑。DolphinScheduler这类工具支持失败重跑但重跑时模型注册表里可能已经存在相同model_version的记录于是INSERT直接报唯一键冲突。我一般在INSERT语句里加上ON DUPLICATE KEY UPDATE逻辑或者重跑前先查出已存在的版本号自动生成v1.2.1这类递增版本避免调度重跑打乱版本序列。我自己在实际操作中最深的体会是库内机器学习这套流程真正难的不是算法也不是数据库而是把每条数据、每个模型的来龙去脉都管清楚。一开始设计model_registry表的时候多花半天时间后面能省下数不清的排查时间。曾经我从“模型文件在谁电脑上”的混乱状态走到“一条SQL就能查到线上模型版本和指标”的可控状态整个团队的状态完全不一样了。如果你正准备搭这套流程建议不要一上来就整那些重型的机器学习平台先把数据出库的视图建好把模型注册表设计好把入库和回滚的SQL写好这套轻量闭环跑通之后再决定要不要引入更复杂的调度和实验管理系统。
RELATED READING

延伸阅读

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