1. 项目概述:从一句提问开始的数据探查实践
“What’s in the data?”——这短短五个单词,不是一句客套的寒暄,而是数据工作真正启动时,每个从业者在打开第一个CSV文件、连接第一张数据库表、拿到第一批埋点日志前,必须问自己的第一句话。它不炫技,不宏大,甚至显得有点笨拙,但恰恰是这句话,把我们从“我要建个模型”“我要做个看板”的模糊冲动,拽回地面,踩在真实数据的质地之上。我带过十几支跨行业数据团队,从电商用户行为分析到制造业设备传感器诊断,从教育平台学习路径挖掘到本地生活服务履约时效优化,所有项目崩盘的起点,几乎都始于跳过了这句提问,或者用一句“数据应该没问题”草草作答。它背后藏着三重硬核需求: 确认数据存在性 (字段有没有?记录空不空?)、 验证数据可信度 (数值范围合不合理?时间戳有没有乱序?ID有没有重复?)、 识别数据语义层 (这个“status”字段,0/1/2到底代表“未支付/已支付/已退款”,还是“待审核/审核中/已通过”?)。这不是IT运维的巡检清单,而是业务与技术共同签署的“数据知情同意书”。适合谁来读?如果你刚接手一份新数据源,正对着几百列字段发懵;如果你的模型效果突然下滑,怀疑是上游数据变了;如果你在写SQL时总要反复查文档确认字段含义;甚至如果你只是想在会议里听懂同事说的“这个指标口径有漂移”,那么这篇内容就是为你写的。它不教你怎么写高级算法,只聚焦一件事:如何用最朴素、最系统、最可重复的方式,回答那句最根本的“What’s in the data?”。
2. 数据探查的整体设计思路与方案选型逻辑
2.1 为什么不能只靠“看一眼”或“跑个count(*)”?
新手最容易犯的错误,是把数据探查等同于“快速扫一遍”。打开Excel拉个透视表,或者在数据库里敲一条
SELECT * FROM table LIMIT 10;
,看到几行数据,就以为心里有数了。我试过三次——第一次是在一个金融风控项目里,用
LIMIT 10
看了用户交易表,发现金额字段都是正数,就默认数据干净。结果上线后模型误判率飙升,排查三天才发现,有0.3%的记录金额为负值,代表退款,而这些负值全被上游ETL脚本当异常数据过滤掉了,导致模型从未见过“退款”这一关键行为模式。第二次是在一个IoT设备项目,
DESCRIBE sensors;
显示所有字段类型都是
FLOAT
,我就信了。直到某天凌晨告警,发现温度传感器读数突变为
-999.0
,而这个值在原始协议里是“传感器离线”的标志位,但下游系统把它当成了真实温度参与了计算。第三次更隐蔽:一个电商订单表,
order_id
字段看着全是16位数字字符串,
SELECT COUNT(*)
和
COUNT(DISTINCT order_id)
结果一致,我以为唯一性没问题。后来做用户复购分析时发现大量“同一用户同一天多笔订单”被合并,深挖才发现,有约5%的
order_id
末尾带了一个不可见的空格字符(ASCII 32),
DISTINCT
在某些数据库引擎下会忽略它,但
JOIN
时却严格匹配,导致关联失败。这些都不是数据量大造成的,而是“看一眼”这种直觉式操作,完全无法覆盖数据的
完整性、一致性、准确性、时效性
这四个维度。
COUNT(*)
只能告诉你有多少行,却无法告诉你这行里的每一个值是否在合理域内;
LIMIT 10
只展示冰山一角,而数据问题往往藏在长尾分布里。
2.2 为什么选择“分层探查法”而非“全量扫描”?
面对动辄上亿行、数百列的表,全量扫描(比如对每一列都算
MIN/MAX/COUNT DISTINCT
)在时间和资源上都是灾难。我在一个日增2TB日志的广告平台做过测算:对一张包含87个字段的用户曝光日志表,执行一次全量统计聚合,单次耗时超过42分钟,且会挤占集群80%的计算资源,影响线上任务。所以,必须设计一套
成本可控、风险前置、结果可解释
的探查流程。我最终落地的方案是“三层漏斗式探查”:
元数据层 → 样本层 → 全量层
。这个设计不是拍脑袋来的,而是基于对数据问题发生概率的统计经验。根据我们团队过去三年对217个数据问题的归因分析,约68%的问题能在元数据层暴露(比如字段名拼写错误、类型定义错误、注释缺失);23%的问题需要样本层深入(比如特定取值的业务含义错误、小概率异常值);只有不到9%的问题必须依赖全量层(比如极低频的ID冲突、跨天的时序错乱)。这意味着,把80%的精力放在前两层,就能拦截90%以上的高危问题。元数据层探查,核心是“读文档、对定义、验契约”,检查的是数据生产者承诺的契约是否被遵守;样本层探查,核心是“抽样、分布、找异常”,用统计学方法在可控成本下逼近真相;全量层探查,则是“靶向、验证、定案”,只对前两层标记出的高风险字段或场景,才投入重资源进行最终确认。这种分层不是为了偷懒,而是把有限的工程师时间,精准地分配给最可能产出价值的地方。
2.3 工具链选型:为什么是Python + Pandas + Great Expectations组合?
工具选择上,我放弃了纯SQL方案和商业BI工具内置的探查功能。纯SQL虽然通用,但编写复杂统计逻辑(比如计算某个分类字段的基尼不纯度、检测时间序列的单调性)极其繁琐,且难以复用和版本化;而BI工具的探查往往是个黑盒,你看到一个“数据质量评分”,却不知道这个分数是怎么算出来的,更无法把它嵌入到CI/CD流水线里自动触发。最终选定的组合是:
Python作为主语言,Pandas处理核心逻辑,Great Expectations(GE)作为质量断言框架
。这个组合的底层逻辑很务实:Python生态成熟,Pandas是事实上的数据处理标准库,学习成本低,社区支持强;而GE的核心价值,在于它把“数据质量检查”从一句描述性的业务规则(如“user_age字段必须在0-150之间”),翻译成了一套可执行、可验证、可报告、可版本化的代码契约。你可以把GE的Expectation Suite(期望套件)当成一份活的、会自己跑测试的数据字典。更重要的是,它天然支持与Git集成——当你修改了某个字段的业务规则,你不是去改一份Word文档,而是直接提交一个
.json
或
.py
文件的变更,PR里自动运行数据质量测试,不通过就阻断合并。这解决了数据治理中最痛的点:规则与代码脱节。我见过太多团队,业务方口头上说“订单状态只能是0,1,2”,但代码里却写了
WHERE status IN (0,1,2,3)
,因为没人去校验这份口头约定。GE让规则变成了代码的一部分,而不是贴在墙上的标语。当然,它也有局限:对于超大规模数据(百亿行以上),GE的
validate
命令会把数据加载进内存,这时就需要配合Spark或Dask做分布式探查,但这属于进阶场景,80%的日常探查,这个组合足够稳、够快、够透明。
3. 核心细节解析与实操要点:从元数据到样本的深度拆解
3.1 元数据层探查:不只是看字段名,更要读懂“数据契约”
元数据层探查,绝不是简单地执行
DESCRIBE table;
或
pd.read_sql("SELECT * FROM table LIMIT 0", conn).dtypes
。它的目标是验证数据生产者与消费者之间那份隐性的“数据契约”是否被完整、准确地履行。我把它拆解为四个必查维度:
第一,字段定义契约
。不仅要查字段名和类型,更要查
精度与约束
。比如一个
amount
字段,数据库定义为
DECIMAL(10,2)
,这表示最多10位数字,其中2位小数。但如果业务要求精确到分,而上游系统传入的是
DECIMAL(12,4)
,那么在入库时,数据库会自动四舍五入,导致0.005元被抹掉。我在一个支付对账项目里就遇到过,上游结算系统按毫为单位传值,而下游账务库只支持分,结果每天都有几笔微小差额无法平账。查元数据时,必须把
CREATE TABLE
语句完整捞出来,逐字比对
DECIMAL
的精度、
VARCHAR
的最大长度、
TIMESTAMP
的时区设置(是
UTC
还是
Asia/Shanghai
?),这些细节决定了数据能否无损流转。
第二,业务语义契约
。这是最容易被忽视的一环。字段名
status
本身毫无意义,它的价值完全取决于附着其上的业务定义。我要求团队在探查时,必须同步查阅三份材料:1)数据字典文档(如果有的话);2)上游系统的代码或API文档(搜索
status
字段的枚举定义);3)最近一周的样本数据,人工核对几个典型值对应的业务场景。例如,一个
user_level
字段,文档里写着“1:普通用户, 2:VIP, 3:SVIP”,但样本里出现了
user_level=0
,这时就必须追问:0代表什么?是新注册未初始化?还是已注销用户?这个0值在后续的用户分群模型里,是该归入“普通用户”还是单独作为一个“未知”群体?不把这个语义搞清楚,任何基于此字段的分析都是空中楼阁。
第三,更新机制契约
。数据是静态快照,还是实时流?是T+1全量覆盖,还是增量追加?这个机制直接决定了你探查的时间窗口。我曾在一个物流轨迹项目里栽过跟头:上游说“每5分钟推送一次最新位置”,我按这个节奏去查,发现
last_update_time
字段的时间戳总是集中在整点前后,非常规律。但实际业务中,司机APP是“有位置变化才上报”,理论上应该是随机的。深挖后发现,上游中间件有个bug,会把所有上报请求攒批,统一在整点触发。这个“更新机制”的偏差,导致我们用
last_update_time
做时效性分析时,结论完全错误。所以,元数据探查必须包含
UPDATE_TIME
、
LOAD_TIME
、
PARTITION_DATE
等时间相关字段的分布检查,并与上游约定的SLA(服务等级协议)做交叉验证。
第四,血缘与依赖契约
。这个字段的数据,是从哪张表、哪个ETL任务、哪段代码生成的?它依赖哪些上游输入?这个信息决定了问题定位的效率。我习惯在探查初期,就用
SHOW CREATE TABLE
或数据血缘工具(如Apache Atlas)画出这张表的上游依赖图。有一次,一个报表指标突然翻倍,我们顺着血缘图一路向上,30分钟就定位到是上游一个临时调试用的
UNION ALL
语句,被误提交到了生产环境,把测试数据也混了进来。没有血缘图,我们可能要在几十张表里大海捞针。
提示:元数据探查不是一次性动作,而是一个持续的过程。我建议把上述四个维度的检查项,固化成一个
check_metadata.py脚本,每次新接入一个数据源,或上游Schema有变更时,自动运行。输出结果不是一行OK,而是一份结构化报告,明确标出“通过”、“警告”(如字段注释为空)、“失败”(如类型与业务要求不符)。
3.2 样本层探查:如何科学抽样,让1000行数据说出100万行的故事?
样本层探查的核心矛盾是:
如何用最小的样本量,最大化地暴露数据中的潜在问题?
简单的随机抽样(
ORDER BY RAND() LIMIT 1000
)在这里是失效的。因为数据问题往往不是均匀分布的,而是集中在某些特定的“角落”:比如,某个
country_code
字段,99%的值是
CN
,但剩下的1%里,有0.5%是
USA
,还有0.5%是各种拼写错误的
US
、
U.S.
、
United States
。一个纯随机样本,很可能一个
USA
都抽不到,更别提那些稀奇古怪的错误值了。因此,我采用的是
分层+边界+时序
的混合抽样策略。
分层抽样(Stratified Sampling)
,针对的是
分类字段
。以
product_category
为例,先用
SELECT product_category, COUNT(*) FROM table GROUP BY product_category ORDER BY COUNT(*) DESC;
获取各品类的分布比例。然后,按比例抽取,确保头部品类(如
手机
)抽500行,腰部品类(如
耳机
)抽200行,长尾品类(如
智能手表
)哪怕只有10条记录,也全部抽出来。这样,样本能真实反映数据的结构,避免长尾问题被淹没。
边界抽样(Boundary Sampling)
,针对的是
数值和时间字段
。这是发现异常值的利器。对
price
字段,我不只抽平均值附近的值,而是专门抽
MIN(price)
、
MAX(price)
、
PERCENTILE_CONT(0.01) WITHIN GROUP (ORDER BY price)
(第1百分位)、
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY price)
(第99百分位)附近的记录。有一次,
MAX(price)
返回
999999999.00
,这显然不是真实的商品价格,而是上游系统的一个“未知价格”占位符。对
create_time
,则抽最早和最晚的10条记录,检查时间戳是否符合业务逻辑(比如,一个昨天才上线的APP,不可能有2020年的创建时间)。
时序抽样(Temporal Sampling)
,针对的是
增量更新表
。对于按天分区的表,我不会只抽最新分区的数据,而是固定抽取
最近3天、上周同一天、上个月同一天
的样本。这能有效捕捉到周期性问题。比如,一个营销活动系统,每周五晚上8点会触发一次批量优惠券发放,这个操作会导致当天
coupon_id
字段出现一个短暂的、集中的峰值,如果只看最新数据,这个峰值可能已经过去,但它的影响(如并发写入导致的主键冲突)可能残留在数据里。
在Pandas中实现这套混合抽样,我封装了一个
smart_sample()
函数。它接收DataFrame和一个配置字典(指定哪些字段做分层、哪些做边界、哪些做时序),内部自动调用
value_counts()
、
quantile()
、
nlargest()
等方法,最后用
pd.concat()
合并所有子样本,并去重。实测下来,一个1000万行的表,用这个函数生成1500行的高质量样本,耗时不到3秒,而它暴露的问题,远超一个10000行的纯随机样本。
注意:样本探查的终极目标,不是为了“看到数据”,而是为了“提出更好的问题”。每看到一个异常值,都要立刻问:这个值是脏数据?是业务新规则?还是上游系统的一个Bug?把问题记下来,带着它去和上游负责人对齐,这才是探查的价值所在。
4. 实操过程与核心环节实现:从零开始构建你的探查流水线
4.1 第一步:搭建基础探查环境与数据接入
一切始于一个干净、可复现的环境。我强烈建议放弃在本地Jupyter Notebook里零敲碎打,而是用 Docker + Poetry 构建一个隔离的探查环境。原因很简单:数据探查不是一次性的,它需要版本化、可分享、可复现。今天你用Pandas 1.5.3跑通的脚本,明天同事用1.4.0可能就报错;你本地连的是测试库,同事连的是生产库,结果自然不同。Docker保证了运行时环境的一致性,Poetry则管理了Python依赖的精确版本。
我的标准
Dockerfile
非常精简:
FROM python:3.9-slim
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN pip install poetry && poetry install --no-root
COPY . .
CMD ["poetry", "run", "python", "main.py"]
对应的
pyproject.toml
里,核心依赖只有三项:
pandas = "^1.5.3"
、
great-expectations = "^0.17.12"
、
sqlalchemy = "^1.4.46"
。版本号都锁死,杜绝了“在我机器上是好的”这类甩锅。
数据接入是第二道关卡。我坚持一个原则:
绝不硬编码数据库连接信息
。所有连接参数(host, port, username, password, database)都通过环境变量注入。在
main.py
里,我用
os.getenv()
读取,并构造SQLAlchemy的
create_engine
。这样,同一个镜像,可以无缝切换测试、预发、生产环境,只需改变启动时的
-e
参数。对于敏感的密码,我使用Docker的
--secret
机制,或者让团队统一使用Vault这类密钥管理服务。
接入完成后,第一行代码永远是:
df = pd.read_sql("SELECT * FROM your_table LIMIT 10;", engine)
print(df.info())
这不是为了看数据,而是为了
确认连接通、权限够、基础Schema能读
。
df.info()
输出的
non-null count
和
dtypes
,就是元数据探查的第一份战报。如果这里就报错,说明问题出在基础设施层,不用往下走了。
4.2 第二步:执行元数据探查,生成第一份“数据健康报告”
元数据探查的输出,必须是一份人机可读的报告。我用Pandas的
describe()
方法是远远不够的,因为它只对数值列有效,对字符串、时间列就束手无策。因此,我写了一个
generate_metadata_report()
函数,它会对DataFrame的每一列,计算并汇总以下12个关键指标:
| 字段名 | 指标类型 | 计算逻辑 | 业务意义 |
|---|---|---|---|
column_name
| 字符串 | 列名 | 基础标识 |
dtype
| 字符串 |
df[col].dtype
| 类型是否符合预期 |
null_ratio
| 浮点数 |
df[col].isnull().mean()
| 缺失率,>5%需警惕 |
unique_count
| 整数 |
df[col].nunique()
| 去重数,判断字段粒度 |
unique_ratio
| 浮点数 |
unique_count / len(df)
| 唯一性比例,<0.01可能是标签 |
min_value
| 任意 |
df[col].min()
| 数值/时间下界 |
max_value
| 任意 |
df[col].max()
| 数值/时间上界 |
mode_value
| 任意 |
df[col].mode().iloc[0] if not df[col].mode().empty else None
| 众数,高频值 |
mode_ratio
| 浮点数 |
(df[col] == mode_value).mean()
| 众数占比,>95%可能是默认值 |
length_min
| 整数 |
df[col].str.len().min()
(仅字符串)
| 字符串最小长度 |
length_max
| 整数 |
df[col].str.len().max()
(仅字符串)
| 字符串最大长度 |
sample_values
| 字符串 |
df[col].dropna().sample(3).tolist()
| 三个随机非空样本值 |
这个函数的输出是一个
pd.DataFrame
,然后我用
df.to_html("metadata_report.html", index=False)
生成一个美观的HTML报告。报告里,我会用CSS样式对
null_ratio > 0.1
的行标红,对
unique_ratio < 0.001
的行标黄。这份报告,就是我和业务方开会时的第一张PPT,它用数据说话,而不是靠嘴说“我觉得这个字段可能有问题”。
4.3 第三步:运行样本探查,用统计学“照妖镜”揪出异常
样本探查的代码,核心在于那个
smart_sample()
函数。下面是我经过多次迭代后的精简版实现:
def smart_sample(df: pd.DataFrame, config: dict, n_total: int = 1000) -> pd.DataFrame:
"""
混合抽样:分层 + 边界 + 时序
config: {
"stratify": ["category", "region"], # 分层字段列表
"boundary": ["price", "score"], # 边界字段列表
"temporal": ["create_time"] # 时序字段列表
}
"""
samples = []
# 1. 分层抽样
if config.get("stratify"):
for col in config["stratify"]:
if col in df.columns:
# 按该列值计数,计算各组应抽数量
counts = df[col].value_counts(normalize=True)
for val, ratio in counts.items():
n_needed = max(1, int(n_total * ratio * 0.5)) # 分层占总样本50%
subset = df[df[col] == val]
if len(subset) >= n_needed:
samples.append(subset.sample(n=n_needed, random_state=42))
else:
samples.append(subset) # 全部抽
# 2. 边界抽样
if config.get("boundary"):
for col in config["boundary"]:
if col in df.columns and pd.api.types.is_numeric_dtype(df[col]):
# 抽取 min, max, 1st & 99th percentile 附近的5条
for quantile_val in [0.0, 0.01, 0.99, 1.0]:
try:
target = df[col].quantile(quantile_val)
# 找最接近target的5条记录
closest = df.iloc[(df[col] - target).abs().argsort()[:5]]
samples.append(closest)
except:
pass
# 3. 时序抽样(简化版:抽最早、最晚、中位数时间点的记录)
if config.get("temporal"):
for col in config["temporal"]:
if col in df.columns and pd.api.types.is_datetime64_any_dtype(df[col]):
times = df[col].dropna()
if len(times) > 0:
earliest = times.min()
latest = times.max()
median_time = times.quantile(0.5)
for t in [earliest, latest, median_time]:
closest = df.iloc[(df[col] - t).abs().argsort()[:3]]
samples.append(closest)
# 合并、去重、截断
if samples:
final_sample = pd.concat(samples, ignore_index=True).drop_duplicates()
return final_sample.head(n_total)
else:
return df.sample(n=min(n_total, len(df)), random_state=42)
调用它非常简单:
sample_df = smart_sample(
df=df,
config={
"stratify": ["user_type", "device_type"],
"boundary": ["revenue", "session_duration"],
"temporal": ["event_time"]
},
n_total=1500
)
sample_df.to_csv("data_sample.csv", index=False)
生成的
data_sample.csv
,就是我们接下来所有深度分析的“弹药”。我会把它导入到一个专用的探查Notebook里,对每一个高风险字段,手动运行分布图、箱线图、时间序列图。比如,对
revenue
字段,我会画一个
seaborn.histplot()
,并叠加一条竖线标出
0
值的位置——因为收入为0,可能意味着免费试用、活动补贴,也可能是数据采集失败的标志。这个过程没有银弹,它考验的是分析师的业务敏感度和对数据的“手感”。
4.4 第四步:用Great Expectations定义并运行数据质量断言
这是将探查成果固化的关键一步。我不会把GE的Expectation Suite写成一个巨大的JSON文件,而是用Python代码来定义,这样逻辑更清晰,也便于单元测试。以下是一个典型的
expectations.py
文件示例:
import great_expectations as ge
from great_expectations.core import ExpectationSuite
from great_expectations.core.expectation_configuration import ExpectationConfiguration
def create_user_table_suite() -> ExpectationSuite:
suite = ExpectationSuite(
expectation_suite_name="user_table_suite"
)
# 1. 基础完整性检查
suite.add_expectation(
ExpectationConfiguration(
expectation_type="expect_table_row_count_to_be_between",
kwargs={"min_value": 10000, "max_value": 10000000}
)
)
# 2. 关键字段非空检查
for col in ["user_id", "register_time", "country_code"]:
suite.add_expectation(
ExpectationConfiguration(
expectation_type="expect_column_values_to_not_be_null",
kwargs={"column": col}
)
)
# 3. 业务规则检查
suite.add_expectation(
ExpectationConfiguration(
expectation_type="expect_column_values_to_be_between",
kwargs={"column": "age", "min_value": 0, "max_value": 150}
)
)
suite.add_expectation(
ExpectationConfiguration(
expectation_type="expect_column_values_to_be_in_set",
kwargs={"column": "gender", "value_set": ["M", "F", "O", "U"]}
)
)
suite.add_expectation(
ExpectationConfiguration(
expectation_type="expect_column_value_lengths_to_be_between",
kwargs={"column": "user_id", "min_value": 16, "max_value": 16}
)
)
# 4. 时序合理性检查
suite.add_expectation(
ExpectationConfiguration(
expectation_type="expect_column_max_to_be_between",
kwargs={"column": "register_time", "min_value": "2020-01-01", "max_value": "2030-01-01"}
)
)
return suite
# 在 main.py 中调用
context = ge.data_context.DataContext()
suite = create_user_table_suite()
validator = context.get_validator(
batch_request={"datasource_name": "my_db", "data_connector_name": "default_inferred_data_connector_name", "data_asset_name": "users"},
expectation_suite=suite
)
results = validator.validate()
这段代码的意义,远不止于“跑个测试”。它把业务规则(“年龄必须在0-150之间”)转化成了可执行、可审计、可追踪的代码。当
results
返回
success=False
时,GE会给出详细的失败报告,精确到哪一行、哪个值违反了哪条规则。更重要的是,这份
expectations.py
文件,可以像业务代码一样,提交到Git仓库,接受Code Review。当业务规则变更时,修改的不是口头约定,而是这行代码,PR里自动触发CI,确保新规则被所有下游消费方知晓并适配。这就是数据治理从“人治”走向“法治”的一小步。
5. 常见问题与排查技巧实录:那些踩过的坑和省下的时间
5.1 “数据看起来都对,但业务指标就是不准”——如何定位“幽灵漂移”?
这是最让人抓狂的问题。所有单字段的探查都通过了:
user_id
不为空、
order_amount
在合理范围内、
order_time
时间戳也正常。但当你把
SUM(order_amount)
按天聚合,画出的曲线却和业务方提供的GMV报表对不上,误差在5%-10%之间,且每天都不一样。我称之为“幽灵漂移”,因为它没有明显的错误日志,却顽固地存在。
我的排查路径是“三步剥洋葱”:
第一步:锁定漂移发生的最小时间粒度
。不是看“今天 vs 昨天”,而是看“今天每个小时 vs 昨天每个小时”。用
GROUP BY DATE_TRUNC('hour', order_time)
,把数据切成60分钟一个桶。通常,漂移不会均匀分布在24小时里,而是集中在某个特定时段,比如“每天上午10:00-10:15”。这个时间段,往往是上游系统进行定时批处理、数据同步或缓存刷新的窗口。一旦锁定这个时间点,问题就从“数据哪里错了”变成了“哪个系统在这个时间点做了什么”。
第二步:对比“原始数据”与“加工后数据”的差异
。很多漂移,源于ETL过程中的隐式转换。比如,上游传来的
order_time
是
STRING
类型,格式为
"2023-10-01 10:05:30.123"
,而ETL脚本用
TO_TIMESTAMP(col, 'YYYY-MM-DD HH24:MI:SS')
解析,自动截断了毫秒部分。这本身没错,但如果下游的“订单创建成功”事件,是用毫秒级时间戳打点的,而“订单支付成功”事件,是用秒级时间戳打点的,那么在按秒聚合时,这两个事件就可能被分到不同的秒桶里,导致“支付成功率”计算失真。解决方法是:在探查时,对所有时间字段,强制用
pd.to_datetime()
并指定
exact=True
,然后检查
dt.nanosecond
是否全为0。如果不是,就要和上游确认,他们提供的时间精度到底是多少。
第三步:检查“数据血缘”中的隐式Join
。这是最高频的坑。一个报表指标,表面看只依赖A表,但实际上,它的SQL里有一个
LEFT JOIN B ON A.user_id = B.user_id
。而B表的
user_id
字段,有约2%的记录是
NULL
,或者格式不一致(比如A表是
123456789
,B表是
'123456789 '
)。这个
LEFT JOIN
在绝大多数情况下是成功的,但在
user_id
为NULL或格式不一致的那2%记录上,会变成
NULL
,导致A表的这部分订单,在最终报表里被“意外过滤”了。排查方法很简单:在探查A表时,额外执行
SELECT COUNT(*) FROM A WHERE user_id IS NULL OR user_id LIKE '% %';
,再执行
SELECT COUNT(*) FROM B WHERE user_id IS NULL OR user_id LIKE '% %';
,对比两个数字。如果A表有1000条脏数据,B表有0条,那就说明Join是安全的;如果B表也有1000条,那就要警惕了。
实操心得:每当遇到“幽灵漂移”,我都会在团队共享文档里新建一个“漂移根因库”,把这次的时间点、SQL片段、上游系统名称、根本原因、解决方案,全部记录下来。一年下来,这个库积累了47个案例,新同学入职第一周,不是看手册,而是读这个库。它比任何培训都管用。
5.2 “样本里没发现问题,但全量跑就OOM”——如何安全地进行全量探查?
当元数据和样本层都“绿灯”时,下一步就是全量探查。但直接
df = pd.read_sql("SELECT * FROM huge_table", engine)
,等待你的大概率是内存溢出(OOM)。我总结了一套“安全全量探查四象限法”,根据数据规模和问题类型,选择最合适的策略:
| 问题类型 | 数据规模 < 100万行 | 数据规模 100万 - 1亿行 | 数据规模 > 1亿行 |
|---|---|---|---|
检查字段分布(如
COUNT DISTINCT
)
|
直接
df[col].nunique()
|
用
df[col].value_counts(dropna=False).shape[0]
|
改用
approx_count_distinct()
(Spark/Trino)或HyperLogLog算法
|
检查数值范围(如
MIN/MAX
)
|
直接
df[col].min()/max()
|
用
df[col].agg(['min', 'max'])
|
改用
SELECT MIN(col), MAX(col) FROM table
(数据库原生聚合)
|
检查记录唯一性(如
user_id
是否重复)
|
df.duplicated(subset=['user_id']).sum()
|
用
df.groupby('user_id').size().gt(1).sum()
|
改用
SELECT COUNT(*) FROM (SELECT user_id, COUNT(*) c FROM table GROUP BY user_id HAVING c > 1)
|
| 检查复杂业务逻辑(如“用户首单是否在注册后24小时内”) |
写Pandas逻辑,
df['first_order_time'] - df['register_time'] < pd.Timedelta('24H')
|
用
dask.dataframe
替代
pandas
,代码几乎不用改
| 必须下推到数据库,写SQL,利用数据库的并行计算能力 |
核心思想只有一个:
把计算尽可能下推到数据源
。数据库(尤其是现代OLAP引擎如ClickHouse、Trino)在处理聚合、过滤、Join时,效率远高于Python。我的原则是:只要数据库能做的,就绝不让Python做。
read_sql()
的
chunksize
参数,是另一个救命稻草。它可以将大查询拆分成多个小批次,逐批处理:
chunk_iter = pd.read_sql("SELECT user_id, order_amount, order_time FROM orders", engine, chunksize=50000)
total_rows = 0
for chunk in chunk_iter:
total_rows += len(chunk)
# 对每个chunk做轻量级检查,比如检查order_amount是否为负
negative_count = (chunk['order_amount'] < 0).sum()
if negative_count > 0:
print(f"Found {negative_count} negative amounts in chunk")
# 记录这批chunk的索引,用于后续精查
这样,即使总数据量是10亿行,内存里也只驻留5万行,压力可控。
5.3 “上游说数据没问题,但我们就是跑不通”——如何高效地与上游对齐?
数据探查的终点,从来不是一份报告,而是与上游达成共识。但现实中,沟通常常陷入“鸡同鸭讲”

3588

被折叠的 条评论
为什么被折叠?



