简介:面向需要进行基站掉话率分析的数据处理技术人员,这份压缩包提供从原始通话话单中统计掉线率最高前10基站的完整数据与SQL方案,适用于Hive/MySQL环境下的话务数据清洗、统计与网络质量评估。资源共2个文件,包括一份CSV原始数据文件和对应的MySQL版SQL脚本,整体压缩后大小为13.03MB。CSV中逐条记录了record_time通话时间、imei基站编号、cell手机编号、drop_num掉话秒数、duration通话持续总秒数等字段,利用duration与drop_num可计算各基站掉话率;SQL脚本则将表结构和导入查询逻辑一并封装,方便直接导入MySQL数据库进行排序统计,省去自行建表解析的麻烦。已有242人学习下载,适合作为Hive/MySQL数据处理练习、基站网络质量分析或相关课程设计的参考素材,也可以在此基础上扩展掉话率计算、TOP基站筛选等后续分析。
1. 解开 cdr_summ_imei_cell_info:7z 包里那份 csv 话单聚合数据能做什么
这个文件名拆开就是三件事:cdr_summ是 Call Detail Record 的汇总(话单汇总),imei是终端设备编号,cell_info是基站小区基础信息,最后的(csv-mysql)表示这份数据以 csv 文件交付、面向 MySQL 导入,整包用 .7z 压缩。在手机信令和人口流动分析这类项目里,这种中间交付物很常见:上游把每天几亿条原始话单按设备、小区、时段聚合成 csv,下游再灌进自己的库。无论你拿到的 csv 是 2023 年全国区县级粒度还是单城市全网粒度,只要字段带cdr_summ、imei、cell_info,处理套路就是一套——确认粒度、建表、导入、校验、关联位置信息。这篇笔记直接把这条链路讲透,边讲边给可抄的语句和参数。
2. 读懂 csv 的数据粒度与字段构成:imei×cell 的聚合表决定你能回答什么问题
2.1 从原始 CDR 到 cdr_summ:中间发生了什么
原始话单一条记录长什么样,决定了这份汇总 csv 是怎么来的。运营商侧每通电话、每次上网会生成一条 CDR,包含主被叫号码、IMEI、IMSI、LAC(位置区编码)、Cell ID(小区编号)、开始时间、结束时间、上下行流量等十几二十个字段。一天下来,一张表少说几亿行,直接丢给下游团队,别说 MySQL,任何数据库都不愿意接。
所以上游通常会做一个聚合动作:把原始 CDR 按“设备 + 小区 + 时间片”分组,统计出呼叫次数、通话时长、流量字节数,甚至基于信令驻留推导出停留秒数。这一步做完,几亿条原始记录塌缩成几百万行甚至几十万行的汇总表,这才是cdr_summ的由来。
对应的聚合逻辑大致是这样的 SQL:
-- 上游常见的聚合逻辑:把原始 CDR 按设备、小区、小时做 GROUP BY SELECT imei, lac, cell_id, DATE_FORMAT(stat_time, '%Y-%m-%d') AS stat_date, -- 按天分片 HOUR(stat_time) AS stat_hour, -- 按小时分片 COUNT(*) AS call_cnt, -- 呼叫次数 SUM(TIMESTAMPDIFF(SECOND, start_time, end_time)) AS duration, -- 累计通话秒数 SUM(data_flow) AS data_flow -- 累计流量字节 FROM raw_cdr GROUP BY imei, lac, cell_id, DATE_FORMAT(stat_time, '%Y-%m-%d'), HOUR(stat_time);注意这个 GROUP BY 的粒度:imei + lac + cell_id + 日期 + 小时。粒度直接决定下游能回答什么问题——按小区聚合,你能算一台设备在某时段出现在哪个基站下、停留了多久;如果只按 imei 聚合丢掉了小区,就只剩“这台设备今天打了几个电话”,空间维度全丢,整个 csv 的价值就去掉一大半。
这里还需要想清楚一个关键选型:为什么按 imei 而不按手机号(MSISDN)聚合。IMEI 是设备标识,手机号是可以换卡的,一个人换了 SIM 卡之后 MSISDN 变了,但设备没变。在做职住分析、人口流动这类场景时,追踪“这台设备去过哪”比“这个号码打过几个电话”更有物理意义。当然 imei 也有它的脏点:部分话单里 IMEI 缺失,或者有人用改机软件伪造,导致同一台设备出现多个 imei。这类数据落到 csv 里之后,导入阶段没法修,只能在分析阶段靠时长阈值、轨迹合理性去过滤。
2.2 典型的字段模板与列类型选择
拿到cdr_summ_imei_cell_info.csv之后,第一件事是打开表头,不是急着建库。常见交付字段大致长这样:
| 字段名 | 类型建议 | 含义 | 是否可空 |
|---|---|---|---|
| imei | VARCHAR(20) | 终端设备标识 | 否 |
| lac | INT | 位置区编码 | 否 |
| cell_id | INT | 小区编号 | 否 |
| cgi | VARCHAR(16) | lac + cell_id 拼接的小区全球识别码 | 否 |
| stat_date | DATE | 聚合日期 | 否 |
| stat_hour | TINYINT | 聚合小时(0-23) | 否 |
| call_cnt | INT | 呼叫次数 | 否 |
| duration | INT | 累计通话秒数 | 否 |
| data_flow | BIGINT | 累计流量字节 | 是 |
| stay_sec | INT | 信令推导驻留秒数 | 是 |
类型选择上有几个容易翻车的点。lac和cell_id这类编号,在 csv 里看起来是数字,但本质上是一个标识符,不是用来做算术的,用 INT 保存是合理的,省空间、查询快。但要注意很多 csv 里 cell_id 带前导零,比如00123,Excel 打开会丢掉零,所以 csv 阶段不能用 Excel 直接编辑。cgi字段如果源文件直接给了,就按 VARCHAR 原样存;如果源文件只有 lac 和 cell_id 两列,就自己拼接,注意两端都要补零成等宽,否则同一小区的 cgi 会出现两种写法。
duration和stay_sec用 INT 够不够,是个值得较真的问题。一天累计通话秒数单设备最多也就 86400 秒,但这是单设备单小区单小时的值,累计量级不大;不过如果是按周、按月汇总的 csv,duration可能到百万级,还在 INT 范围内。真正要小心的是data_flow,流量字节数轻松上亿,必须用 BIGINT,否则导入后数据溢出直接报错或变成负数。
时间字段是 csv 里最容易出幺蛾子的地方。有的 csv 给的是2023-01-01 08:00:00这种完整时间戳,有的直接拆成stat_date和stat_hour两列。如果是完整时间戳,导入时要在SET子句里拆开;如果文件里已经拆好了,反而省事。需要警惕的是stat_hour=24这种脏值,部分上游系统会把凌晨 0 点写成 24,导入后TINYINT存得下,但HOUR()函数和排序都会出问题,需要提前挡掉。
2.3 csv 文件为什么是瓶颈:pandas 读取 vs MySQL 导入
csv 文件到 MySQL 有两条路:用 pandas 读进来再逐条 INSERT,或者用 MySQL 自带的LOAD DATA INFILE文本协议直灌。很多新手习惯用 pandas,因为代码写起来顺手。但一个几 GB 的 csv,pandasread_csv会把整个文件读进内存,加上 DataFrame 的副本开销,机器内存直接见底。
# pandas 方式——适合小文件,大文件别硬来 import pandas as pd # usecols 只取需要的列,dtype 全指定为 str,避免 pandas 自作主张推断类型 df = pd.read_csv( "cdr_summ_imei_cell_info.csv", dtype={ "imei": "string", "lac": "string", # 这里不转 int,防止前导零丢失 "cell_id": "string", "stat_date": "string", }, usecols=["imei", "lac", "cell_id", "stat_date", "stat_hour", "call_cnt", "duration", "data_flow", "stay_sec"], ) # 走 MySQL 连接池批量写入(比逐条 execute 快,但远不如 LOAD DATA) # 这里只是备用方案,文件超过 500MB 时直接放弃 pandas这段代码的关键是dtype全部指定为string:lac和cell_id一旦让 pandas 自动推断成 int64,前导零就没了,后面关联cell_info表时会大量失配。usecols是另一个必须养成的习惯,csv 交付文件经常附带一堆用不上的辅助列,只读需要的列能省三分之一内存。
但说实话,文件超过 500MB 之后,pandas 路线的性价比就很低了。我在生产环境里见过同事用 pandas 导一个 3GB 的 csv,跑了四十分钟没结束,最后 OOM 被系统 kill。换LOAD DATA LOCAL INFILE之后,同样的文件基本是分钟级到秒级的差距——因为 MySQL 的 LOAD DATA 走的是文本解析协议,不经过客户端逐行组装 INSERT 语句,少了网络往返和 SQL 解析开销。
所以我的习惯是:小文件(100MB 以内)随手用 pandas 处理加清洗没问题;大文件直接走LOAD DATA,清洗逻辑放到 SQL 的SET子句里做。后面第三章给的完整链路就是以LOAD DATA为主线的方案。
3. 把 csv 灌进 mysql:7z 解压、建表 DDL 与 LOAD DATA 调优
3.1 解压 .7z 与 csv 预检:先看编码、行数和表头
第一步是解压。Linux 环境默认不带 7z 命令,需要先装 p7zip 系列工具:
# Debian/Ubuntu 系 apt-get install -y p7zip-full # CentOS/RHEL 系 yum install -y p7zip # 解压,注意文件名里有括号,用单引号包住防止 shell 转义 7z x 'cdr_summ_imei_cell_info(csv-mysql).7z'解压这个动作本身有讲究。文件名里的括号在 bash 里会被解释成子 shell 语法,不转义或不用引号会直接报bash: syntax error near unexpected token。加上单引号是最省心的做法。还有一种情况是拿到手的是.7z.001、.7z.002这种分卷包,7z x命令会自动识别分卷,不用手动合并,但前提是所有分卷都在同一个目录里。
解压完成后,别急着建表,三条命令先探底:
# 查看文件体积和行数(行数要减掉表头那一行) ls -lh *.csv wc -l *.csv # 查看文件编码,常见输出 US-ASCII / UTF-8 / ISO-8859 或 GBK 系 file -bi *.csv # 预览前 5 行,确认分隔符、表头、时间格式 head -5 *.csvfile -bi这条很多人不看,但它能省掉后面一大截乱码排查。如果输出是text/plain; charset=iso-8859-1,说明文件大概率是 GBK/GB18030 编码,导入时必须显式声明字符集;如果输出charset=utf-8,直接导即可。head -5则要看三件事:分隔符是不是纯逗号、有没有ENCLOSED BY '"'的必要、时间列到底是2023-01-01还是2023/01/01,这决定后面STR_TO_DATE的格式串。
3.2 建表 DDL:把聚合键、时间、计数值一次定对
预检完就可以建表了。下面的 DDL 是按imei × 日期 × 小时 × cgi四个键做唯一约束的典型设计,也是我处理这类 csv 的默认模板:
-- 话单按 imei × 小区 × 小时汇总表 CREATE TABLE IF NOT EXISTS cdr_summ_imei_cell ( imei VARCHAR(20) NOT NULL COMMENT '终端设备标识', lac INT NOT NULL COMMENT '位置区编码', cell_id INT NOT NULL COMMENT '小区编号', cgi VARCHAR(16) NOT NULL COMMENT 'lac+cell_id 拼接的小区识别码', stat_date DATE NOT NULL COMMENT '聚合日期', stat_hour TINYINT NOT NULL COMMENT '聚合小时(0-23)', call_cnt INT NOT NULL DEFAULT 0 COMMENT '呼叫次数', duration INT NOT NULL DEFAULT 0 COMMENT '通话时长(秒)', data_flow BIGINT NOT NULL DEFAULT 0 COMMENT '流量字节数', stay_sec INT NOT NULL DEFAULT 0 COMMENT '信令驻留秒数', PRIMARY KEY (imei, stat_date, stat_hour, cgi), KEY idx_date_cgi (stat_date, cgi), KEY idx_cgi (cgi) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='cdr_summ_imei_cell_info 导入表';主键选imei + stat_date + stat_hour + cgi,理由是这套键天然对应聚合粒度,能直接防止重复导入;如果 csv 里分区粒度不是小时而是 15 分钟,主键里就要换成stat_time完整时间戳。data_flow用 BIGINT 是必须的,一个设备一天刷几个 GB 视频就是几千万字节,INT 上限 21 亿看着够,但小区级累计很容易破。stay_sec这类可能为空的字段,显式DEFAULT 0比允许 NULL 好——分析阶段SUM(stay_sec)遇到 NULL 会直接变 NULL,还得套IFNULL,脏数据自己给自己挖坑。
字段注释一定要写。csv 交付文件经常没有配套字段说明文档,注释写清楚“信令推导驻留秒数”和“通话时长”的区别,两个月后你自己回来看表也不会猜错。
3.3 用 LOAD DATA LOCAL INFILE 导入:指令与参数调优
建完表,主菜是LOAD DATA。完整命令如下:
-- 导入前先把会话字符集切到 utf8mb4 SET NAMES utf8mb4; LOAD DATA LOCAL INFILE '/data/cdr_summ_imei_cell_info/cdr_summ_imei_cell_info.csv' INTO TABLE cdr_summ_imei_cell CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (imei, lac, cell_id, cgi, @stat_date, @stat_hour, call_cnt, duration, data_flow, stay_sec) SET stat_date = STR_TO_DATE(@stat_date, '%Y-%m-%d'), stat_hour = IF(@stat_hour = '', 0, CAST(@stat_hour AS UNSIGNED)), cgi = IF(cgi = '', CONCAT(LPAD(lac, 5, '0'), LPAD(cell_id, 5, '0')), cgi);逐段拆解。LOCAL关键字表示文件在客户端本地,不是 MySQL 服务器磁盘上,这样即使数据库跑在远程机器也不用把 csv 先传到服务器,日常开发最省事。FIELDS TERMINATED BY ','是字段分隔符;如果 csv 里某些文本字段内含逗号(比如city字段写成"北京市,朝阳区"这种情况),必须加OPTIONALLY ENCLOSED BY '"',否则 LOAD DATA 会把一个字段劈成两列,后面所有列错位。
LINES TERMINATED BY '\n'是另一个高频坑。Windows 下用 Excel 或记事本编辑过的 csv,行结束符可能是\r\n,这里不写'\r\n'的话,每行末尾会多出一个\r,字符串字段不明显,但如果是stat_hour这种数字字段,CAST('\r' AS UNSIGNED)会得到 0,还静默成功,校验时才发现小时全变成 0 了。所以预检阶段head -5看的就是这个。
@stat_date、@stat_hour这种带@的是 MySQL 用户变量,作用是把 csv 里的原始字符串先存进变量,再在SET里做转换。STR_TO_DATE(@stat_date, '%Y-%m-%d')负责把2023/01/01这类分隔符不标准的日期纠正过来;IF(@stat_hour = '', 0, ...)处理空字符串——csv 里的小时列如果是空,直接 CAST 成 UNSIGNED 会得到 0,但会先报一个 warning,大量字段告警时一眼看过去全是红色,没法区分真正的问题。
关于cgi这一列的SET:如果源 csv 里没有 cgi 列,但建表 DDL 里 cgi 是 NOT NULL,LOAD DATA 会报Column 'cgi' cannot be null。这时候有两种修法——要么把 DDL 里的 cgi 改成可空,导入完成后统一 UPDATE;要么像上面这样用IF(cgi = '', ...)在导入时自动拼接。第二种更省事,拼接时LPAD(lac, 5, '0')保证等宽,避免同一个小区出现00001和1两种写法。注意如果 csv 里本身有 cgi 列,这个SET完全不干扰,它只在 cgi 为空时触发生成。
大文件导入还有一个调优组合,导入前临时调整几个会话级参数:
-- 导入前执行:减少磁盘刷盘频率,批量提交更快(生产环境谨慎) SET SESSION innodb_flush_log_at_trx_commit = 2; SET SESSION autocommit = 0; SET FOREIGN_KEY_CHECKS = 0; -- 导入完成后记得恢复: -- SET SESSION autocommit = 1; -- SET FOREIGN_KEY_CHECKS = 1;innodb_flush_log_at_trx_commit=2的意思是每秒刷一次日志而不是每次事务都刷,导入速度能快一个量级,但代价是 MySQL 进程突然崩溃时可能丢最后一秒的数据。导入场景这是可以接受的,业务场景千万别改。还有更激进的做法是导入前ALTER TABLE ... DISABLE KEYS,但 InnoDB 表这个语句效果有限,MyISAM 才有明显收益,现在默认引擎都是 InnoDB,不用浪费时间。
3.4 不清空重导:staging 表 + 原子切换
实际项目中 csv 经常要反复导入——上游重新跑数、你发现脏数据太多要重来。这时候直接往正式表里 LOAD DATA,主键冲突会让导入中断,清空表重导又怕中途失败把好数据也带走。我的做法是先导一张 staging 表,校验没问题再切换。
-- 1. 复制表结构建 staging 表 CREATE TABLE stage_cdr_summ LIKE cdr_summ_imei_cell; -- 2. 往 staging 表灌数据(LOAD DATA 命令同上,INTO 换成 stage 表) LOAD DATA LOCAL INFILE '/data/cdr_summ_imei_cell_info/cdr_summ_imei_cell_info.csv' INTO TABLE stage_cdr_summ -- ... 其余语句同上 ... -- 3. 校验 staging 表行数和关键指标 SELECT COUNT(*) FROM stage_cdr_summ; -- 4. 原子切换:旧表改名备份,新表顶上 RENAME TABLE cdr_summ_imei_cell TO bk_cdr_summ_imei_cell, stage_cdr_summ TO cdr_summ_imei_cell;RENAME TABLE是原子操作,过程中查询要么看到旧表要么看到新表,不会出现读到一半表结构消失的情况。备份表bk_cdr_summ_imei_cell保留一两天再 DROP,确认新数据没问题的后悔药就在这里。
这套 staging 流程最大的好处是:LOAD DATA 失败时直接在 staging 表上 DROP 重来,正式表完全不受影响。坏处是要占双倍磁盘空间,一张 10GB 的 cdr_summ 表,磁盘要有 20GB 余量才玩得起。磁盘紧张时退而求其次,先备份旧表数据到 csv 再清空重导,但就没有原子切换这个保障了。
4. 导入后的三道关卡:索引设计、行数校验与脏数据清理
4.1 主键和二级索引:imei 单独索引是第一个大坑
导入完成不等于能用。这个表最常跑的查询有两种:按 imei 查某台设备的时空轨迹,按日期和 cgi 统计某个小区的人流。主键(imei, stat_date, stat_hour, cgi)已经覆盖了第一种查询——WHERE imei='xxx'直接走主键最左前缀,不需要额外建索引。但第二种查询WHERE stat_date='2023-01-01' AND cgi='xxxx'在主键里完全用不上,因为主键最左列是 imei。
所以 DDL 里给了两个二级索引:idx_date_cgi (stat_date, cgi)和idx_cgi (cgi)。idx_cgi在只有几百万行时看着冗余,但当你后面 JOIN cell_info 表做轨迹分析时,ON a.cgi = b.cgi没有索引就是全表扫描,百万行加几万行的笛卡尔积,查一次卡一分钟很常见。
这里最容易犯的错是另外单独建一个KEY idx_imei (imei)——主键最左列已经是 imei,再建一个单列索引纯属浪费写放大。判断一个索引该不该建,别猜,用EXPLAIN验证:
-- 查看查询是否走索引,rows 字段预估扫多少行 EXPLAIN SELECT * FROM cdr_summ_imei_cell WHERE stat_date = '2023-01-01' AND cgi = '0000100001'\GEXPLAIN输出里type是ref、rows预估个位数,说明索引走对了;如果是ALL全表扫描,就是索引列顺序写反了。联合索引idx_date_cgi两个列的先后顺序也有讲究:先等值列(cgi)再范围列(stat_date)通常更好,但 cgi 的区分度如果不高(整个城市只有几千个小区),先放日期反而更合理。我的经验是直接用(stat_date, cgi),日期先等值过滤,再在小区上过滤,这符合大多数“某天哪些小区人多”的查询模式。
4.2 LOAD DATA 后的三句校验 SQL:总数、独立设备数与抽样
导入完第一件事是校验。三句 SQL 把基本盘打一遍:
-- 第一句:总数与 wc -l 对比(wc -l 结果减 1,减去表头) SELECT COUNT(*) AS total_rows, COUNT(DISTINCT imei) AS dev_cnt, COUNT(DISTINCT cgi) AS cell_cnt FROM cdr_summ_imei_cell; -- 第二句:关键指标 SUM,和上游提供的汇总数对账 SELECT SUM(call_cnt), SUM(duration), SUM(data_flow), SUM(stay_sec) FROM cdr_summ_imei_cell; -- 第三句:自检唯一性,主键理论上不允许重复,查出来就是设计问题 SELECT imei, stat_date, stat_hour, cgi, COUNT(*) AS dup_cnt FROM cdr_summ_imei_cell GROUP BY imei, stat_date, stat_hour, cgi HAVING COUNT(*) > 1 LIMIT 10;第一句的COUNT(*)要和wc -l对上,差一行都说明文件行数不对或者 LOAD DATA 丢行。wc -l统计的是换行符个数,csv 最后一行如果没有换行符,wc -l会少一行,所以更稳的算法是wc -l结果跟SELECT COUNT(*)加一对比,允许表头行存在。第二句的 SUM 对账是硬仗——如果上游给了“该文件总话单量 xxx 万条”之类的清单,这里就能直接核对;没有清单就只能和源文件抽样验证。
第三句更重要。表上主键已经是(imei, stat_date, stat_hour, cgi),正常情况下根本查不出重复,能查出COUNT(*) > 1只有一种可能——建表时主键没建成,或者导入走了 REPLACE 模式导致数据被覆盖但没报错。这个检查跑一遍花不了几秒钟,但能挡住后面所有统计结果虚高的灾难。
还有个抽样验证的小技巧值得用:随机抽一台设备的某个小时,看它在 csv 里对应行的数值和表里是否一致。
-- 抽样:查某台设备 2023-01-01 早上 8 点的小区分布 SELECT cgi, call_cnt, duration, stay_sec FROM cdr_summ_imei_cell WHERE imei = '861234567890123' AND stat_date = '2023-01-01' AND stat_hour = 8;我一般是拿 csv 里对应行用grep搜出来手工对比一次。抽样不要只抽一行,至少抽三行覆盖不同时段,不然撞上脏数据的概率太低,等于没验。
4.3 空值、缺省与脏时间:导入后必须处理的四种脏数据
LOAD DATA 导入完成只是第一关,脏数据清理才是日常。按出现频率排,这四种最恶心。
第一种是空字符串和NULL混杂。csv 里的空值可能表现为真空、NULL字符串、\N三种写法。LOAD DATA在SET子句里处理了一部分,但stay_sec这种没显式处理的字段,导入后可能是 0 也可能是 NULL。统一处理用一条 UPDATE:
-- 把 NULL 统一刷成 0,避免 SUM 统计直接变 NULL UPDATE cdr_summ_imei_cell SET stay_sec = 0, data_flow = 0 WHERE stay_sec IS NULL OR data_flow IS NULL;第二种是时间字段越界。stat_date = '0000-00-00'在 MySQL 严格模式下根本插不进去,但如果表里已经存在,说明当时建表时没开严格模式或者 csv 里有非法值被 STR_TO_DATE 转成了 NULL。查法很简单:
-- 查非法日期,重点是 0000-00-00 和远超当天的未来时间 SELECT stat_date, COUNT(*) FROM cdr_summ_imei_cell WHERE stat_date = '0000-00-00' OR stat_date > CURDATE() GROUP BY stat_date;> 提示:日期字段查出来是 NULL 也要小心。`STR_TO_DATE` 转不了的日期返回 NULL,但 DDL 里 stat_date 是 NOT NULL,严格模式下会直接报错。所以能用 NOT NULL 的字段尽量别留 NULL 口子,脏数据在导入期暴露比在分析期暴露好一万倍。第三种是 imei 脏值。正常 IMEI 是 15 位数字,但测试卡、模拟器、山寨机经常给出1234567890、3558950512345678(16位)或者带字母的。处理方式是筛选出来单独归档,不直接删:
-- 导出非法 imei 清单,交给上游确认 SELECT imei, COUNT(*) AS cnt FROM cdr_summ_imei_cell WHERE imei NOT REGEXP '^[0-9]{15}$' GROUP BY imei;第四种是 cgi 拼接不一致。建表 DDL 里cgi列如果允许空字符串并且导入时IF(cgi='', ...)没有触发,就会出现一部分行是0000100001这种等宽字符串,另一部分是源文件里直接给的1或100001,长度不齐。JOIN cell_info 表时这种不一致会让匹配率掉到一半以下。检查也很简单,按长度分组看一眼:
SELECT LENGTH(cgi) AS len, COUNT(*) AS cnt FROM cdr_summ_imei_cell GROUP BY LENGTH(cgi);正常情况只应该有一个分组。两个分组就说明 cgi 来源不统一,用 UPDATE 把短格式统一成等宽格式即可。
5. 从 csv 到 mysql 的翻车现场:五条高频报错与排查路径
5.1 ERROR 2002:socket 连不上,不是密码错
现象很典型:刚装完 MySQL,mysql -uroot -p回车,直接报ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2)。第一反应是密码记错了,其实根本不是,这是客户端走 Unix socket 连不上服务端。
原因一般三个:MySQL 服务没启动;启动的 socket 路径不是/tmp/mysql.sock;或者你人在客户端机器上但目标 MySQL 在远程服务器。排查顺序固定:
# 看服务是否在跑 systemctl status mysql # 看 MySQL 实际监听的 socket 路径 mysqladmin --socket=/var/run/mysqld/mysqld.sock ping如果systemctl显示服务是 running,就看 socket 路径是否存在。MySQL 8.0 默认 socket 可能在/var/run/mysqld/mysqld.sock,而客户端默认找/tmp/mysql.sock,路径对不上就是 2002。最省心的解法是跳过 socket,直接用 TCP:
mysql -h127.0.0.1 -P3306 -uroot -p-h127.0.0.1会强制走 TCP 协议而不是 socket。注意这里写localhost还是会走 socket,只有写 IP 才走 TCP。docker 容器里连宿主机 MySQL 也同理,必须加-h指定宿主 IP,否则容器内根本没有宿主的 socket 文件,报错一模一样。
5.2 ERROR 1290:secure-file-priv 拦住了 LOAD DATA
现象:执行LOAD DATA INFILE(不带 LOCAL)时报ERROR 1290 (HY000): The MySQL server is running with the --secure-file-priv option so it cannot execute this statement。
原因:MySQL 默认开启secure_file_priv,限制服务端只能从指定目录读文件。我用的是LOAD DATA LOCAL INFILE不走这条路,但如果你图省事把 csv 传到服务器上用不带 LOCAL 的版本,就会撞上这个限制。
-- 先看限制目录在哪 SHOW VARIABLES LIKE 'secure_file_priv';输出如果是/var/lib/mysql-files/,把 csv 挪到该目录再导入;如果输出是空字符串,表示服务端读文件被完全禁止,只能改用LOAD DATA LOCAL并在客户端连接时加参数:
mysql --local-infile=1 -uroot -p -h127.0.0.1注意--local-infile=1是客户端参数,MySQL 8.0 默认客户端这个选项是关的,不加它执行LOAD DATA LOCAL会报另一个错:ERROR 3948 (42000): Loading local data is disabled; this must be enabled on both the client and server sides。改 my.cnf 放开secure_file_priv也是办法,但生产库上改这个属于打开文件读取权限,风险自己掂量。
5.3 7z 解压时报 CRC 错误,密码正确却反复失败
这个报错发生在第一步,还没到 MySQL 呢,但它足够劝退很多人。现象是7z x解压到一半突然报CRC Failed,或者输入密码后明明是对的却提示错误。
原因分析:第一,压缩包下载不完整,文件在传输过程中损坏,CRC 校验自然过不去;第二,p7zip 版本太老,对某些新压缩算法的 7z 包兼容性差;第三,文件名里带括号或特殊符号,shell 把参数截断了。
处理办法按顺序试:
# 1. 先测完整性,不实际解压 7z t 'cdr_summ_imei_cell_info(csv-mysql).7z' # 2. 测出来 CRC 错误,重新下载并对比文件大小 ls -lh 'cdr_summ_imei_cell_info(csv-mysql).7z' # 3. 升级 p7zip 到 16.02 以上 apt-get install --only-upgrade p7zip-full密码“正确却报错”还有一个隐蔽原因:密码里带空格或特殊字符时,终端输入法和键盘布局干扰导致实际输入的字符不对。7z 命令行读密码用-p参数时,密码在进程列表里是明文可见的,我一般先复制到剪贴板再粘贴,规避手输错误。还有一种情况是加密头(-mhe=on)的包,老版本 7z 工具解不了,升级版本即可解决。
5.4 csv 中文乱码与 GBK/UTF-8 错乱
现象:导入 MySQL 后,province、city字段全是乱码,或者插入时报Incorrect string value: '\xE5\x8C\x97...' for column 'city'。
原因:这个场景太常见了。手机信令数据的 csv 很多来自运营商内部系统,导出时用的 GBK 编码,而表是 utf8mb4,LOAD DATA 又没有声明字符集,MySQL 把 GBK 字节流按 utf8mb4 解析,中文必然乱码。
# 解压后先用 file 命令确认编码 file -bi cdr_summ_imei_cell_info.csv # 输出 charset=iso-8859-1 或 charset=unknown 时,多半是 GBK # 转码:GBK 转 UTF-8(文件大时用 iconv 会比 pandoc 之类快很多) iconv -f GBK -t UTF-8 cdr_summ_imei_cell_info.csv > cdr_summ_imei_cell_info_utf8.csv转码后导入时仍然显式声明字符集最稳,LOAD DATA 里CHARACTER SET utf8mb4不能省。还有一个隐藏坑:csv 带 BOM 头(\xEF\xBB\xBF),第一列字段名会带着不可见字符,IGNORE 1 LINES跳过头行后,列名对不上或者第一个字段名变成\xEF\xBB\xBFimei,查询时得用反引号包着写。用sed -i '1s/^\xEF\xBB\xBF//'把 BOM 删掉再导。
5.5 Excel 编辑过的 csv:多字段、回车符、时间格式三重错位
现象:LOAD DATA 导入后行数比wc -l多出不少,或者call_cnt明明是数字但查出来是 0,某些行的字段串列,stat_date变成45292这种 Excel 日期序列号。
原因:这个 csv 被 Excel 打开并重新保存过。Excel 保存 CSV 时会干三件坏事:字段内如果有逗号,它会给字段加双引号,但引号规则和 RFC 4180 不一定完全一致;单元格里的换行符会被直接写进 csv,导致一行物理行变成多行,LOAD DATA 按\n切行时直接切碎;日期列被格式化成mm/dd/yyyy或序列号,STR_TO_DATE解析失败返回 NULL。
处理办法就一条:别用 Excel 编辑 csv。如果源文件只有 Excel 处理过的版本,用代码清洗:
# Python 清洗被 Excel 搞坏的 csv,按标准 CSV 规则重写 import csv with open('cdr_summ_from_excel.csv', 'r', encoding='utf-8') as f_in, \ open('cdr_summ_clean.csv', 'w', encoding='utf-8', newline='') as f_out: reader = csv.reader(f_in) writer = csv.writer(f_out) for row in reader: # csv 模块已经处理了引号和字段内换行,这里只修日期 writer.writerow([cell.replace('/', '-') for cell in row])这里csv.reader会正确处理引号包裹的字段内逗号和换行,把 Excel 版 csv 重新标准化。日期里的/替换成-,是为了让 MySQL 的STR_TO_DATE能按%Y-%m-%d解析。经验之谈:拿到 csv 第一件事就复制一份原始包到只读目录,所有清洗都在副本上做,源文件永远不动。
6. 让 cdr_summ 数据产生价值:把 imei 轨迹切成停留点的窗口函数技巧
6.1 先补一张 cell_info 经纬度表
cdr_summ 本身只有小区编号,没有经纬度,不关联 cell_info 表就是一堆数字。单元格位置信息通常单独维护:cgi、lac、cell_id、经度、纬度、所属区县。把这表也灌进 MySQL,然后就能做空间关联了。
-- 典型 cell_info 表结构(csv 导入方式同前) CREATE TABLE cell_info ( cgi VARCHAR(16) PRIMARY KEY COMMENT '小区识别码', lac INT NOT NULL COMMENT '位置区编码', cell_id INT NOT NULL COMMENT '小区编号', lon DECIMAL(10, 6) NOT NULL COMMENT '经度', lat DECIMAL(10, 6) NOT NULL COMMENT '纬度', city VARCHAR(64) COMMENT '所属城市', district VARCHAR(64) COMMENT '所属区县', scene_type VARCHAR(32) COMMENT '场景:商场/园区/交通枢纽/居住区' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;scene_type字段是进阶分析的关键——有了它才能区分“晚上停在居住区”和“白天停在办公园区”,这是职住判断的基础。如果没有现成的场景标注,可以用 POI 数据匹配小区位置周边有什么设施来打标,但那个工程量大,先保证经纬度和区县字段准确就足够跑通主流程。
6.2 用窗口函数把轨迹切成停留点:ST_Distance_Sphere 与 LAG
接下来是让这份数据值钱的核心操作:把 imei 的时空轨迹转成“在哪停留了多久”。原理很简单——按 imei 和时间排序,计算每个点与上一个点的距离和时差,距离小于阈值且持续超过时长的归为同一段停留,否则视为移动。
-- MySQL 8.0 窗口函数版,识别单设备连续停留 WITH ordered AS ( SELECT imei, stat_date, stat_hour, cgi, lon, lat, -- 取上一个观测点的时间、坐标,NULL 表示该设备第一条记录 LAG(CONCAT(stat_date, ' ', LPAD(stat_hour, 2, '0'), ':00:00')) OVER (PARTITION BY imei ORDER BY stat_date, stat_hour) AS prev_ts, LAG(lon) OVER (PARTITION BY imei ORDER BY stat_date, stat_hour) AS prev_lon, LAG(lat) OVER (PARTITION BY imei ORDER BY stat_date, stat_hour) AS prev_lat FROM cdr_summ_imei_cell JOIN cell_info USING (cgi) ), move_flags AS ( SELECT imei, stat_date, stat_hour, cgi, lon, lat, prev_ts, prev_lon, prev_lat, -- 距离超过 300 米或时间间隔超过 2 小时,视为新的一段轨迹 CASE WHEN prev_ts IS NULL OR TIMESTAMPDIFF(MINUTE, STR_TO_DATE(prev_ts, '%Y-%m-%d %H:%i:%s'), CONCAT(stat_date, ' ', LPAD(stat_hour, 2, '0'), ':00:00') ) > 120 OR ST_Distance_Sphere(POINT(lon, lat), POINT(prev_lon, prev_lat)) > 300 THEN 1 ELSE 0 END AS is_new_segment FROM ordered ) SELECT imei, cgi, MIN(stat_date) AS seg_start_date, MIN(stat_hour) AS seg_start_hour, COUNT(*) AS total_obs, MAX(lon) AS lon, MAX(lat) AS lat FROM ( SELECT imei, stat_date, stat_hour, cgi, lon, lat, -- 累加拐点标记,生成分段编号 SUM(is_new_segment) OVER (PARTITION BY imei ORDER BY stat_date, stat_hour ROWS UNBOUNDED PRECEDING) AS seg_id FROM move_flags ) seg_start GROUP BY imei, seg_id, cgi ORDER BY imei, seg_start_date, seg_start_hour;这段 SQL 的核心是LAG窗口函数取上一个观测点,然后用ST_Distance_Sphere计算两个经纬度点的球面距离,单位是米。ST_Distance_Sphere是 MySQL 8.0 才有的函数,5.7 里没有,5.7 环境要退而求其次用 Haversine 公式自己算。is_new_segment标记打出来后,再用一次SUM() OVER做累加分段,每个段落的 cgi、起止时间和观测次数就是一次“停留”。
跑完这段 SQL,每个 imei 会得到一串驻留点序列,配合cell_info.scene_type就能判断这个设备晚上住在哪个区、白天出现在哪个园区,区县级职住比、跨城通勤 OD 都是从这个结果继续汇总得到的。这也回到标题里cell_info存在的意义——cdr_summ 提供轨迹,cell_info 提供位置语义,两者结合才是完整的分析链路。
我处理 2023 年区县级手机信令数据时的习惯是:先跑一段小规模验证(比如抽 100 台设备跑通整条 SQL),确认分段结果合理,再放开全量跑。这个习惯帮我躲过好几次全量跑完才发现时间字段拼接错误、全部驻留点散架的事故。数据文件到手,先花十分钟做字段级抽样,比导入后补救省一倍时间,希望帮到你。
本文还有配套的精品资源,点击获取