
上周末我用不到 10 美元的成本在一个周末的时间里实现了一个可检索 50 万条域名的轻量搜索引擎。整个项目没有使用 Elasticsearch、Solr 这类重型中间件只依赖 Python 自带的 SQLite 就在一台最低配的云主机上跑了起来。本文完整复盘这个项目的需求拆解、索引设计、查询优化、部署成本和踩坑记录既包含可直接运行的代码也梳理了域名搜索场景里最常遇到的性能与异常问题适合想自己搭建轻量搜索服务、做数据分析类小工具的开发者参考。1. 项目背景为什么需要“域名搜索引擎”域名搜索不是指在浏览器地址栏里输入网址而是指对一个预先收集的域名数据集做快速检索与统计分析。比如独立开发者想做一个小工具需要找到一个还没被注册的短域名做安全巡检的工程师需要从证书透明日志或公开数据中筛选某个企业相关的子域名做品牌保护的运营同学想知道相似域名是否已被他人注册做市场调研的分析师希望按 TLD顶级域统计某个地区的域名分布。这些场景都有一个共同点数据量不在少数少则几万条多则几十万上百万条。如果直接用 Excel 打开或者用程序遍历列表做模糊匹配速度和体验都很难接受。于是就有了“轻量级域名搜索引擎”的用武之地。为什么强调轻量因为大多数场景并不需要 10 亿级的索引也不需要复杂的分布式集群。50 万条域名数据如果设计得当只需要一个几十 MB 的 SQLite 数据库文件就能在一台 1 核 1GB 内存的廉价 VPS 上提供百毫秒级搜索响应。这个体量用传统关系型数据库加全文索引完全可以覆盖真正需要思考的是索引结构、分词方式和查询逻辑。从本质上说这类项目是一个典型的“数据检索系统”数据获取从公开渠道收集域名样本。数据清洗去重、过滤非法域名、格式标准化。索引构建建立能够快速定位的数据结构。查询服务对外提供 HTTP 接口或 Web 页面。本文接下来的章节会围绕这几个环节把完整实现方案拆开讲。2. 需求拆解与总体技术方案动手写代码之前先明确我们要做哪些功能以及为什么选 SQLite 而不是更重型的搜索引擎。2.1 功能需求一个 50 万条规模的域名搜索引擎至少需要支持以下查询能力关键字搜索用户输入example能返回包含该关键字的域名。精确匹配用户输入example.com能优先返回该完整域名。前缀匹配用户输入exam能返回以exam开头的域名这个场景类似自动补全。后缀/TLD 过滤用户输入com开头的域名或者按country过滤例如只查看.io域名。分页搜索结果数量可能很多必须支持page和page_size。排序按相关性排序而不是简单地按数据库默认顺序。另外还有一个很重要的细节域名结构是倒树状的www.example.com中最具标识性的是example但按 TLD 来筛选时com也很关键。因此索引里通常会同时保存原始域名和反转标签后的域名www.example.com转成com.example.www这样既支持正序搜索也支持按 TLD 反向过滤。2.2 技术选型为什么用 SQLite FTS5搜索类项目很多时候第一反应是上 Elasticsearch。但对于 50 万条数据Elasticsearch 属于“杀鸡用牛刀”。它带来的问题是JVM 内存占用高、部署复杂、运维成本大。一个周末 10 美元的预算显然不适合这种重方案。SQLite 自带全文搜索扩展 FTS5优势非常明显零额外依赖Python 3 标准库自带sqlite3只要编译版本的 SQLite 开启了 FTS5就不需要单独安装服务。数据文件单一整个索引就是一个.db文件备份、迁移都很方便。查询速度快几十万行数据做全文检索响应时间通常在几十到几百毫秒级别。支持 BM25 排序FTS5 内置相关性打分机制不需要自己实现 TF-IDF。部署成本极低一个 1 核 1GB 内存的小机器就能稳定运行。如果项目规模未来增长到千万级再考虑迁移到 Meilisearch、Typesense 或 Elasticsearch 也不迟。从轻量到重型演进路径是平滑的。2.3 整体架构整个系统分为三层数据层SQLite 数据库里面包含普通表domains、全文索引表domains_fts以及可选的三元组索引表domain_trigrams。服务层使用 FastAPI 提供 HTTP 查询接口接收q参数解析后查询 SQLite。展示层一个极简 HTML 页面提供搜索框和结果列表。数据构建与查询服务分离。构建索引时用写模式打开数据库搜索服务以只读模式打开避免锁冲突。3. 环境准备与数据预处理在写核心搜索逻辑之前先准备好环境、数据库结构和演示数据。3.1 环境准备本文示例基于以下环境操作系统Ubuntu 22.04 LTS其他 Linux 发行版或 macOS 也可。Python3.10。SQLite3.35需要支持 FTS5如果版本过低请先升级或更换系统自带 SQLite。Web 框架FastAPI Uvicorn。安装 FastAPI 和 Uvicornpip install fastapi uvicorn如果网络环境特殊可以考虑使用国内 PyPI 镜像pip install fastapi uvicorn -i https://pypi.tuna.tsinghua.edu.cn/simple3.2 域名数据从哪来真实项目里50 万条域名数据通常来自以下渠道证书透明日志Certificate Transparency简称 CTGoogle 等机构维护的公开日志记录了签发过的数字证书从中可以提取大量域名和子域名公开的 DNS 数据集例如某些安全研究机构定期公布的被动 DNS 数据域名 zone 文件部分顶级域允许申请授权后下载完整的 zone 文件Common Crawl 等网页爬虫数据集URL 列表里天然包含大量域名。这些数据源的获取方式各不相同有些需要申请 API Key有些需要下载几十 GB 的压缩包。为了让你能最快地跑通整个流程本文直接用脚本生成一份 50 万条模拟域名数据字段格式与真实数据一致重点演示索引构建和查询服务的实现思路。生产环境只需要把“模拟数据”替换成你从 CT 日志或公开数据集里提取的真实域名即可。3.3 数据清洗规则域名数据清洗是一个容易踩坑的环节。常见问题包括大小写不统一、包含空格或不可见字符、重复域名、危险字符、国际化域名IDN编码混乱、末尾带点号等。清洗函数如下import re import unicodedata def clean_domain(raw: str) - str | None: if not raw: return None # 去掉 URL 前缀、末尾斜杠等 raw raw.strip().lower() raw re.sub(r^[a-zA-Z]://, , raw) raw raw.split(/)[0] raw raw.split(?)[0] raw raw.split(#)[0] raw raw.rstrip(.) # 只保留字母、数字、点、中划线 if not re.fullmatch(r[a-z0-9.\-], raw): return None # 去除连续点和首尾点 raw re.sub(r\., ., raw) raw raw.strip(.) if not raw: return None # 简单校验必须至少包含一个点 if . not in raw: return None # 每个标签长度不能超过 63总长度不超过 253 labels raw.split(.) if any(len(label) 63 for label in labels): return None if len(raw) 253: return None return raw这里有几个设计细节值得注意统一转小写可以避免Example.com和example.com被当成两条数据。去掉 URL Scheme 和路径是因为数据源里的域名经常混在 URL 中。限制域名每段长度是因为 DNS 标准规定标签长度不能超过 63 字符。至少包含一个点是为了过滤掉localhost这类单标签字符串。3.4 生成演示数据集下面生成 50 万条模拟域名字段包括域名本身、TLD、来源标记和创建时间。演示代码只保证格式正确不保证域名真实存在。import random import sqlite3 import time random.seed(42) word_pool [ apple, banana, cherry, data, edge, flow, gate, hub, index, jump, kernel, logic, maker, node, open, pulse, query, route, stack, trace, unity, vision, wave, xeno, yield, zen, api, cloud, dev, git, log, map ] tld_pool [com, io, net, org, cn, dev, xyz, me, co, app] def random_domain(): label_count random.randint(2, 4) labels [] for _ in range(label_count): word random.choice(word_pool) if random.random() 0.2: word str(random.randint(0, 99)) labels.append(word) tld random.choice(tld_pool) return ..join(labels) . tld这个生成器看起来比较粗糙但用来验证索引和查询逻辑已经足够。真实的 50 万条数据建议按来源文件批量读取再调用清洗函数。4. 索引设计与核心代码实现索引设计是整个项目最核心的部分。这里的思路是创建一张基础表保存域名元数据再创建一张 FTS5 虚拟表保存搜索索引。4.1 数据库表结构执行以下 SQL 建表CREATE TABLE IF NOT EXISTS domains ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL UNIQUE, tld TEXT, source TEXT DEFAULT unknown, created_at TEXT ); CREATE VIRTUAL TABLE IF NOT EXISTS domains_fts USING fts5( domain, reversed_domain, domain_raw UNINDEXED, tld UNINDEXED, source UNINDEXED, created_at UNINDEXED );解释一下 FTS5 表里的各字段domain参与全文索引的原始域名例如www.example.com。reversed_domain反转标签后的域名例如com.example.www。它参与索引是为了支持“按 TLD 搜索”的场景。比如查询com时FTS5 会在反转域名里匹配大量.com域名。domain_raw UNINDEXED索引字段在 FTS5 内部不一定能原样还原所以额外存一份原始值便于查询后直接展示。tld、source、created_at作为元数据存储不参与分词索引。多个字段都放在同一张 FTS5 虚拟表里好处是查询时可以直接SELECT domain_raw, tld FROM domains_fts不需要再关联基础表。4.2 构建索引脚本创建数据库并写入 50 万条数据的脚本如下import random import sqlite3 import time DB_PATH domains.db def init_db(conn: sqlite3.Connection): conn.executescript( CREATE TABLE IF NOT EXISTS domains ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL UNIQUE, tld TEXT, source TEXT DEFAULT unknown, created_at TEXT ); CREATE VIRTUAL TABLE IF NOT EXISTS domains_fts USING fts5( domain, reversed_domain, domain_raw UNINDEXED, tld UNINDEXED, source UNINDEXED, created_at UNINDEXED ); ) conn.commit() def add_domain(conn: sqlite3.Connection, domain: str, source: str mock): from datetime import datetime labels domain.split(.) tld labels[-1] if len(labels) 1 else reversed_domain ..join(reversed(labels)) created_at datetime.utcnow().isoformat() cur conn.execute( INSERT OR IGNORE INTO domains(domain, tld, source, created_at) VALUES (?,?,?,?), (domain, tld, source, created_at) ) if cur.rowcount 0: return False rowid cur.lastrowid conn.execute( INSERT INTO domains_fts(rowid, domain, reversed_domain, domain_raw, tld, source, created_at) VALUES (?,?,?,?,?,?,?), (rowid, domain, reversed_domain, domain, tld, source, created_at) ) return True def build_index(): conn sqlite3.connect(DB_PATH) init_db(conn) start time.time() for i in range(500000): domain random_domain() add_domain(conn, domain, sourcemock) if i % 50000 0: conn.commit() print(fprocessed {i}, cost {time.time() - start:.1f}s) conn.commit() conn.close() print(build done) if __name__ __main__: build_index()这段代码中有一个细节先插入基础表domains拿到自增rowid再插入 FTS5 表而不是插入时直接把整个域名写进虚拟表。这样可以保证两表数据一致即使后续需要关联基础表做更新统计也能通过rowid找到对应记录。在 50 万条数据的规模下这个建索引脚本在我的测试环境里大概耗时 2 到 4 分钟最终生成的数据库文件在 50MB 左右完全符合轻量级的要求。4.3 查询服务FastAPI 接口核心搜索逻辑放在 一个search_domains函数里。先支持最基本的关键字搜索from fastapi import FastAPI, Query import sqlite3 DB_PATH domains.db app FastAPI(titleDomain Search API) def search(conn: sqlite3.Connection, q: str, page: int 1, page_size: int 20): if not q: return [] # 将查询关键字转成 FTS5 的 MATCH 表达式 q q.strip().lower() # 简单处理去掉特殊字符避免破坏 FTS 语法 safe_q .join([part for part in q.replace(., ).split() if part]) if not safe_q: return [] offset (page - 1) * page_size sql SELECT domain_raw, tld, source, created_at, bm25(domains_fts) AS rank FROM domains_fts WHERE domains_fts MATCH ? ORDER BY rank LIMIT ? OFFSET ? params [safe_q, page_size, offset] try: cur conn.execute(sql, params) rows cur.fetchall() return [dict(r) for r in rows] except sqlite3.OperationalError: # MATCH 语法可能被用户输入破坏这里先返回空 return [] app.get(/search) def search_api( q: str Query(..., description搜索关键字), page: int Query(1, ge1), page_size: int Query(20, ge1, le100) ): conn sqlite3.connect(ffile:{DB_PATH}?modero, uriTrue) try: results search(conn, q, page, page_size) return {query: q, page: page, results: results} finally: conn.close()这里值得注意的有两点一是连接数据库时使用了只读模式sqlite3.connect(ffile:{DB_PATH}?modero, uriTrue)因为搜索服务只需要读数据只读模式可以避免查询线程长时间占用写锁也不会因为误操作把索引弄坏。二是 MATCH 查询前做了一个简单的清洗。FTS5 的 MATCH 语法支持AND、OR、NOT、双引号短语、*前缀匹配等。如果直接把用户输入原样拼进MATCH可能引发语法异常或者让用户误用高级语法。这里简化处理成空格分隔的空白字符之后如果需要支持高级语法可以再单独设计一套解析器。4.4 精确匹配与前缀匹配优化FTS5 的 BM25 排序已经能处理大多数场景但在用户输入完整域名时我们更希望精确匹配的记录排在最前面。因此排序不能只看bm25还要结合“匹配类型”来加权。改进查询逻辑def search(conn: sqlite3.Connection, q: str, page: int 1, page_size: int 20): if not q: return [] q q.strip().lower() if not q: return [] safe_q .join([part for part in q.replace(., ).split() if part]) if not safe_q: return [] offset (page - 1) * page_size sql SELECT domain_raw, tld, source, created_at, CASE WHEN domain_raw ? THEN 0 WHEN domain_raw LIKE ? THEN 1 ELSE 2 END AS match_priority, bm25(domains_fts) AS rank FROM domains_fts WHERE domains_fts MATCH ? ORDER BY match_priority ASC, rank ASC LIMIT ? OFFSET ? prefix q % params [q, prefix, safe_q, page_size, offset] cur conn.execute(sql, params) rows cur.fetchall() return [dict(r) for r in rows]这里增加了一个match_priority字段domain_raw q完全匹配优先级最高。domain_raw LIKE q%前缀匹配比如用户输入example.co希望看到以这个字符串开头的域名。其他普通全文匹配。需要说明的是LIKE条件虽然会做一次额外扫描但在 50 万条数据量级下配合 FTS5 先过滤出候选集再判定优先级性能依然可以接受。这比一开始就全表LIKE %q%高效得多。5. 通过反转域名与三元组索引支持复杂查询FTS5 默认的分词器对英文输入支持得不错但由于域名包含点号实际使用中还是会遇到两个明显不足用户非常习惯输入带点号的完整域名例如example.comFTS5 会把它拆成两个 tokenexample和com在分词逻辑上没问题但如果搜索ample.comFTS5 就无能为力因为ample不是完整 token。在 FTS5 中前缀通配符只能加在最后一个词后面不支持中间的任意子串匹配。比如我们想搜所有包含amp的域名example.com、camp.io、ample.dev用 FTS5 就很别扭。解决办法有两种预计算反转域名以及建立三元组索引。5.1 反转域名按 TLD 查询的利器前面建表时已经预留了reversed_domain字段。它的作用是把example.com变成com.example把foo.example.com变成com.example.foo。这样一来搜索“所有.com域名”就可以写成SELECT domain_raw, bm25(domains_fts) AS rank FROM domains_fts WHERE domains_fts MATCH com* ORDER BY rank LIMIT 20;搜索“所有以.example.com为后缀的子域名”可以写成SELECT domain_raw, bm25(domains_fts) AS rank FROM domains_fts WHERE domains_fts MATCH com example* ORDER BY rank LIMIT 20;这种写法非常适合安全人员枚举子域名或者市场分析师按 TLD 看数据分布。5.2 三元组子串索引三元组索引trigram index的思路非常朴素把每个域名拆成所有长度为 3 的连续字符片段建立倒排关系。比如example.com的三元组包括exa, xam, amp, mpl, ple, le., e.c, .co, com当用户搜索关键字amp时我们只需查amp对应的倒排列表再对结果做一次精确的LIKE校验就能找到所有包含amp的域名。这样可以避免对全表做LIKE %amp%扫描查询性能能提升几个数量级。建表 SQLCREATE TABLE IF NOT EXISTS domain_trigrams ( trigram TEXT NOT NULL, domain_id INTEGER NOT NULL, position INTEGER NOT NULL, PRIMARY KEY (trigram, domain_id, position) ); CREATE INDEX IF NOT EXISTS idx_trigram_lookup ON domain_trigrams(trigram);构建三元组索引的 Python 代码def build_trigrams(conn: sqlite3.Connection, batch_size: int 20000): conn.execute(DELETE FROM domain_trigrams) conn.commit() last_id 0 while True: rows conn.execute( SELECT id, domain FROM domains WHERE id ? ORDER BY id LIMIT ?, (last_id, batch_size) ).fetchall() if not rows: break data [] for domain_id, domain in rows: for pos in range(len(domain) - 2): trigram domain[pos:pos 3] data.append((trigram, domain_id, pos)) conn.executemany( INSERT OR IGNORE INTO domain_trigrams(trigram, domain_id, position) VALUES (?,?,?), data ) conn.commit() last_id rows[-1][0] print(fprocessed up to domain_id{last_id}, trigram_rows{len(data)})查询时把用户输入的关键字也拆成三元组def trigram_search(conn: sqlite3.Connection, q: str, limit: int 20): q q.strip().lower() if not q or len(q) 3: return [] trigrams [q[i:i3] for i in range(len(q) - 2)] placeholders ,.join([?] * len(trigrams)) sql f SELECT dt.domain_id, COUNT(*) AS match_count FROM domain_trigrams dt WHERE dt.trigram IN ({placeholders}) GROUP BY dt.domain_id HAVING match_count ? ORDER BY match_count DESC LIMIT ? rows conn.execute(sql, [*trigrams, len(trigrams), limit]).fetchall() if not rows: return [] ids [r[0] for r in rows] placeholders_ids ,.join([?] * len(ids)) domains conn.execute( fSELECT id, domain, tld, source FROM domains WHERE id IN ({placeholders_ids}), ids ).fetchall() return domains这个方案的核心是先用三元组交集缩小候选集再用原始关键字做一次确认。候选集通常比全表小几个数量级所以查询很快。缺点是三元组表会比较大50 万域名、平均每个域名 20 到 40 个三元组那么表里大概有 1000 万到 2000 万行。在 SQLite 中这个量级依然可以接受但需要注意磁盘空间和索引构建时间。如果不想引入这么复杂的索引可以直接退回到LIKE %q%扫描。50 万条数据的全表 LIKE 在 SQLite 中也只需要一两秒如果只是试验用途完全够用。生产环境建议在 FTS5 的前缀检索和 LIKE 兜底之间做取舍而不是强行上线完整的三元组方案。6. 前端搜索页面与部署后端接口写完以后可以补一个简单的前端页面让这个工具对非程序员朋友也可用。6.1 极简搜索页面在项目目录下新建static/index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title域名搜索引擎/title style body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 0 16px; } input { width: 100%; padding: 12px; font-size: 16px; box-sizing: border-box; } button { margin-top: 12px; padding: 10px 24px; font-size: 16px; cursor: pointer; } .result-item { padding: 12px; border-bottom: 1px solid #eee; } .domain-name { font-weight: bold; } .meta { color: #777; font-size: 13px; margin-top: 6px; } /style /head body h1域名搜索/h1 input idq typetext placeholder输入域名或关键字例如 example 或 example.com / button onclicksearch()搜索/button div idresults stylemargin-top: 20px;/div script async function search() { const q document.getElementById(q).value.trim(); if (!q) return; const resp await fetch(/search?q${encodeURIComponent(q)}); const data await resp.json(); const container document.getElementById(results); if (!data.results || data.results.length 0) { container.innerHTML p没有找到匹配的域名。/p; return; } container.innerHTML data.results.map(item div classresult-item div classdomain-name${item.domain_raw}/div div classmetaTLD: ${item.tld} | 来源: ${item.source} | 收录时间: ${item.created_at}/div /div ).join(); } document.getElementById(q).addEventListener(keydown, function(e) { if (e.key Enter) search(); }); /script /body /htmlFastAPI 中加入静态文件路径from fastapi.staticfiles import StaticFiles app.mount(/static, StaticFiles(directorystatic), namestatic)这样访问http://服务器IP:8000/static/index.html就能看到搜索页面。6.2 部署到云主机部署思路很简单一台最便宜的云主机安装 Python 环境把项目代码和数据库文件传上去用 Uvicorn 启动服务再用 Nginx 反向代理。启动命令uvicorn main:app --host 127.0.0.1 --port 8000Nginx 配置片段server { listen 80; server_name your-domain.com; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /search { # 限制搜索接口的频率防止被脚本批量抓取 limit_req zonesearch_limit burst10 nodelay; proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }limit_req配置需要在http块中定义共享内存区域例如limit_req_zone $binary_remote_addr zonesearch_limit:10m rate5r/s;这表示每个 IP 每秒最多 5 次请求超过会被 Nginx 拒绝。如果被拒绝Nginx 默认返回 503也可以通过limit_req_status 403;改成 403。这个细节在后面讲“domain forbidden”问题时还会提到。6.3 成本分析10 美元预算大概可以这样拆项目估算费用说明云主机约 5 美元/月选择 1 核 1GB 内存、20-30GB SSD 的最低配套餐域名0-2 美元/年如果使用 IP 访问或免费子域名则不需要其他杂项约 2-3 美元对象存储备份、DNS 解析等按量计费如果你用的是按量计费的云主机一个周末跑 500 万次查询也花不了几美元。如果只是周末跑一下跑通流程月底关停服务器总花费很容易控制在 10 美元以内。7. 常见问题与排查思路实际开发过程中我遇到并想到了一些高频问题这里整理成一个排查表。问题现象常见原因解决思路搜索无结果FTS5 分词器把带点号的域名拆碎导致MATCH语法或匹配逻辑和预期不一致先查SELECT * FROM domains_fts WHERE domains_fts MATCH example确认索引可用如果是子串搜索使用三元组索引或LIKE数据库一直提示database is lockedSQLite 写锁与读锁冲突或者多个线程同时写库开启 WAL 模式PRAGMA journal_modeWAL;搜索服务用只读模式连接写索引时单独跑脚本接口返回 500日志里是malformed MATCH expression用户输入包含 FTS5 保留字符如、*、AND等对输入做转义或清洗最保守的方式是只保留字母数字和点号某些域名在搜索里出现但实际访问提示code: 1004, error: domain forbidden域名已经过期、被注册局处置或者对方服务器禁止了当前来源的访问这类缓存数据不代表域名当前可用建议定期用 DNS 查询或 HTTP 探活更新状态字段并给结果加上“可能已失效”标记探测子域名时提示non-existent domain使用_ldap._tcp.dc._msdsc这类 SRV 记录做内网域名验证时当前环境本身没有相应域控记录这通常代表该记录不存在或当前域名不可解析不是程序 bug按空结果处理即可并记录到日志中便于后续分析50 万条数据构建索引慢逐条插入导致数据库事务开销过大使用executemany批量插入每 5 万条提交一次事务索引文件太大三元组索引和 FTS5 索引同时存在加上数据库页填充开销只保留 FTS5 和LIKE兜底方案三元组索引作为可选扩展搜索结果排序不符合预期BM25 对短文本排序不够直观例如example.com和example.io分数接近增加match_priority字段先按精确/前缀/模糊分类再按 BM25 排序被其他脚本批量抓取数据服务没有限制访问频率数据被恶意扒取启用 Nginx 限流配合 IP 黑名单搜索接口可以加简单 Token 认证8. 最佳实践与工程建议这个项目虽然小但涉及数据采集、索引构建、服务开发、部署运维等完整链路因此可以从几个维度总结工程经验。8.1 数据与索引管理来源字段一定要保留。域名的来源可能是 CT 日志、爬虫数据或用户上报不同来源的数据可信度不同。加了source字段后面做数据审计非常方便。数据更新采用“全量重建 增量补充”结合的方式。索引脚本每天晚上定时跑一次新建一个domains.db替换旧文件。50 万条数据从 0 构建只需要几分钟完全不需要在线更新复杂逻辑。备份别忘记。SQLite 数据库要备份的话不要直接复制文件建议用.backup命令或者定期用 Python 调sqlite3.Connection.backup()这样可以保证一致性。8.2 接口安全与滥用防护域名搜索引擎很容易被爬虫盯上。因为接口返回的是结构化 JSON别人可以直接写脚本把你的 50 万条数据扒走。建议至少做三层防护用户输入校验限制查询长度去掉危险字符。访问频率限制Nginx 的limit_req是最简单实用的方案。数据脱敏如果业务不需要就不要在接口中返回完整 JSON 全量字段只返回必要字段。如果将来接口需要公开还应该在更上层增加 API Key 认证。FastAPI 可以写一个简单的Depends校验函数查询请求必须带Authorization头否则返回 401。8.3 关于“域名不可访问”类错误的处理在域名搜索、域名探活这类项目中“domain forbidden”和non-existent domain是常见反馈。它们通常不代表系统故障而代表某些域名当前不可用或不允许访问。比如HTTP 层返回{code:1004,error:domain forbidden}通常说明该域名对应的网站已经停止服务、启用了 WAF 或者设置了禁止访问策略。DNS 层返回non-existent domain说明这个域名在 DNS 系统中已经没有解析记录。遇到这类结果建议在数据探活时单独标记status字段例如alive、dead、forbidden、no_dns_record不要直接删除数据。因为今天不可用的域名明天可能被别人重新注册保留历史记录有助于分析域名的生命周期。8.4 搜索体验优化对超长查询做截断例如只处理前 50 个字符防止恶意构造超大关键字导致索引查询慢。支持高亮显示关键字可以极大提升用户体验。前端拿到结果后把查询关键字匹配的片段用mark标签包起来即可。如果后续要支持中文搜索SQLite FTS5 的默认分词器表现一般建议引入 jieba 分词或者继续使用更轻量的子串匹配方案。8.5 成本控制建议轻度使用场景下云主机选择最便宜的套餐即可。内存 1GB 对于 50 万条 SQLite 数据完全够用但如果同时运行网站、爬虫、数据库构建脚本建议把索引构建脚本和搜索服务拆开避免构建索引时占用大量 CPU 导致服务响应变慢。如果不想维护服务器也可以考虑使用各种 Serverless 平台。不过 Serverless 环境下 SQLite 文件通常只能放在临时目录持久化需要额外处理复杂度反而不如一台小 VPS 直观。所以我更推荐在项目初期就用一台小机器 SQLite 的简单方案。9. 总结与后续扩展这个项目的核心价值在于它证明了在 50 万条数据规模下完全可以用最简单的技术栈实现一个可用的搜索引擎。SQLite FTS5 FastAPI 这套组合在数据量不超过百万级、并发量不高的场景下远比引入 Elasticsearch 划算。从技术角度看你至少掌握了以下内容域名数据的清洗规则与预处理方法。FTS5 虚拟表的使用、BM25 排序、外部内容与 show 列表的取舍。反转域名和三元组索引在域名搜索场景中的应用。FastAPI 接口开发、静态页面部署、数据库只读连接。Nginx 反代、限流、安全防护等基础运维能力。如果继续往下做可以考虑这几个方向增加域名实时探活用 DNS 查询和 HTTP HEAD 请求扫描域名状态在搜索结果中标记“可注册”“已被占用”“禁止访问”。接入 whois 信息展示域名的注册日期、过期日期、注册商方便做域名投资分析。加入相似域名推荐基于编辑距离或三元组匹配找出相近域名。做成定时任务每周自动更新一次索引配合邮件或企业微信机器人推送新增热词域名。这个项目的代码量并不大核心逻辑压缩在一两百行以内非常适合作为周末项目去实践。如果你正需要一个数据量不大、但应用场景清晰的检索系统练手这个域名搜索工具是一个不错的选择。如果这篇文章对你有所帮助欢迎收藏备用。你也可以在评论区聊聊如果你拿到 50 万条域名数据会优先用它做什么方向的分析。