前言
做数据开发的同学应该都遇到过这些问题:
- 一批用户数据里有重复,精确去重不够,"张三" 和 "张 三" 算不算同一个人?
- 手机号格式五花八门:138-xxxx-xxxx、+86 138 xxxx xxxx、0138xxxxxxxx
- 邮箱看着像真的,发出去全退信
我做一个数据清洗 API 来解决这些问题。这篇文章分享完整的实现方案,包括去重算法、技术选型和踩过的坑。
一、产品功能
API 提供三个核心端点:
POST /v1/dedup # 去重(精确 + 模糊)
POST /v1/standardize # 标准化(手机号/邮箱/地址)
POST /v1/clean # 全流程(去重 + 标准化 + 验证)
还有用户系统(注册/登录)、API Key 管理、用量统计、支付。
二、去重算法设计
这是核心。我用了两层去重方案:
第一层:MD5 精确去重
对用户指定的字段组合做 MD5 hash:
import hashlib
def exact_hash(record, fields):
"""对指定字段组合做 MD5"""
values = [str(record.get(f, '')).strip().lower() for f in fields]
combined = '|'.join(values)
return hashlib.md5(combined.encode()).hexdigest()
完全相同的记录 -> hash 一样 -> 判定为重复。
这层 O(n) 走完,干掉所有完全重复的记录。但对于 "张三" 和 "张 三" 这种,MD5 会认为不同(空格不同)。
第二层:SimHash 模糊去重
对第一层没去掉的记录,做 SimHash 计算:
from simhash import Simhash
def compute_simhash(record, fields):
"""对记录计算 SimHash 值"""
text = ' '.join(str(record.get(f, '')) for f in fields)
return Simhash(text.split())
SimHash 的核心思想是:把文本拆成特征(这里是词),每个特征 hash 后按位投票,最终得到一个 64 位的指纹。两个文本越相似,指纹的相同位数越多。
比较两个 SimHash 的海明距离(不同位数):
def hamming_distance(a, b):
"""计算两个 SimHash 的海明距离"""
x = (a ^ b) & ((1 << 64) - 1)
return bin(x).count('1')
策略:
- 海明距离 <= 3 -> 疑似重复,进入第三步
- 海明距离 > 3 -> 不重复
第三步对疑似重复的记录对走 Levenshtein 编辑距离:
import Levenshtein
def similarity(a, b):
"""计算两个字符串的相似度"""
max_len = max(len(a), len(b))
if max_len == 0:
return 1.0
return 1 - Levenshtein.distance(a, b) / max_len
相似度 > 阈值(默认 0.85)-> 最终判定为重复。
为什么不直接全量走 Levenshtein?
Levenshtein 是 O(n^2) 的,10000 条数据要做 50,000,000 次比较。SimHash 先过滤掉 99% 不可能重复的,只有极少量的进入 Levenshtein 精确比较。
实测 10,000 条数据:
- 全量 Levenshtein:~120 秒
- 两层方案:~2 秒
三、技术选型
| 组件 | 选型 | 原因 |
|---|---|---|
| Web 框架 | FastAPI | 自带 OpenAPI 文档,异步支持好 |
| 数据库 | Supabase | 托管 PostgreSQL + REST API,省运维 |
| 限流 | Upstash Redis | Serverless Redis,按量付费 |
| 支付 | LemonSqueezy | 支持 PayPal,对中国开发者友好 |
| 部署 | Render | Docker 部署,免费 tier 够用 |
四、API 鉴权设计
两层鉴权:
1. Auth Token(用户操作)
- 注册/登录后返回 48 位 token
- 存 users 表的 auth_token 字段
- 前端用 Authorization: Bearer {token} 访问 Dashboard
2. API Key(接口调用)
- 用户在 Dashboard 创建 API Key
- Key 格式:dk_live_{32位随机字符串}
- 存储:只存 SHA256 hash,明文只在创建时返回一次
- 调用时用 X-API-Key 头
# API Key 鉴权中间件
async def api_key_auth(request: Request, call_next):
api_key = request.headers.get("X-API-Key")
if not api_key:
return JSONResponse({"detail": "缺少 API Key"}, 401)
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
db = get_db()
result = db.table("api_keys") \
.select("*, users(*)") \
.eq("key_hash", key_hash) \
.eq("is_active", True) \
.execute()
if not result.data:
return JSONResponse({"detail": "无效的 API Key"}, 401)
request.state.user = result.data[0]["users"]
return await call_next(request)
五、Redis 限流设计
两层限流:QPS(每秒请求数)+ 日配额(每天调用次数)
# QPS 限流
qps_key = f"qps:{user_id}"
current = await redis.incr(qps_key)
if current == 1:
await redis.expire(qps_key, 1)
if current > qps_limit:
return JSONResponse({"detail": "超过 QPS 限制"}, 429)
# 日配额限流
daily_key = f"daily:{user_id}:{date.today()}"
count = await redis.incr(daily_key)
if count == 1:
await redis.expire(daily_key, 86400)
if count > daily_limit:
return JSONResponse({"detail": "超过日配额"}, 429)
六、踩过的坑
1. Supabase 连接报 Invalid API Key
Supabase 有两种 Key:anon key 和 service_role key。代码里要用 service_role key 才能绕过 RLS(行级安全)策略。
2. GitHub Push Protection 阻止提交
环境变量里有 JWT 格式的密钥,GitHub 检测到后阻止推送。解决方案:base64 编码密钥,在 Docker entrypoint 里解码。
3. Docker entrypoint CRLF 问题
Windows 开发的 shell 脚本是 CRLF 行尾,Linux 容器里无法执行。Dockerfile 里加:
RUN sed -i 's/\r$//' docker-entrypoint.sh && chmod +x docker-entrypoint.sh
4. Render 环境变量末尾有换行符
在 Render 后台粘贴 API Key 时,末尾带了换行符,导致 HTTP 头非法。代码里加 .strip() 解决。
七、效果
线上 Demo:dataclean-x4jc.onrender.com GitHub:github.com/lbl1988/Dat…
免费额度 1,000 次调用,Pro 包 49 元 / 10,000 次,按量付费不过期。
技术栈:FastAPI + Supabase + Redis + LemonSqueezy,部署在 Render 上,前端控制台是原生 JS 无框架。
欢迎体验和反馈!