我接到这份门禁数据的时候,整个人是有点懵的。系统导出一万条通行记录,表面看字段齐全,卡号、时间、门区都在,可真要拿去统计“哪个门区最忙”“哪些人经常加班”的时候,发现人员姓名、部门、进出方向这些关键字段,零零散散缺了一大片。做数据补全这件事,听起来没那么热血,但真正把一万条脏数据一条条捋干净、把缺失的信息用SQL和业务规则一步步“拼”回去之后,我意识到这活儿远远不止是写几个UPDATE语句那么简单。这篇就算作“东方仙盟”系列的练气期复盘吧——练的不是花架子,是对数据链路、字段血缘和取舍判断的基本功。如果你也在处理门禁记录、考勤流水、刷卡日志这类设备型数据,这篇应该能帮你少踩不少坑。
1. 原始数据的摸底:先搞清楚一万条里到底缺了什么
拿到数据的第一步永远不要急着补,先查“病”。门禁数据通常是从一卡通平台或硬件厂商的管理系统里导出来的,常见格式无非是CSV或Excel,入库之后长这样:
表名:access_log
code复制record_id BIGINT 记录主键
card_no VARCHAR(20) 物理卡号 / 工号
person_name VARCHAR(50) 姓名
dept_name VARCHAR(100) 部门
door_no VARCHAR(20) 门区编号
direction TINYINT 进出方向 1=进 0=出
pass_time DATETIME 通行时间
terminal_id VARCHAR(20) 读头/终端编号
听起来很清晰对吧?可真实数据给我上了一课。第一轮探查我写了一个通用探查SQL,把每一列的空值率、重复率、枚举值分布全拉了出来:
sql复制SELECT
COUNT(*) AS total_records,
COUNT(card_no) AS ok_card_no,
COUNT(person_name) AS ok_person_name,
COUNT(dept_name) AS ok_dept_name,
COUNT(door_no) AS ok_door_no,
COUNT(direction) AS ok_direction,
COUNT(pass_time) AS ok_pass_time,
SUM(CASE WHEN card_no IS NULL OR card_no = '' THEN 1 ELSE 0 END) AS missing_card,
SUM(CASE WHEN person_name IS NULL OR person_name = '' THEN 1 ELSE 0 END) AS missing_name,
SUM(CASE WHEN dept_name IS NULL OR dept_name = '' THEN 1 ELSE 0 END) AS missing_dept,
SUM(CASE WHEN door_no IS NULL OR door_no = '' THEN 1 ELSE 0 END) AS missing_door,
SUM(CASE WHEN direction IS NULL THEN 1 ELSE 0 END) AS missing_direction,
SUM(CASE WHEN pass_time IS NULL OR pass_time = '0000-00-00 00:00:00' THEN 1 ELSE 0 END) AS missing_time
FROM access_log;
统计结果我整理成了下面这张表:
| 字段 | 缺失/异常数量 | 占比 | 严重程度 |
|---|---|---|---|
| card_no | 0 | 0% | 无,主键可靠 |
| person_name | 316 | 3.16% | 高,影响人员维度分析 |
| dept_name | 184 | 1.84% | 中,影响部门维度分析 |
| door_no | 0 | 0% | 无 |
| direction | 93 | 0.93% | 中,影响进出流量统计 |
| pass_time | 7 | 0.07% | 低,但必须处理 |
| terminal_id | 470 | 4.7% | 中,可关联门区推导 |
一万条数据,缺得不算离谱,但也绝对到不了直接分析的程度。如果硬着头皮拿去做报表,3%的姓名缺失会让“按人员统计通行次数”直接少算三百多次,方向缺失则会让“早高峰进楼人数”严重失真。更麻烦的是——光看这些数字,根本看不出缺失背后的规律。
所以摸底不只是算缺失率,还得看缺失记录有没有聚集性。我按小时、按门区分别做了一次聚合,发现两个特征:第一,姓名缺失的记录里有一批集中在某一栋楼的地下车库门区,那个门禁读头是后加装的,人员绑定不完整;第二,direction缺失的记录主要出现在双向闸机上,而且时间分布全天都有,不像是固定故障,更像是闸机归位和方向识别偶发失效。这些规律直接影响后面的补全方案,不在排查阶段看清楚,后面很容易做错。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缺失根因分析:顺着门禁系统的链路找“病根”
门禁数据缺失不是随机的,绝大多数情况下,是数据从产生到落库的整条链路上某个环节出了问题。做补全之前如果不理解这个问题根源,就会出现“补了A又坏了B”的情况,甚至会把实际上正确的数据误改掉。
2.1 设备端到数据库:一次通行记录是怎么丢字段的
门禁系统的通行数据流是这样的:读卡器读到卡号,发送给门禁控制器,控制器判断权限后开闸,同时把记录上传到中心服务器,最终写入业务数据库。数据在这条链路中每一跳都有可能丢信息。
我实际核对后发现,人员姓名的缺失并不是在刷卡那一刻丢的,而是在中心平台“人卡绑定关系”变更时造成的。大多数门禁系统在人员表里只存工号和卡号,person_name和dept_name来自HR系统的同步快照。如果某个人离职后卡被回收又重新发给了另一个人,但HR同步没有及时刷新,甚至历史表里彻底没有了这条绑定关系,老记录就会变成“有卡号没姓名”的孤儿数据。
2.2 方向字段为什么容易丢
direction这个字段更有意思。双向闸机(比如地铁站那种翼闸)是靠红外感应判断人是进还是出的。如果两个人几乎同时通过,或者有人逆行、尾随,读头识别到的方向逻辑就会失效,控制器拿不到明确方向,上传的时候direction就会落一个NULL或默认值。而单向门(比如只进不出的安全门)几乎不会丢direction,因为它只有一个方向。
从数据表现看,direction缺失集中在双向闸机上,和这个机制完全吻合。所以,补这个字段不能瞎猜,要有“双向门+相邻时间戳”作为推断前提,后文我会展开讲。
2.3 时间字段异常:设备时钟和时区是隐形杀手
pass_time缺失或为0的记录只有7条,但这7条背后的问题不能小看。我检查了一下,全是某台Windows工控机上的门禁服务在重启期间产生的。设备本地时钟没有同步NTP,重启后BIOS时间回退到出厂时间,导致上传的时间戳是1970年或者2038年的“非法值”。这类错误单独看量不大,但要是不处理,后续做时间排序、做峰谷分析的时候,会把整段数据的时间轴打乱。
2.4 重复数据:设备重试机制造成的“幽灵记录”
除了缺字段,还有一类不算“缺”但同样要命的问题:重复记录。排查时我按“卡号 + 门区 + pass_time”分组,发现同一秒内同一张卡在同一门区出现多次的记录占了约4%。原因通常是网络瞬时超时,设备自动重传了数据包,而中心端没有做幂等去重。这类重复如果不清理,统计人数时会直接翻倍。
到这里我算是给数据“确诊”了:缺姓名/缺部门是人员主数据同步滞后;缺方向是设备逻辑层失效;时间是设备时钟问题;重复是网络重传机制问题。每一种缺失的修法不一样,统一用“猜”的补法一定会出事。
3. 补全策略设计:为什么我用SQL为主、脚本为辅
排查完之后,我列了几个候选方案。最省事的是直接把缺数据的记录删掉——一万条删五百条,剩九千五百条,干净得很。但这么做的问题在于,后面只要做全量统计,口径就会跟门禁监控平台的原始记录对不上。而且那些缺失的姓名和部门,很多其实是有办法合法找回的,直接删等于把有价值的信息扔了。
另一个方案是写Python脚本,pandas一把梭。对于数据量稍大、逻辑特别复杂的场景,脚本确实是主流选择。但这一万条的门禁数据规模,根本没必要上pandas,而且Python脚本在数据回溯、关联查询上,未必比SQL更高效。尤其“用历史记录关联补全”这类操作,SQL天然就是干这个的。
我最终定的补全策略是:能用SQL解决的全部用SQL,SQL表达不了或需要人工研判的少数记录(比如方向无法推断、姓名完全无法回溯的),单独标记出来,不删除、不硬补,留待人工线下确认。
这个策略的核心原则有三条:
- 可解释性优先。每一条补全都必须能说清楚“依据是什么”。是用人员主数据关联的?还是从历史刷卡记录里回溯的?还是根据前后时间推断的?如果补全逻辑说不清,下游业务方对数据就不敢信。
- 脏数据宁留不删。无法合法补全的记录,与其删除导致统计口径漂移,不如保留并加一个repair_flag=0的标记,让它在报表里默认排除,但原始数据不丢。
- 先备份,再动手。所有UPDATE操作之前,我把access_log完整复制了一份到access_log_backup,这是最不值钱也最不能省的保护措施。
设计好策略之后,我随手在数据库里建了一张补全日志表,目的后面会讲到——记录什么人、什么时间、用什么规则、修改了哪些记录的哪个字段。这不是形式主义,是为了让整个“补全过程”本身也能被审计。
4. 分步补全实操:去重、关联、回溯、推断的四重拳
4.1 第一重:重复记录的清理
清理重复记录是第一件要做的事,因为如果不去重,后面补方向、补时间时,同一秒钟的重复记录会互相干扰判断。比如方向推断依赖前后记录顺序,重复记录会让LAG函数取到同秒的另一条重复数据,推断结果就会出错。
我先用下面的语句把重复组揪出来看一眼:
sql复制SELECT card_no, door_no, pass_time, COUNT(*) AS dup_cnt
FROM access_log
GROUP BY card_no, door_no, pass_time
HAVING COUNT(*) > 1
ORDER BY dup_cnt DESC;
确认重复规则后,给每条记录打一个“组内序号”,保留最早写入的那条,其余标为删除。
sql复制WITH ranked AS (
SELECT record_id,
ROW_NUMBER() OVER (
PARTITION BY card_no, door_no, pass_time
ORDER BY record_id ASC
) AS rn
FROM access_log
)
SELECT * FROM ranked WHERE rn = 1;
先用SELECT确认保留范围没问题,再执行DELETE:
sql复制DELETE FROM access_log
WHERE record_id NOT IN (
SELECT record_id FROM (
SELECT record_id,
ROW_NUMBER() OVER (
PARTITION BY card_no, door_no, pass_time
ORDER BY record_id ASC
) AS rn
FROM access_log
) t
WHERE t.rn = 1
);
注意MySQL里子查询要再包一层,否则会报“You can’t specify target table for update in FROM clause”的错误。这一步清掉了约420条纯重复记录。
4.2 第二重:姓名和部门的关联补全
去重之后,最优先补的是person_name和dept_name,因为这两个字段是人员维度分析的根基。补全来源有几层:
第一层,根据卡号关联员工主表emp_info。这一步能解决大部分正常在职人员的缺失:
sql复制UPDATE access_log a
LEFT JOIN emp_info e ON a.card_no = e.card_no
SET a.person_name = COALESCE(a.person_name, e.emp_name),
a.dept_name = COALESCE(a.dept_name, e.dept_name)
WHERE a.person_name IS NULL OR a.person_name = ''
OR a.dept_name IS NULL OR a.dept_name = '';
这里有一个很多人容易忽略的细节:为什么用LEFT JOIN而不是INNER JOIN?因为如果用INNER JOIN,会把那些join不上的人直接从结果里过滤掉,等于把10000条记录悄悄降成了8000条,这是最典型且最阴险的坑。LEFT JOIN才能保证原表记录数不变,join不上的保留原值继续走下一层补全。
第二层,对于员工主表里join不上但历史上曾经出现过正确姓名的情况,用“卡号历史回溯”法。比如一张卡以前绑定过张三,后来张三调走、卡转给李四,但门禁平台的人员快照更新不及时,部分记录的姓名还是老的。这时我写了一条自关联语句,取同一卡号在缺失记录时间点之前最近一条有姓名记录的值:
sql复制UPDATE access_log a
LEFT JOIN emp_info e ON a.card_no = e.card_no
LEFT JOIN access_log history ON history.card_no = a.card_no
AND history.pass_time < a.pass_time
AND history.person_name IS NOT NULL
AND history.person_name <> ''
SET a.person_name = COALESCE(a.person_name,
NULLIF(e.emp_name, ''),
NULLIF(history.person_name, ''))
WHERE a.person_name IS NULL OR a.person_name = '';
需要注意的是,如果同一条缺失记录前一天对应张三、后一天对应李四,直接用“最近一次”也可能出错。所以回溯之前要先看一眼这个卡号的历史姓名序列是否唯一。我实际探查后发现,大部分缺失记录的card_no在历史表里只能匹配到同一个姓名,只有十来条卡号出现过多次姓名的变更。这批记录我单独拉出来人工判断,不套用自动逻辑。
第三层,部门字段同理,但部门有一个额外坑:部门名称会在组织架构调整后变化。比如“研发二部”整体改名成“智能研发部”,半年前的记录存的是“研发二部”,现在的人员表只有“智能研发部”。如果直接拿员工主表JOIN,补出来的部门其实是“当前部门”,跟当时的部门并不是一回事。对做历史分析来说,可能需要保留原始部门名称,或至少加一个部门变更注释。这个取决于业务口径,我这次的做法是优先保留历史快照中的部门,主表中的当前部门只作为不存在历史快照时的兜底。
4.3 第三重:方向缺失的双向推断
direction字段的补全需要一点业务推理。对于单向门,缺失的方向可以直接按门的物理属性赋值,比如“东门-单向进入”这个门的所有记录只可能是进。我在门禁设备表door_info里存有这个属性,所以先做一次JOIN赋值:
sql复制UPDATE access_log a
JOIN door_info d ON a.door_no = d.door_no
SET a.direction = d.default_direction
WHERE a.direction IS NULL
AND d.door_type = 'single';
这一条规则能覆盖一小部分缺失,但大头还是双向闸机。双向闸机的方向怎么推断?我的思路是:看相邻两条记录的位置关系。如果某个卡号在T1时刻通过了A门,过了几分钟在B门又刷了一次,而B门所在物理位置在A门的“内侧”,那这次大概率是出楼。反过来则是进楼。
实际操作上,我用LAG窗口函数取同一卡号上一次通行的门区和时间,和当前记录做对比,再结合门区物理拓扑关系表给出判定:
sql复制WITH ordered_log AS (
SELECT record_id, card_no, door_no, pass_time, direction,
LAG(door_no) OVER (PARTITION BY card_no ORDER BY pass_time) AS prev_door,
LAG(pass_time) OVER (PARTITION BY card_no ORDER BY pass_time) AS prev_time
FROM access_log
)
SELECT record_id, card_no, door_no, pass_time, direction, prev_door, prev_time
FROM ordered_log
WHERE direction IS NULL
ORDER BY card_no, pass_time;
输出结果需要人工抽样核查几轮,确认门区之间的拓扑关系是稳定的,再写UPDATE。比如我在这套门禁中发现了一个规律:B2车库负一层的闸机,刷卡记录在时间上紧挨着B1电梯厅的记录时,方向几乎都是从车库进入楼内;而紧挨着室外门禁记录时,方向多是外出。这种推断依赖的是既定物理拓扑,不是凭空猜。
方向推断的正确率很难做到100%,我给自己定的标准是98%以上,抽样复核没发现反例,就可以采用;如果发现大量反例,就要回头检查门区拓扑数据是不是反了。对最终仍然无法确定的少数记录,比如该卡号当天只出现一次、前后没有参照物的,直接标记repair_status='direction_unknown',宁可保留NULL也不编造。
4.4 第四重:时间异常修复与边界处理
pass_time缺失或为0的7条记录,处理起来反而简单:设备日志和另一台服务器的操作日志里可以找到同一张卡同一门区相近时间的事件,再把时间戳还原出来。但更微妙的是那些看起来正常、实际不对的“脏时间”——比如某几条记录的时间比前后记录快了整整12个小时,大概率是设备12小时制/24小时制切换导致的。
我用下面这个查询把“时间倒挂”的记录找出来:同一个卡号,本次通行时间比上次还早,且差值超过8小时的:
sql复制SELECT record_id, card_no, door_no, pass_time,
LAG(pass_time) OVER (PARTITION BY card_no ORDER BY pass_time) AS prev_time
FROM access_log
WHERE TIMESTAMPDIFF(HOUR,
LAG(pass_time) OVER (PARTITION BY card_no ORDER BY pass_time),
pass_time) < -8;
对确认是12小时制导致的时间偏移,处理时统一加12小时,并且要人工复核结果是否与刷卡场景相符——大半夜零点刷卡的人毕竟少,调整后落到中午才是合理的。
4.5 补不全的数据如何处理:别删,打个标记
所有自动补全跑完之后,我清点了一遍,还有几十条姓名完全无法回溯的记录,以及十几条无法确定方向的记录。面对它们,我的处理原则是:不硬补、不删除,但要让下游查询默认不消费这些脏数据。
我为此加了一个repair_status字段,取值包括ok、repaired、missing_name、direction_unknown、device_time_fixed等。在最终提供给分析侧的视图中,只输出repair_status != 'direction_unknown'的数据,保证分析口径干净。但底层表仍然留全量记录,方便追溯和审计。
这一步在整个项目中看起来不起眼,但它的价值很大——数据补全不是“把所有空都填上就万事大吉”,填不上时要敢于留空。硬编一个“未知”进去,等于把问题往后端转移,后面做数据挖掘的人根本不知道这个“未知”是真实存在还是你编出来的。补全过程的“可解释性”从设计到执行必须坚守到底。
5. 校验与复盘:用什么标准证明补全结果可信
补全做完之后,最重要的不是“自我感觉良好”,而是拿出一套可验证的证据链。我一般从五个维度做校验,缺一不可。
5.1 记录总数守恒校验
这是最底线的检查。补全过程中,绝对不能改变记录总数。我前后统计了多轮:
| 阶段 | 记录总数 | 姓名缺失 | 部门缺失 | 方向缺失 | 时间异常 | 重复标记 |
|---|---|---|---|---|---|---|
| 原始表 | 10000 | 316 | 184 | 93 | 7 | 420 |
| 去重后 | 9580 | 302 | 176 | 88 | 7 | 0 |
| 姓名部门补全后 | 9580 | 41 | 23 | 88 | 7 | 0 |
| 方向补全后 | 9580 | 41 | 23 | 12 | 7 | 0 |
| 时间修复后 | 9580 | 41 | 23 | 12 | 0 | 0 |
| 标记脏数据后 | 9580 | 41(已标记) | 23(已标记) | 12(已标记) | 0 | 0 |
如果哪个阶段做完,COUNT(*)少了或多了一条,说明SQL的JOIN或DELETE逻辑有bug,必须停下来查,而不是继续往下走。
5.2 抽样人工核对
系统自动补全的结果,我会抽3%左右按“缺失类型分层抽样”,人工对着原始刷卡小票、门禁监控记录、HR花名册核实。尤其是方向字段,如果人工抽样发现推断错误超过2%,整条推断规则就不能上线。我在这个项目里抽了30条方向补全记录,全部核实无误,这才敢把UPDATE应用到全表。
5.3 按业务维度回归测试
补数据最终是为了分析服务。我会用原始数据(只删除重复但未补字段)和补全后数据分别跑一遍核心指标,观察差异是否在预期范围内。比如“按日期统计进出楼总人次”,补全前和补全后,同一日期的数据差异应该在缺失比例的容差范围内,如果某一天突然产生5%以上的波动,那说明补全规则可能在某些特殊日期上过度推断,要单独查。
5.4 连续性和逻辑规则抽查
针对时间字段,我额外做了一次“同一天内,同一卡号连续两条记录的时间差不能为负”的检查。这能捕捉到漏网的时间脏数据。针对direction,也做了“同一扇双向门,不能在同一条秒级记录里既进又出”的规则校验,有冲突则说明方向推断或去重逻辑还没做到位。
5.5 补全过程审计
前面建的那张补全日志表,这时派上了用场。它记录了每一条被修改记录的record_id、被修改字段、旧值、新值、补全规则类型、操作SQL批次、执行时间。将来业务方质疑某一条数据“为什么变成这样”,我可以直接翻日志给出依据。数据库维护里,比“做对”更重要的是“能证明你做对了”,很多朋友做数据处理时只管结果不管过程,结果上线后一被质疑就哑火。
6. 这次练气期的实操心得:门禁类数据补全的通用底稿
如果下次再让我处理另一批门禁数据,或者说换成考勤记录、停车场进出记录这类设备型数据,我的处理框架基本不会变:先做缺失全貌摸底,再推导缺失根因,然后按根因分策略补全,“关联补全”永远优先于“推断补全”,“推断补全”永远优先于“人工硬编”,最终无法处理的打标记而不是删除。
这里分享几个这次项目里踩过的具体教训,给各位做个参考:
-
不要小看“去重”的时机。如果先做了人员关联补全再做出重,重复记录可能会被错误地补上不同姓名(因为同一卡号在重复时刻可能对应不同快照),再去重时就会丢掉有效信息或保留错误信息。正确顺序一定是先去重、再补字段。
-
LEFT JOIN保护记录数这条规则怎么强调都不过分。补全阶段宁可使用LEFT JOIN加COALESCE链,也不要图省事INNER JOIN一把梭。记录数在UPDATE前后必须做COUNT校验,我建议把“跑SQL前先COUNT、跑完后COUNT”变成肌肉记忆。
-
方向推断务必先看门区拓扑。不要试图用一个通用算法解决所有门的推断问题。单向门、双向门、卷帘门、地下车库门,物理特征完全不同,用一套逻辑通吃必然翻车。最稳妥的做法是先按door_type和door_no拆分成不同场景,分别制定规则,分别验证。
-
备份这种事,养成习惯就不觉得是成本。我在处理前执行了一句CREATE TABLE access_log_backup AS SELECT * FROM access_log;,整轮操作在一个事务里反复UPDATE、DELETE、再回滚,最后也是靠它兜底做了一次全量对账。没有备份的前提下操作几万行数据,心态和技术动作都会变形。
这批门禁数据处理到最后,真正下到业务报表里被使用的记录是九千五百多条,每条记录都经历了“哪来的、缺什么、依据什么补的、谁什么时候改的”这一整套拷问。练气期的标题听起来中二,但修炼的其实是数据库从业者的日常基本功——没有花哨框架、没有高深算法,就是老老实实理解每一行数据的来路去向,在可控的范围内动手修复,在不确定的边界上保留敬畏。后面如果有机会,我会把这种数据治理的套路延伸到更大规模的数据集上——当数据量从万级涨到百万级,很多在当时靠手写SQL就能完成的方案就不得不换思路了,那个时候再跟大家聊筑基期的心得。
