从零搭建数据清洗 API:SimHash 模糊去重 + FastAPI + Supabase 全栈实践

12 阅读4分钟

前言

做数据开发的同学应该都遇到过这些问题:

  • 一批用户数据里有重复,精确去重不够,"张三" 和 "张 三" 算不算同一个人?
  • 手机号格式五花八门: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 RedisServerless Redis,按量付费
支付LemonSqueezy支持 PayPal,对中国开发者友好
部署RenderDocker 部署,免费 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 无框架。

欢迎体验和反馈!