适用读者:正在用 LIKE '%关键词%' 做站内搜索、发现数据一多就慢得没法用的人;在 PostgreSQL 全文检索和 Elasticsearch 之间摇摆的人;已经上了搜索引擎,但搜索结果不相关、索引还经常不同步的人
结论先行:模糊匹配慢,不是索引没加对,而是 B+ 树按完整字符串排序,天生回答不了「中间包含什么」。搜索需要的是倒排索引:从词出发,直接找到包含它的文档。选型的分界线也不是「数据量大小」,而是你要不要分词控制、相关性调权、聚合和横向扩展;要,就上搜索引擎,不要,PostgreSQL 的全文检索往往够用。
阅读时间:约 34 分钟 | 难度:进阶 | 收获:倒排索引的物理结构、分词与 BM25 的因果、PostgreSQL 与 Elasticsearch 的能力边界、索引同步的三种方案,以及一套可观测的搜索指标
目录
Article.objects.filter(title__icontains=q)。数据量几万条时,它快得让人忘记它的存在;到几十万条,接口开始偶尔超过一秒;到一百多万条,搜索框每敲一个字都要等三四秒,用户索性不搜了。
我一开始的反应很典型:加索引。给 title 建了一个普通的 B+ 树索引,重新发布,结果没有任何变化。EXPLAIN 显示优化器仍然选择顺序扫描,索引安静地躺在那里,一点忙都帮不上。
第二个问题是结果不相关。搜「Django 部署」,标题里同时出现这两个词但隔得很远的文章,和标题就是「Django 部署到 PythonAnywhere」的文章,排在差不多的位置;搜「缓存」,正文里提了一百次「缓存」的长文压过了真正讲缓存架构的短文。数据库的 icontains 只能回答「包含或不包含」,它没有「这个词有多重要」的概念,更没有「标题比正文重要」的概念。
这两个问题看起来是性能和相关性的两件事,根因却是同一个:用有序数组的查找方式,去回答集合成员的查找问题。B+ 树适合「等于」和「从某个前缀开始的范围」,不适合「任意位置包含」;LIKE 也没有能力表达相关性。要同时解决,需要换一种索引结构。
K,它能快速定位到 K 所在的位置,也能沿着叶子节点顺序扫描 K 之后的所有键。这决定了它支持两类查询:精确匹配,以及「以某段固定前缀开头」的范围扫描。
LIKE 'Django%' 之所以可能用上索引,是因为所有以 Django 开头的字符串在 B+ 树里是连续的一段,优化器可以把 Django 当成下界,把 Django 后面追加一个最大字符当成上界,只扫这一小段。
LIKE '%Django%' 不行。以 Django 结尾、或者在中间包含 Django 的字符串,在字典序里散落在整棵树中:A Django book、My Django、Using Django 彼此不相邻。没有一个连续区间能覆盖它们,优化器只能退化成全表扫描,对每一行做一次子串查找。
全表扫描的成本是 O(N·L):N 是行数,L 是参与匹配的字符串平均长度。一百多万行、标题平均三十个字符,就是几千万次字符比较;如果还带 icontains 的大小写折叠,每次比较还要做 Unicode 归一化,成本再往上抬。数据量线性增长,延迟也线性增长,这就是「越来越慢」的来源。
排序规则(collation)会在这个问题上再插一脚。MySQL 的 utf8mb4_0900_ai_ci 是大小写不敏感、重音不敏感的排序规则,LIKE 的语义跟着它走;PostgreSQL 的 LC_COLLATE 决定比较行为,不同 locale 下同一段文本的排序可能不同。索引是按某一种排序规则建的,查询如果用了另一种语义,优化器更不敢用索引。很多「加了索引还是不生效」的案例,最后都落到排序规则不匹配上。
PostgreSQL 有一个例外:pg_trgm 扩展把字符串切成三元组,建 GIN 或 GiST 索引后,LIKE '%x%'、ILIKE、正则匹配都能被索引加速。但这不是 B+ 树的功劳,而是另建了一套倒排式的结构;它索引体积大、写入变慢,对短查询词和常见词的效果也会打折。MySQL 没有等价的内置三元组索引,LIKE '%x%' 基本只能扫。知道这个例外很重要,它说明「中缀匹配」并非无解,只是解法不是普通 B+ 树。
1, 3, 7, 12 变成 1, 2, 4, 5,每个数占的位数大幅下降。词频用来算相关性分数。
第三层是位置(positions)。如果只做「包含这个词」的布尔检索,有文档 ID 就够了;一旦要支持短语查询、邻近查询和高亮,就必须记录每个词在文档中的位置。位置列表同样用差值编码。代价是索引变大,所以有些字段会关掉 positions,只保留文档级信息。
多个词项的 posting 求交集时,如果逐项线性推进,成本是各列表长度之和。跳表(skip list)在 posting 里每隔一段建一个跳跃指针,让「推进到下一个大于等于 X 的文档」可以跳过整段,而不是一个一个走。Lucene 的 posting 里还有更细的跳表层级,查询计划的顺序也会按词项稀有度排序:先处理文档频率最低的词,候选集缩得最快。
文档频率(df)在这里是一个贯穿始终的量。一个词出现在越少的文档里,它的 posting 越短,查询越快,信息量也越大;出现在几乎所有文档里的词,posting 很长,查询慢,区分度也低。这既是性能问题,也是后面 BM25 里 idf 的来源。
用一次查询走一遍流程:查询被分词成若干词项;每个词项在词典里定位,拿到 posting 的偏移;按稀有度排序后求交集,求交过程中用跳表跳过不可能命中的文档;对命中的文档,用 posting 里的词频、字段长度和 idf 算分;最后用一个大小为 K 的最小堆维护 top-K,而不是把所有命中文档全排序。整套流程的成本大致与「参与求交的 posting 总长度」成正比,而不是与文档总数成正比。这就是为什么倒排索引在百万、千万文档上仍然能保持可接受的延迟。
Lucene 还把索引切成不可变的段(segment)。新写入先进入内存缓冲,攒够一定量后 flush 成一个新段;段一旦写出就不再修改,删除通过墓碑标记;后台的 merge 把多个小段合并成大段,减少查询时要打开的段数。这个「不可变段 + 合并」的设计,是后面近实时搜索和副本同步的基础。
CJKBigramFilter、Elasticsearch 的 cjk analyzer 都走这条路。它适合词典覆盖不足、且能接受一定噪声的场景。
第三种是混合:用词典分词做主索引,同时保留 bigram 或单字作为兜底字段,查询时两路合并。实现复杂,但对中文站内搜索往往最稳。选择哪一种,取决于你的语料和查询词分布,不存在「中文分词器排行榜第一」这种答案。
英文的分词看起来简单,其实也有坑。标准分词器按空白和标点切,随后要决定是否小写、是否去停用词、是否做词干提取。词干提取(stemming)把 running、runs、ran 归到 run,能提高召回;但它也会把不该合并的词合并,比如 universe 和 university 在某些算法下会被截到同一个前缀。Porter 和 Snowball 是常见算法,Lucene 提供 EnglishAnalyzer,Elasticsearch 的 english analyzer 内置了停用词和 Snowball 词干。
归一化是分词之外的另一半。大小写折叠、全角转半角、Unicode NFKC、重音折叠(café 与 cafe)、空白压缩,这些操作要让索引时和查询时走同一套。最常见的错误是索引用了 ik_smart,查询用了 standard,两边切出来的词项对不上,结果自然为零。排查时不要猜,直接用分析接口看两边分别切成了什么:
POST /articles/_analyze
{
"field": "title",
"text": "Django 部署与断点续传"
}
tokens 会告诉你每个词项、位置和偏移。索引和查询的分析结果必须一致,这是一条硬约束。
同义词要决定放在索引时还是查询时。索引时扩展同义词,会让 posting 变大,而且同义词表一改就得重建索引;查询时扩展更灵活,改完立即生效,代价是查询词项变多、延迟略增。绝大多数场景应该选查询时扩展,索引只保留原文的词项。
score(q, d) = Σ tf(t, d) × idf(t)
idf(t) = log(N / df(t))
tf 是词在文档里出现的次数,df 是包含这个词的文档数,N 是文档总数。直觉很合理:词出现得越多越相关,越稀有越有区分度。但直接用它会有两个明显问题。
第一,tf 无上界。一个词从出现 1 次到 2 次,分数翻倍;从 10 次到 20 次,分数再翻倍。这等于鼓励堆词,长文只要反复提同一个词就能压过短文,哪怕短文才是主题。
第二,文档长度没有归一化。长文档天然包含更多词、每个词的次数也更多,tf 的总和更大,于是长文系统性占优。这不是因为长文更相关,只是因为它更长。
BM25 针对这两点做了修正。它的常见形式是:
score(q, d) = Σ idf(t) × [ tf × (k1 + 1) ] / [ tf + k1 × (1 - b + b × dl / avgdl) ]
idf(t) = ln(1 + (N - df + 0.5) / (df + 0.5))
dl 是文档长度,avgdl 是平均文档长度,k1 控制词频饱和的速度,b 控制长度归一化的强度。Lucene 的 BM25Similarity 默认 k1 = 1.2、b = 0.75,Elasticsearch 默认就用这套。
k1 的饱和效应可以这样理解:当 tf 很小时,分母里的 tf 占主导,分数随出现次数快速上升;当 tf 已经很大,分母被 k1 × (...) 托住,再增加次数带来的增益越来越小。于是「出现 1 次到 2 次」有意义,「出现 10 次到 20 次」几乎不改变排序。这就是它比 TF-IDF 稳的地方:不奖励堆词。
b 的取值决定长度惩罚。b = 0 完全不归一化,长文占优;b = 1 完全按长度比例惩罚,短文可能被过度抬高。0.75 是一个折中。对于标题这种长度差异不大的字段,长度归一化的影响小;对于正文,它非常关键。
idf 的形式也变了。BM25 用的是 ln(1 + (N - df + 0.5)/(df + 0.5)),即使某个词出现在所有文档里,idf 仍然是一个小的正数,不会变成零或负数。TF-IDF 的 log(N/df) 在 df = N 时直接归零,在极端情况下还会出负值,导致「包含这个词反而扣分」。BM25 的写法避免了这种不稳定性。
字段权重是第二层排序信号。标题命中的权重通常高于正文,正文高于评论;实现上可以给不同字段不同的 boost,或者用多字段查询的合并策略。以 Elasticsearch 的 multi_match 为例,best_fields 取所有字段里得分最高的一个,适合「命中一个字段就够」;most_fields 把各字段分数相加,适合「多处提及更相关」;cross_fields 把多个字段当成一个大字段,适合「一个词在标题、另一个词在正文」的查询。
短语和邻近度是第三层。倒排索引存了位置,才能回答「这两个词是否相邻」。短语查询要求词项位置连续;邻近查询允许中间隔几个词,slop 就是允许的间隔。没有 positions 的字段做不了这件事,只能退化成布尔「都包含」。高亮同样依赖位置或偏移,否则无法知道该给哪一段加标记。
相关性没有一劳永逸的公式。BM25 是一个好的起点,但业务上的「相关」往往还包含时效性、作者权重、点击率、内容质量。把这些信号叠加进去,通常用 function_score 这类机制,而不是修改 BM25 本身。
tsvector:一串按位置排序的词位(lexeme),每个词位可以带位置和权重。to_tsvector 负责分词、归一化和去停用词:
SELECT to_tsvector('english', 'The quick brown foxes are jumping');
-- 'brown':3 'fox':4 'jump':6 'quick':2
foxes 被词干化成 fox,jumping 变成 jump,the 和 are 作为停用词被去掉,数字是词在原文中的位置。权重可以用 setweight 标成 A、B、C、D,分别对应标题、摘要、正文等不同重要程度。
查询侧用 tsquery,支持与、或、非、短语和距离。下面的中文例子统一用 chinese 配置,这个配置的创建命令在本节后半段;没装中文解析器时,可以先用 simple 在英文语料上验证整条链路。
SELECT to_tsvector('chinese', title) @@ to_tsquery('chinese', 'django & 部署');
SELECT to_tsvector('chinese', body) @@ to_tsquery('chinese', '缓存 <-> 穿透');
SELECT to_tsvector('chinese', body) @@ to_tsquery('chinese', '缓存 <3> 雪崩');
<-> 要求相邻,<N> 允许中间隔 N 个词。websearch_to_tsquery 更适合直接接用户输入:按 PostgreSQL docs 的说明,它把引号里的内容当短语,把 OR 当或,把 - 当前面词的排除,并且不会因为用户输入里的特殊字符报错。这是面向搜索框最实用的入口。
索引用 GIN:
CREATE INDEX articles_title_fts
ON articles
USING GIN (to_tsvector('chinese', coalesce(title, '')));
SELECT id, title,
ts_rank(to_tsvector('chinese', title), query) AS rank
FROM articles, websearch_to_tsquery('chinese', 'django 部署') AS query
WHERE to_tsvector('chinese', title) @@ query
ORDER BY rank DESC
LIMIT 20;
@@ 这种包含查询;GiST 也能用,但更新更贵,通常只在需要动态重排或有特殊数据类型时才考虑。PostgreSQL docs 对 GIN 的适用场景和更新代价有明确说明。表达式索引要求查询里的表达式和索引定义逐字一致,coalesce 和配置名都不能差,否则规划器不会用它。
中文需要额外的解析器。PostgreSQL 内置的分词器面向空格分隔的语言,直接对中文用 simple 配置,往往把整句当成一个词位,或者按标点切得乱七八糟。常见方案是 zhparser(基于 SCWS)或 pg_jieba。以 zhparser 为例,安装扩展后创建配置,把词性映射到 simple 字典:
CREATE EXTENSION zhparser;
CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION chinese
ADD MAPPING FOR n, v, a, i, e, l WITH simple;
SELECT to_tsvector('chinese', 'Django 部署与断点续传');
zhparser 的边界要提前知道:词典覆盖有限,专业术语和新词需要 zhparser.zhprs_custom_word 补词;词性映射配错会导致某些词直接被丢掉;它没有 Elasticsearch 那种同义词图和丰富的分析器链。它够用,但不万能。
排序用 ts_rank 或 ts_rank_cd。前者基于词频,后者基于覆盖密度(cover density),对短语和邻近更敏感。它们都不是 BM25,排序质量与 Lucene 有差距,调权手段也有限。如果相关性是核心竞争力,这里会成为瓶颈。
规模上,GIN 索引的大小与词位和 posting 数量相关,通常是原文本的几个到十几个百分点;写入时每个文档都要更新索引,更新频繁的大表会把写放大。查询性能则高度依赖词项的文档频率:搜一个常见词,posting 很长,延迟会明显上升。百万级行、读多写少、查询不复杂的站内搜索,PostgreSQL 完全能扛;到了需要多字段复杂调权、聚合、拼写容错、横向扩展的阶段,就该考虑搜索引擎了。
text 加 keyword 子字段,把日期猜成日期,把数字猜成 long;猜错之后再改字段类型,通常要重建索引。生产索引应该关掉动态猜测,至少对核心字段写死类型:
PUT /articles
{
"mappings": {
"properties": {
"title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"summary": { "type": "text", "analyzer": "ik_max_word" },
"author": { "type": "keyword" },
"tags": { "type": "keyword" },
"published_at": { "type": "date" },
"view_count": { "type": "integer" }
}
}
}
text 用于全文检索,会被分词、建 posting、参与打分;keyword 用于精确过滤、排序和聚合,不分词,走 doc values 的列式存储。把该用 keyword 的字段建成 text,过滤会变慢且语义不对;把该用 text 的字段建成 keyword,全文检索直接失效。
analyzer 和 search_analyzer 可以分开。索引时用细粒度分词(ik_max_word)提高召回,查询时用粗粒度(ik_smart)减少噪声,这是中文场景常见的组合。前提是两者的词项能对上;如果对不上,就会出现「索引里有、查询切不出」的零结果。
查询 DSL 的基本结构是 bool:
GET /articles/_search
{
"query": {
"bool": {
"must": [
{ "multi_match": {
"query": "Django 部署",
"fields": ["title^3", "summary^2", "body"],
"type": "best_fields",
"tie_breaker": 0.3
}}
],
"filter": [
{ "term": { "status": "published" } },
{ "range": { "published_at": { "gte": "now-2y" } } }
],
"must_not": [
{ "term": { "tags": "spam" } }
]
}
},
"sort": [
{ "_score": "desc" },
{ "published_at": "desc" }
],
"size": 10
}
must 参与打分,filter 不参与打分且可以被缓存,must_not 排除。把状态、租户、时间范围这类「硬条件」放进 filter,是性能优化的第一步,也是权限过滤的正确位置。
排序信号可以用 function_score 叠加。比如给新文章一个时间衰减,给高阅读量一个加权:
GET /articles/_search
{
"query": {
"function_score": {
"query": { "multi_match": { "query": "缓存", "fields": ["title^3", "body"] } },
"functions": [
{ "gauss": { "published_at": { "origin": "now", "scale": "30d", "decay": 0.5 } } },
{ "field_value_factor": { "field": "view_count", "factor": 0.1, "modifier": "log1p" } }
],
"score_mode": "sum",
"boost_mode": "multiply"
}
}
}
terms 聚合按标签分桶,date_histogram 按时间分桶,cardinality 估算去重数。聚合走 doc values,和全文检索的 posting 是两套结构,所以「搜索 + 聚合」在 Elasticsearch 里是一次请求能完成的事,在纯 PostgreSQL 里往往要额外写 SQL。
refresh_interval 是 1 秒,所以最坏情况下有接近一秒的可见性延迟。需要更强可见性时可以显式 refresh,但频繁 refresh 会产生大量小段,加重 merge。
持久性和可见性是两件事。translog 负责前者:默认 index.translog.durability 是 request,每个写请求都会 fsync;即使进程崩溃,重启后也能从 translog 恢复。refresh 负责后者,它不保证数据落盘。把两者混为一谈,就会在「为什么明明返回 200 却搜不到」和「refresh 了是不是就安全了」之间反复困惑。
副本解决可用性和读吞吐。每个分片有一个主分片和若干副本,写入先到主分片,再复制到副本;搜索可以打到主分片或副本。副本数增加会提高读能力,但也增加写入放大和存储成本。副本的 refresh 跟随主分片,所以可见性延迟不会因为副本而消失。
深分页是另一个常见陷阱。from + size 的查询,每个分片都要取出前 from + size 条,协调节点再归并排序。翻到第 100 页、每页 20 条,意味着每个分片都要计算并丢弃前 2000 条;分片越多,浪费越大。Elasticsearch docs 里 index.max_result_window 默认 10000,就是给这种成本设的上限。
正确的翻页方式是 search_after:用上一页最后一个文档的排序值作为游标,下一页从它之后开始。它要求排序字段组合唯一,通常用 published_at 加 _id。为了避免翻页过程中数据变化导致重复或漏项,配合 PIT(point in time)固定索引视图:
POST /articles/_search
{
"size": 20,
"sort": [ { "published_at": "desc" }, { "_id": "asc" } ],
"search_after": [ "2026-10-01T00:00:00Z", "article-123" ],
"pit": { "id": "pit_id", "keep_alive": "5m" }
}
search_after 不适合「跳到第 N 页」的交互,它只适合「下一页」。需要随机跳页的产品,要么限制页数,要么换一种呈现方式。导出全量数据用 scroll 或 PIT 加切片,不要用 from + size 硬翻。
分片数量也要克制。每个分片都是一个 Lucene 索引,有固定的段、文件句柄和堆开销;分片过多会导致 cluster state 变大、查询要合并更多结果。常见经验是单分片几十 GB 量级,按数据增长预留,不要一开始就按「未来十倍」切出几百个分片。
CREATE TABLE search_outbox (
id BIGSERIAL PRIMARY KEY,
doc_id BIGINT NOT NULL,
op TEXT NOT NULL,
version BIGINT NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
version_type=external)或应用层版本比较:只有更大的版本才能覆盖当前文档。这样即使同一事件被投递两次,或者旧事件晚到,也不会把新数据覆盖回旧值。删除也要带版本,否则「删除事件晚于更新事件」会让已删除的文档复活。
版本号从哪里来,是这套机制的关键。可以用数据库的自增序列、行的 updated_at 加版本列,或者 CDC 里的 binlog 位点。无论哪种,必须单调递增且能比较;只靠时间戳在并发写入下不可靠,因为同一毫秒内的顺序不确定。
全量重建是最后的保险。新建一个索引(例如 articles_v2),从数据库或快照全量导入,校验文档数和抽样内容,然后通过别名原子切换:
POST /_aliases
{
"actions": [
{ "remove": { "index": "articles_v1", "alias": "articles" } },
{ "add": { "index": "articles_v2", "alias": "articles" } }
]
}
filter 上下文:不参与打分,可以被缓存,也不会因为 boost 被意外抬高。查询词、同义词、模糊匹配,这些是相关性条件,放进 query 上下文。混在一起写,既慢又容易出权限漏洞。
第二条原则是缓存要按「归一化后的查询」做键。同一个搜索,大小写不同、空格不同、参数顺序不同,应该命中同一个缓存。缓存键里必须包含租户、权限范围、分页游标和排序方式;只按查询词缓存,会把 A 租户的结果发给 B 租户。
第三条原则是认真对待零结果。零结果率高,通常不是索引没建好,而是分词不一致、同义词缺失、用户输入里有错别字、或者过滤条件太窄。做法是记录零结果查询词,按频次排序,人工看 Top 20;补同义词、加拼音或拼写容错、放宽时间范围、给出「你是不是想搜……」的提示。不要用一个空页面结束这次交互。
高亮要付出代价。它需要知道词在原文中的位置和偏移,text 字段要保留 positions 或 term vectors;字段很大时,高亮会成为查询里最慢的一段。只对需要展示的字段开高亮,限制片段长度和数量,不要对整个正文做全量高亮。
多租户过滤必须在服务端完成。客户端传来的 tenant_id 只能作为「请求的租户」,服务端要根据登录身份解析出真正允许的租户集合,再拼进 filter。直接把客户端参数塞进查询,等于把租户隔离交给用户。字段级权限同理:敏感字段在 mapping 里单独存放,返回时用 _source 过滤,或者用文档级安全在查询层拦截;不要指望前端「不展示」就是安全。
查询注入是另一个容易忽视的点。query_string 语法支持通配符、正则、字段引用和布尔表达式,用户输入 title:* 或一段超长的嵌套表达式,可能拖垮集群。面向用户的搜索框应该用结构化的 multi_match,把用户输入当纯文本处理,而不是把原始字符串拼进 query_string。必须用 query_string 时,要转义保留字符、限制长度、禁用前导通配符和正则。脚本查询(script_score)如果开放给不可信输入,风险更高,生产里应当关闭动态脚本或使用沙箱。
pg_stat_statements 和 log_min_duration_statement;把超过阈值的查询原文、耗时、命中数记录下来。rejected 指标上升说明容量不够或慢查询过多。| 问题 | 结论 |
|---|---|
LIKE '%x%' 为什么慢 |
B+ 树按完整字符串排序,中缀匹配没有连续区间,只能全表扫描 |
| 加普通索引为什么没用 | 索引结构回答不了「任意位置包含」,排序规则不匹配时更不会生效 |
| 倒排索引是什么 | 词项到文档列表的映射,含词典、posting、位置和跳表 |
| 中文分词怎么选 | 词典分词精度高但依赖词典,bigram 召回稳但索引大,混合方案最稳 |
| TF-IDF 的问题 | 词频无上界、长文占优,容易奖励堆词 |
| BM25 为什么更稳 | 词频饱和(k1)加长度归一化(b),idf 不会归零 |
| PostgreSQL 全文检索适合谁 | 已有 PG、百万级数据、查询不复杂、能接受弱于 BM25 的排序 |
| Elasticsearch 强在哪 | 分析器链、查询 DSL、聚合、横向扩展、近实时 |
| 深分页怎么做 | search_after 加 PIT,不要 from + size 硬翻 |
| 索引怎么同步 | Outbox/CDC 至少一次投递,版本号幂等,别名切换做全量重建 |
| 多租户怎么隔离 | 服务端解析租户并放进 filter,永不信任客户端参数 |
tsvector 加 GIN 足够,运维也简单;如果要处理中文新词、同义词、拼写容错、字段调权、聚合面板、横向扩展,那就不是在数据库里加一个索引能解决的事,搜索引擎的复杂度是必须要付的成本。
而无论选哪个,索引都只是数据库的影子。把同步做成可重放、可幂等、可重建,比一开始就追求「实时」更重要。影子偶尔落后不可怕,可怕的是它和真相分叉之后再也没人对账。
专题:后端架构 — Django、并发、限流、灰度与线上部署