资讯详情 MySQL数据预处理实战:去重、缺失值填充与格式统一技巧
📅 2026/10/19 15:04:31
上周朋友接了一个数据清洗的活儿对方数据库里几十张表光是用户订单表就八百多万行重复记录、空手机号、格式乱七八糟的日期全混在一起导到分析工具里直接跑崩。他问我怎么办我说你先别急着上Python很多脏数据在MySQL里就能直接收拾干净。他半信半疑试了一个下午回来跟我说省了大半天的活儿。这就是MySQL数据预处理的价值。它不是什么高深莫测的算法而是把杂乱无章的原始数据变成能直接用于分析、报表、迁移、对接下游系统的“干净数据”。很多人一提到数据清洗就想到Python、想到ETL工具但对于数据量在千万级以内、逻辑又不算特别复杂的场景直接在MySQL里用SQL来处理速度快、不用搬数据、改起来也方便是性价比最高的方案。这篇文章我从实际处理过的场景出发把MySQL里常用的预处理操作拆开揉碎讲清楚包括去重、缺失值补齐、格式统一、类型转换、字段拆分合并再带一个完整的实操案例最后是踩过坑之后总结出来的排查思路和优化经验。适合正在做数据分析、报表开发、系统迁移的朋友参考新手也能按步骤直接上手。1. 为什么选择在MySQL层面完成数据预处理1.1 预处理前移能解决什么实际问题数据预处理放在数据库层面做最大的好处是“在数据还没出门之前就把问题解决掉”。我之前参与过一个跨系统数据对接项目上游业务库每天产生的订单数据质量很差下游的数据分析团队拿到数据之后至少花半天时间做清洗去重、补字段、修格式。后来我们改了思路在MySQL里写了一套定时执行的预处理存储过程上游数据进来之后先过一遍预处理逻辑下游拿到的直接就是干净数据。这样一来从“事后补救”变成了“事前拦截”整个链路的稳定性提升了一大截。另外直接在MySQL里做还有一个非常实际的好处不用把大量原始数据导出到本地或者中间服务器。几百万行数据用Python处理先要导出成CSV再导入Pandas内存稍微小一点就卡死。而在MySQL里一条UPDATE语句或者一个INSERT INTO SELECT几百万行的处理也就是十几秒到几分钟的事处理完直接原地更新数据的血缘关系也清晰。1.2 MySQL预处理与外部工具的边界在哪里我个人的判断标准是凡是能用一个SQL语句解决的清洗逻辑绝不搬到外部工具里做。比如去重、补默认值、格式化日期、拆分字段这些都是SQL的强项。那什么时候该用外部工具呢三种情况需要复杂的机器学习模型来判断异常值或者做缺失值填充比如用回归预测来填充这种逻辑SQL写起来非常痛苦数据量达到亿级以上MySQL单表已经扛不住频繁的更新操作需要跨多个异构数据源做复杂的关联清洗比如MySQL的数据要和HBase、文件型数据源做融合这时候用专业的ETL工具更合适。注意MySQL虽然性能不错但它不是分布式系统单表几千万行的全表UPDATE是可以执行的但要做分批提交否则会锁表很久影响线上业务。1.3 预处理之前必须想清楚的三件事在动手写SQL之前我会先问自己三个问题想清楚了再动手往往能少走很多弯路。第一目标数据的样子是什么也就是清洗之后每一列应该是什么格式、什么取值范围、哪些字段不能为空。这个必须提前定义清楚否则清洗到一半发现规则变了返工成本很高。第二脏数据都有哪几类我会先对目标表做一轮探查性查询看看重复数据大概占多少比例、空值集中在哪几列、日期格式有几种写法、有没有明显越界的数值。摸清底细之后再针对性地写清洗逻辑而不是一上来就套一个万能模板。第三预处理是只跑一次还是要周期性地跑如果是周期性任务就要考虑操作是否幂等——也就是同一批数据跑两遍和跑一遍的结果是一样的。这个非常关键后面我会专门讲。2. 五大高频预处理场景与SQL实现细节2.1 去重从DISTINCT到窗口函数的进阶之路去重是我在数据预处理里遇到最多的需求但很多人只会用DISTINCT遇到“保留最新一条”“保留最早一条”这类需求就不知道怎么处理了。先看最简单的场景完全重复的行也就是每一列都一样。这种情况直接用DISTINCTSELECT DISTINCT user_id, order_no, amount, create_time FROM raw_orders;但实际业务里更常见的是部分关键字段重复比如同一个订单号出现了多次但其他字段有细微差异。这时候DISTINCT就无能为力了因为整行不完全相同。正确的做法是用GROUP BY配合聚合函数来确定保留哪条SELECT order_no, MAX(user_id) AS user_id, MAX(amount) AS amount, MAX(create_time) AS create_time FROM raw_orders GROUP BY order_no;如果需求是“同一个用户保留最新一条订单”那就要用到窗口函数MySQL 8.0及以上版本支持WITH ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time DESC) AS rn FROM raw_orders ) SELECT * FROM ranked WHERE rn 1;这里的逻辑是用PARTITION BY按用户分组然后组内按创建时间倒序编号编号为1的就是这个用户最新的一条记录。如果MySQL版本还没到8.0只能用变量模拟我之后在实操案例里演示。注意窗口函数方式写起来干净但执行效率不一定比GROUP BY高。如果表非常大优先考虑先建索引组合索引user_id, create_time再看执行计划决定用哪种写法。2.2 缺失值处理填充、标记与剔除的组合策略缺失值处理不是一个“一刀切”的操作我通常会把缺失值分成三类来对待。第一类是业务上允许为空、分析时有特殊含义的字段比如“备注”字段。这类字段不需要处理查询时用IFNULL或者COALESCE设置展示值就行SELECT order_no, COALESCE(remark, 无备注) AS remark_display FROM orders;第二类是字段缺失后会影响后续计算的字段比如金额、数量。这类字段我更倾向于先看缺失比例缺失比例在5%以内直接填充业务默认值比如0或者1缺失比例超过20%填充默认值反而会引入偏差这时候我会标记出来让下游决定是剔除还是单独分析-- 填充默认值 UPDATE orders SET quantity 1 WHERE quantity IS NULL; -- 或者打上标记 UPDATE orders SET quantity_flag missing WHERE quantity IS NULL;第三类是主键性质或者强业务约束的字段比如订单号、用户ID。这类字段一旦缺失整条记录就没有分析价值直接删除或者移到异常表。-- 将异常数据移入备份表 INSERT INTO orders_invalid SELECT * FROM orders WHERE order_no IS NULL; -- 再从原表删除 DELETE FROM orders WHERE order_no IS NULL;我个人的原则是能填就不删能标记就不改值。因为一旦把缺失值填充成业务默认值数据原本的信息就丢了再想追溯原始情况就麻烦很多。所以填充之前先备份原始数据或者至少保留一列原始值这个习惯能帮你避免很多后续的坑。2.3 格式统一日期、字符串与数值的标准化处理格式不统一是最让人头疼的脏数据类型。我见过一个表里的日期字段同一个字段里既有“2024-01-15”这种标准格式又有“2024/1/15”这种带斜杠的还有“20240115”这种纯数字的。这种情况在MySQL里处理起来不像想象中那么难关键是要先摸清楚有哪些格式。先查一下有哪些格式SELECT create_time, COUNT(*) FROM orders GROUP BY create_time;确认格式种类之后用CASE WHEN配合STR_TO_DATE和DATE_FORMAT统一转换UPDATE orders SET create_time CASE WHEN create_time LIKE %/% THEN DATE_FORMAT(STR_TO_DATE(create_time, %Y/%m/%d), %Y-%m-%d) WHEN create_time REGEXP ^[0-9]{8}$ THEN STR_TO_DATE(create_time, %Y%m%d) ELSE create_time END;字符串格式的问题主要集中在两处不可见字符和大小写不一致。不可见字符一般藏在字符串开头或结尾肉眼看不出来但用LENGTH和CHAR_LENGTH一对比就能发现端倪。处理用TRIM函数-- 去除字段两端的空格、制表符、换行符 UPDATE users SET user_name TRIM(user_name); -- 去除中间的多余空格可以用正则也可以用REPLACE叠加 UPDATE users SET user_name REPLACE(REPLACE(REPLACE(user_name, , ), CHAR(9), ), CHAR(10), );数值类型的格式问题最常见的是“数值被存成了带千分位、货币符号的字符串”比如“¥1,299.00”。这种字符串不能直接参与SUM计算必须把符号去掉再转成数值UPDATE products SET price CAST(REPLACE(REPLACE(REPLACE(price, ¥, ), ,, ), , ) AS DECIMAL(10,2));2.4 类型转换CAST与CONVERT的正确打开方式MySQL中的类型转换有两个常用函数CAST和CONVERT两者功能大部分重叠区别在于CONVERT还支持字符集转换。我在日常开发里用CAST更多语法也更标准。-- 字符串转整数 SELECT CAST(123abc AS SIGNED); -- 结果为123遇到非数字自动截断 -- 字符串转小数 SELECT CAST(12.34 AS DECIMAL(10,2)); -- 结果为12.34 -- 字符串转日期 SELECT CAST(2024-01-15 AS DATE); -- 结果为2024-01-15这里有一个非常容易踩的坑MySQL的隐式类型转换规则。当你把一个字符串字段与数值比较时MySQL会尝试把字符串转成数值但如果字符串不是合法的数值格式转换结果是0然后拿0去和字段比较查出来的数据就全是错的。我实际遇到过的一个案例-- 假设id_card_no是字符串存储身份证号 SELECT * FROM users WHERE id_card_no 123456789012345678;这条SQL看起来没问题但实际MySQL会先把id_card_no转换成数值再比较结果是所有身份证号都变成了一个浮点数匹配出来一堆错误数据。正确的写法是SELECT * FROM users WHERE id_card_no 123456789012345678;如果你在开发中经常需要做同类转换我建议建立一个简单的检查习惯凡是字段和常量比较先把常量的类型写对凡是表达式里有两个不同类型的字段先手动CAST成同一类型再去比较避免数据库自作主张。2.5 字段拆分与合并的处理技巧字段拆分的经典场景是把“省市区”存成一个字段的地址拆成三列。MySQL里没有类似Python的split函数但可以用SUBSTRING_INDEX按分隔符取子串SELECT address, SUBSTRING_INDEX(address, |, 1) AS province, SUBSTRING_INDEX(SUBSTRING_INDEX(address, |, 2), |, -1) AS city, SUBSTRING_INDEX(address, |, -1) AS district FROM users;这里的分隔符是“|”SUBSTRING_INDEX的三参分别是字符串、分隔符、取第几个。取中间部分时嵌套一层先取前两个再从右边取第一个有点绕但用顺手了就很快。字段合并相对简单用CONCAT或者CONCAT_WS-- 普通拼接遇到NULL整体返回NULL SELECT CONCAT(last_name, first_name) AS full_name FROM users; -- 带分隔符拼接自动忽略NULL值 SELECT CONCAT_WS(-, province, city, district) AS full_address FROM users;如果一张表的字段数量太多而且很多字段是稀疏的也就是大部分行只有少数几个字段有值可以考虑用JSON合并。MySQL 5.7以上支持JSON_OBJECT和JSON_ARRAYSELECT user_id, JSON_OBJECT(name, user_name, phone, phone, address, address) AS user_info FROM users;这样就把多个字段压缩成了一个JSON字段下游做解析时也方便。不过要谨慎使用JSON字段在MySQL里无法走普通索引如果后续需要根据JSON里的某个属性做过滤查询性能会受影响。3. 实操从一份脏乱订单表到可直接分析的数据集3.1 构造一份包含典型脏数据的订单表下面我用一个模拟场景来演示完整的预处理流程。场景是某零售业务的订单明细数据里包含了三种典型问题重复记录、空手机号、格式不统一的日期和金额。-- 创建订单原始表 CREATE TABLE raw_orders ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(50), user_id INT, product_name VARCHAR(100), amount VARCHAR(20), order_date VARCHAR(20), status VARCHAR(20) ); -- 插入脏数据 INSERT INTO raw_orders (order_no, user_id, product_name, amount, order_date, status) VALUES (A1001, 101, 手机, ¥5,299.00, 2024/01/15, 已完成), (A1001, 101, 手机, ¥5,299.00, 2024-01-15, 已完成), -- 重复记录 (A1002, 102, 电脑, ¥7,999.00 , 20240116, 已发货), (A1003, NULL, 耳机, 299.00, 2024-01-17, 已取消), -- user_id为空 (A1004, 104, 键盘, NULL, 2024-01-18, 已完成), -- amount为空 (A1005, 105, 鼠标, 198.50, 2024/1/19, 待付款), (A1006, 106, 显示器, 1,299.00, 2024-01-20, 已完成);这张表数据量不大但基本上把常见的脏数据类型都覆盖了order_no重复、amount格式混乱带货币符号、带千分位、前后空格、order_date格式三种并存、user_id和amount有空值。3.2 第一步清洗前的探查性分析拿到表我不会急着写UPDATE而是先跑一组探查SQL搞清楚每一类问题的影响范围。-- 查看总记录数 SELECT COUNT(*) AS total_cnt FROM raw_orders; -- 查看关键字段的空值情况 SELECT COUNT(*) AS total_cnt, SUM(CASE WHEN order_no IS NULL OR order_no THEN 1 ELSE 0 END) AS order_no_null_cnt, SUM(CASE WHEN user_id IS NULL THEN 1 ELSE 0 END) AS user_id_null_cnt, SUM(CASE WHEN amount IS NULL THEN 1 ELSE 0 END) AS amount_null_cnt FROM raw_orders; -- 查看重复订单号 SELECT order_no, COUNT(*) AS dup_cnt FROM raw_orders GROUP BY order_no HAVING COUNT(*) 1;这三条SQL跑完之后我基本能做到心里有数哪些字段需要重点清、哪些字段可以放过、重复的比例占多少。探查这一步花不了几分钟但能避免后面处理到一半发现新问题。3.3 第二步按预处理规则逐步清理整个预处理过程我分成五步每一步之间是顺序依赖的不能跳着来。第一步去重。这个案例里的重复是同一订单号的完全重复记录保留最小ID那条即可。用MySQL 8.0的窗口函数一步搞定DELETE FROM raw_orders WHERE id NOT IN ( SELECT MIN(id) FROM raw_orders GROUP BY order_no );这里子查询里用了MIN(id)含义是每个订单号保留最早插入的那条其余的删除。执行完之后再查一次重复情况确认SELECT order_no, COUNT(*) AS dup_cnt FROM raw_orders GROUP BY order_no HAVING COUNT(*) 1;结果应该是空集说明去重完成。第二步清理amount字段。当前的情况是有些带¥符号、有些带逗号、有些前后有空格。处理逻辑是先去空格再去货币符号再去千分位逗号最后转成DECIMAL类型UPDATE raw_orders SET amount REPLACE(REPLACE(REPLACE(TRIM(amount), ¥, ), ,, ), , ) WHERE amount IS NOT NULL; -- 转成数值型并新增一个数值列 ALTER TABLE raw_orders ADD COLUMN amount_value DECIMAL(10,2); UPDATE raw_orders SET amount_value CAST(amount AS DECIMAL(10,2)) WHERE amount IS NOT NULL AND amount ;第三步统一order_date格式。当前有三种格式斜杠日期、纯数字日期、横杠日期。统一成标准“YYYY-MM-DD”UPDATE raw_orders SET order_date CASE WHEN order_date LIKE %/% THEN DATE_FORMAT(STR_TO_DATE(order_date, %Y/%m/%d), %Y-%m-%d) WHEN order_date REGEXP ^[0-9]{8}$ THEN STR_TO_DATE(order_date, %Y%m%d) ELSE order_date END;第四步处理缺失值。user_id为空的情况由于user_id是订单归属的关键标识缺失的话无法判断订单属于谁这行数据只能标记异常或者剔除。我这里选择把user_id为空的记录移到异常表避免影响后续统计CREATE TABLE raw_orders_invalid AS SELECT * FROM raw_orders WHERE user_id IS NULL; DELETE FROM raw_orders WHERE user_id IS NULL;amount为空的情况根据业务规则无法确定订单金额的按0处理会影响均价所以在amount_value列中标记为NULL值分析时用COALESCE兜底ALTER TABLE raw_orders ADD COLUMN amount_missing TINYINT DEFAULT 0; UPDATE raw_orders SET amount_missing 1 WHERE amount_value IS NULL;第五步整体验证。清洗完成后跑一遍总体验证SQL确认各项指标正常SELECT COUNT(*) AS total_cnt, COUNT(DISTINCT order_no) AS distinct_order_cnt, COUNT(*) - COUNT(DISTINCT order_no) AS dup_remaining, SUM(amount_missing) AS amount_missing_cnt, MIN(order_date) AS min_date, MAX(order_date) AS max_date, SUM(amount_value) AS total_amount FROM raw_orders;如果dup_remaining是0amount_missing数量在预期范围日期没有明显异常这份数据就可以放心交给下游使用了。3.4 MySQL 5.7环境下的去重替代方案上面去重用到了窗口函数如果是MySQL 5.7及之前的老版本窗口函数不可用需要用关联子查询或者变量来实现。用关联子查询DELETE FROM raw_orders WHERE id NOT IN ( SELECT MIN_id FROM ( SELECT MIN(id) AS MIN_id FROM raw_orders GROUP BY order_no ) AS tmp );这里多嵌套了一层子查询原因是MySQL不允许在DELETE语句的WHERE子句中直接引用自己这张表的聚合子查询需要包一层派生表否则会报“You cant specify target table for update in FROM clause”的错误。用变量方式也能实现但在DELETE场景下书写比较复杂我更推荐关联子查询或者先查出来要保留的ID再删除其他-- 先建一张临时表存放要保留的ID CREATE TEMPORARY TABLE tmp_keep_id AS SELECT MIN(id) AS id FROM raw_orders GROUP BY order_no; -- 再删除不在临时表里的记录 DELETE FROM raw_orders WHERE id NOT IN (SELECT id FROM tmp_keep_id);临时表的方式在数据量大的场景下比关联子查询更可控也更好排查问题。4. 预处理过程中的常见问题与排查技巧4.1 字符串编码导致的乱码问题MySQL的数据预处理过程中最隐蔽的问题就是字符集。我之前遇到过一张旧表字段注释是中文但实际存的是latin1编码用客户端工具查出来全是问号。处理这种问题分两步先确认字段真实的字节类型再转换字符集。-- 查看字段的字符集信息 SHOW FULL COLUMNS FROM users; -- 转换字段字符集比如从latin1转成utf8mb4 ALTER TABLE users MODIFY user_name VARCHAR(100) CHARACTER SET utf8mb4;要注意的是直接ALTER MODIFY不一定能解决数据本身损坏的问题因为数据在写入时已经按错误的编码存了转换之后可能是乱码套乱码。更可靠的做法是先用HEX函数导出十六进制数据确认原始字节结构后再决定怎么转换。遇到这种情况时我会建议先保留原表在新表上做转换测试验证结果无误后再切换。如果是字段本身是utf8但导入的源文件是GBK编码导入时乱码解决方案是在LOAD DATA语句里指定字符集LOAD DATA INFILE /tmp/orders.csv INTO TABLE raw_orders CHARACTER SET gbk FIELDS TERMINATED BY , LINES TERMINATED BY \n;4.2 时区与日期时间的边界场景日期时间字段的清洗最容易出现边界问题。比如有一列是“2024-02-30”这在业务上根本不存在但字符串格式的DATE_FORMAT不会报错只有转换成DATE类型时才会报错。为了避免单个坏值导致整个UPDATE失败可以先查出来SELECT order_date FROM raw_orders WHERE STR_TO_DATE(order_date, %Y-%m-%d) IS NULL AND order_date IS NOT NULL;如果查询结果有值就说明存在非法日期需要先修正这些值再执行批量转换。另外时区问题也常被忽略如果MySQL服务器的时区设置与业务时区不一致用NOW()生成的时间戳就会偏差好几个小时。处理方式是-- 查看当前时区 SELECT global.time_zone, session.time_zone; -- 设置会话时区 SET time_zone 08:00;尤其在做数据迁移、数据对比的时候两边系统的时区不一致比对结果永远对不上排查半天最后发现只是差了几个小时非常耽误时间。4.3 NULL值带来的隐性BugNULL值在SQL中是个特殊的存在很多预处理操作因为它而出现不符合直觉的结果。最常见的一个坑是在拼接字符串时只要其中一个值为NULL整个拼接结果就是NULL。比如SELECT CONCAT(user_name, -, phone) AS user_contact FROM users;如果phone是NULL这行结果的user_contact就是NULL而不是“张三-”。解决办法是用IFNULL或COALESCE提前处理SELECT CONCAT(user_name, -, IFNULL(phone, 无)) AS user_contact FROM users;另一个坑是在WHERE条件里用“ NULL”去过滤数据结果永远查不到数据。正确写法是IS NULL或者IS NOT NULL-- 错误写法 SELECT * FROM users WHERE phone NULL; -- 正确写法 SELECT * FROM users WHERE phone IS NULL;还有人会在CASE WHEN里漏掉NULL分支导致预期的“其他”没兜住。比如SELECT CASE WHEN status 1 THEN 已完成 WHEN status 2 THEN 进行中 ELSE 其他 END AS status_text FROM orders;如果status本身是NULL前面的等值判断都会返回NULL而不是FALSE最终ELSE也不会命中结果就是NULL。这里需要显式加上WHEN status IS NULL THEN 未知。4.4 大数据量UPDATE的性能优化当你需要对一张几百万行的表做批量UPDATE时如果不做任何优化很可能会一直锁表不仅慢还会影响线上业务。我常用的策略有四个。第一个策略是分批处理。不要一次更新全表而是按主键范围分成多个批次每批5000到10000行一批跑完再跑下一批。例子UPDATE raw_orders SET status 已完成 WHERE id BETWEEN 1 AND 10000 AND status 已完成;第二个策略是避免更新二级索引列。如果你的WHERE条件可以用到索引但SET部分更新的是索引列每次更新都会触发索引重建性能会明显下降。所以设计表结构时频繁更新的列尽量放普通索引列之外的字段。第三个策略是先查后改。在大表上做UPDATE之前先用SELECT定位出需要更新的ID集合对ID集合做更新而不是用复杂的WHERE条件直接更新大表。比如-- 先找出要更新的ID CREATE TEMPORARY TABLE tmp_ids AS SELECT id FROM raw_orders WHERE order_date 2023-01-01; -- 再根据ID集合更新 UPDATE raw_orders SET is_old 1 WHERE id IN (SELECT id FROM tmp_ids);第四个策略是优化redo log和索引。大批量更新前可以临时关闭唯一索引检查仅在确认数据质量的前提下或者调整innodb_buffer_pool_size让更多数据驻留在内存中。对于临时性的批处理任务做完之后再恢复设置。5. 个人经验总结构建一套可持续复用的预处理模板预处理这件事做得多了之后会发现很多操作的套路是固定的。我后来给自己建了一个“预处理工具箱”把高频用到的SQL片段整理成模板每次新接一个数据任务时先复制模板再根据具体场景改表名和字段名效率提升非常明显。我的工具箱里有这么几类模板探查模板统计表总行数、空值数量、重复比例、日期异常数量、字段分布情况去重模板窗口函数版、关联子查询版、临时表版适配不同版本和数据量缺失值处理模板填充默认值、标记缺失、移入异常表三种策略格式化模板日期格式统一、金额符号清理、前后空格清理、大小写统一验证模板处理前后对比的行数、关键字段的汇总值、离散值的枚举情况。这些模板不是一次性写完就完事每次遇到新的脏数据形态我都会在旁边追加备注。比如“金额字段里出现过全角逗号”“日期字段里混入过中文年月日”下次再遇到类似情况时直接能翻出当时的处理SQL省了重新踩坑的时间。一个值得分享的小技巧是在做任何预处理之前先给自己的操作留好“后悔药”。具体做法是把原始表完整复制一份或者至少把即将改动的那几列复制出来CREATE TABLE raw_orders_backup AS SELECT * FROM raw_orders;处理小表的时候这条SQL没什么成本但是处理大表、多表、跨天任务的时候就非常重要了。因为很多脏数据的特征是“遇到才知道”你第一轮清洗之后发现新的问题想回到原始状态重来一遍没有备份就只能手动反向修数据极度痛苦。还有一个经验是关于验证的。清洗完不是直接交付就算完事要有一个自检的过程。我自己习惯的做法是写一个验证SQL集合把“数据总量变化”“重复数归零”“空值比例”“金额总和变化”“日期范围”这些关键指标在处理前和处理后各跑一遍对比差异在预期范围内才敢交付。给下游的数据宁可多花十分钟验证也不要让对方跑数到一半发现异常再回过头来找你。MySQL的数据预处理不是什么高深的技术它更像一门需要大量实操打磨的手艺。你处理得越多见过的脏数据形态越丰富写起SQL来就越有底气。希望这篇文章里的方法能帮你在下次面对一堆“烂数据”的时候少一些手忙脚乱多一些从容。