Django+GraphQL构建生产级URL缩短服务 1. 项目概述为什么一个URL缩短服务值得用DjangoGraphQL重做一遍我第一次在2019年用Flask搭过一个极简版短链服务当时只用了30行代码加一个SQLite跑在学生服务器上每天撑住几百次跳转就沾沾自喜。但去年帮一家本地教育机构重构其课程分享系统时他们提出一个看似简单却暗藏复杂性的需求“老师发给家长的课表链接要能实时看到谁点开了、从哪台手机点的、点了几次还要支持按班级批量生成带不同参数的短链且所有操作必须留审计日志。”——这时候我才意识到一个“能用”的短链服务和一个“可运维、可审计、可扩展”的短链服务之间隔着整整一套工程化思维。这个标题《How To Create a URL Shortener with Django and GraphQL》表面看是技术组合教学实则是一次典型的现代Web后端架构实践切片它把高并发读写场景跳转请求、强一致性要求短码唯一性、多端协同管理后台小程序H5页面、权限隔离不同租户/角色访问不同数据以及前端灵活取数无需为每个新报表改API全部压缩进一个不到200行核心模型的项目里。我过去三年带团队落地的7个SaaS类项目中有5个在初期都绕不开类似短链模块——不是因为它多难而是它像一面镜子照出你对数据库事务、缓存策略、API设计、安全边界和可观测性的真实掌控力。关键词里反复出现的“django graphql 注入”“防注入”恰恰暴露了多数人动手前最忽略的一环GraphQL不是REST的语法糖替代品它是查询能力的彻底开放。当你允许前端自由拼接{ clicks(where: {ip_contains: 192.168}) {id, ip} }这类查询时等同于把数据库的WHERE子句直接交到用户手上。而Django ORM默认的__contains、__in等字段查找器若未经白名单过滤就是天然的注入温床。这不是理论风险——我在2022年审计一个招聘平台时就发现其GraphQL接口因未限制job_postings(where: {salary_gte: 0 OR 11})被用于批量爬取薪资数据。所以这篇内容不教你怎么“做出一个能跳转的短链”而是带你亲手构建一个从建模开始就内置防注入机制、自带审计追踪、支持灰度发布且能扛住每秒300跳转请求的生产级短链服务。适合正在用Django做真实项目的中级开发者也适合想理解GraphQL安全边界的前端工程师——毕竟当你调用useQuery(GET_CLICKS)时得知道背后那行SQL到底长什么样。2. 整体架构设计与技术选型逻辑2.1 为什么不用现成的短链SaaS或开源方案先说结论如果你的业务只需要“生成短码→跳转”用Bitly或开源的YOURLS确实省事。但一旦涉及以下任一场景自研就成了必选项数据主权要求医疗、金融类客户明确禁止将用户点击行为上传至第三方服务器定制化跳转逻辑比如“企业微信内打开跳H5iOS Safari跳App Store安卓微信跳应用宝”这种设备UA路由与现有系统深度集成短链需自动绑定CRM中的客户ID、关联ERP中的订单号、触发内部审批流合规审计需求GDPR/等保要求记录每次跳转的完整上下文IP、User-Agent、Referer、时间戳且不可篡改。我见过太多团队前期图快接入SaaS后期为满足等保整改被迫用Nginx反向代理日志解析硬凑审计数据结果发现Referer字段被微信客户端强制清空最终不得不推倒重来。自研的核心价值不在“造轮子”而在把业务规则、安全策略、审计要求全部编码进基础设施层。2.2 Django为何仍是首选框架有人会问既然GraphQL是重点为什么不选Node.js的Apollo Server答案很实在Django的ORM、Admin、中间件和安全模块构成了短链服务最需要的“底盘能力”。ORM的原子性保障短码生成必须保证全局唯一。Django的get_or_create()配合数据库唯一索引比Node.js手动写INSERT ... ON CONFLICT DO NOTHING更可靠。我实测过PostgreSQL在10万QPS下Django ORM的select_for_update()对short_code字段加行锁的延迟稳定在8ms内而手写SQL若忘记加FOR UPDATE在高并发下必然出现重复短码。Admin即管理后台教育机构客户要求“班主任能登录后台查看本班链接点击热力图”。Django Admin通过list_display、search_fields、list_filter三行配置就能生成带搜索、分页、导出的界面比从零写Vue管理后台快5倍。更重要的是Admin自动生成的审计日志LogEntry模型天然记录谁在何时修改了哪条短链这直接满足等保2.0中“应用系统应提供安全审计功能”的条款。中间件的统一入口所有跳转请求必须经过ClickMiddleware记录IP、UA、Referer。Django中间件的process_request()方法在URL路由前执行确保即使用户直接访问/abc123也能被捕获。而Express的中间件若放在路由定义之后就可能漏掉静态资源请求。提示Django 4.2的asgi.py已原生支持WebSocket为后续增加“实时点击数看板”预留了通道。别小看这点——当运营同事盯着大屏看活动链接的实时点击曲线时你会感谢当初没选WSGI-only框架。2.3 GraphQL的不可替代性在哪REST API在此场景的三大硬伤GraphQL全解决了问题REST方案GraphQL方案实际效果N1查询前端需先查/api/links/123再查/api/links/123/clicks?limit10一次请求{ link(id: 123) { url, clicks(first: 10) { ip, userAgent } } }减少60%网络往返移动端首屏加载快1.8秒字段冗余GET /api/links返回全部字段含created_at,updated_at,is_active但管理后台只显示short_code和clicks_count前端精确声明所需字段{ shortCode, clicksCount }单次响应体积从2.1KB降至380B节省CDN流量权限粒度REST需为不同角色维护多套Endpoint/admin/links,/teacher/linksGraphQL通过resolve_*函数动态控制教师角色resolve_clicks()直接抛出PermissionDenied异常后端代码量减少40%权限逻辑集中一处最关键的是GraphQL的Schema即契约。当我把LinkType定义为class LinkType(DjangoObjectType): class Meta: model Link fields (id, short_code, original_url, clicks_count) # 显式声明仅暴露必要字段避免意外泄露这就从代码层面锁死了created_by创建人ID、last_click_at最后点击时间等敏感字段的输出可能。而REST若依赖文档约定开发中一个疏忽的fields __all__就可能酿成数据泄露。2.4 技术栈组合的避坑要点Django版本锁定在4.2.x这是首个正式支持AsyncView的LTS版本。短链跳转是典型I/O密集型操作查DB→写Redis→302重定向用async def redirect_view()比同步视图吞吐量高2.3倍。但切记Django 4.2的AsyncModelAdmin仍不成熟管理后台保持同步即可。GraphQL后端选Graphene-Django而非Strawberry前者对Django ORM的集成深度远超后者。例如DjangoFilterConnectionField一行代码就支持{ links(where: {clicksCount_gt: 100}) }而Strawberry需手动实现where解析器。我们实测过在10万条短链数据集上Graphene的过滤查询平均耗时42msStrawberry同类实现需117ms。绝不使用graphene-django-extras这个库试图封装所有功能结果把DjangoFilterConnectionField的where参数强行映射为Django ORM的**kwargs导致{ links(where: {shortCode_in: [abc, def]}) }直接翻译成Link.objects.filter(short_code__in[abc,def])——这正是GraphQL注入的温床。我们坚持用Graphene原生DjangoFilterConnectionField并自行编写白名单校验器。3. 核心模型与安全设计详解3.1 数据库模型从“能存”到“防爆”短链服务最常被低估的其实是数据模型设计。很多人直接建一张表# 错误示范看似简洁实则埋雷 class Link(models.Model): short_code models.CharField(max_length10, uniqueTrue) original_url models.URLField() created_at models.DateTimeField(auto_now_addTrue)这在测试环境没问题但上线后立刻暴雷短码碰撞short_code用随机字符串生成当数据量超100万时MD5哈希碰撞概率升至0.0001%用户投诉“生成的短码打不开”URL注入original_url若存javascript:alert(1)前端跳转时直接XSS无审计字段无法追溯谁创建了恶意链接。我们采用四层防护模型# backend/models.py from django.db import models from django.contrib.auth.models import User from django.core.validators import URLValidator from django.core.exceptions import ValidationError import re class Link(models.Model): # 【第一层】短码生成避免随机碰撞 short_code models.CharField( max_length8, uniqueTrue, db_indexTrue, # 加速查询 help_text8位大小写字母数字如 aB3xK9mL ) # 【第二层】URL净化防御协议注入 original_url models.TextField( validators[URLValidator(schemes[http, https])], help_text仅允许 http/https 协议 ) # 【第三层】业务字段支撑审计与运营 created_by models.ForeignKey( User, on_deletemodels.PROTECT, # 防止删除用户导致链接失效 related_namecreated_links ) is_active models.BooleanField(defaultTrue) # 软删除保留历史数据 created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) # 【第四层】安全增强防止SSRF和开放重定向 property def safe_redirect_url(self) - str: 返回经校验的安全跳转地址 # 步骤1移除URL片段#后内容 clean_url self.original_url.split(#)[0] # 步骤2验证域名白名单教育机构要求仅允许.edu.cn域名 domain re.search(rhttps?://([^/]), clean_url) if domain and not domain.group(1).endswith(.edu.cn): return settings.DEFAULT_FALLBACK_URL # 配置的默认跳转页 return clean_url def clean(self): Django Model.clean() 是数据入库前最后一道防线 super().clean() # 强制HTTPS防止混合内容警告 if self.original_url.startswith(http://): self.original_url https:// self.original_url[7:] # 长度限制防超长URL拖慢数据库 if len(self.original_url) 2000: raise ValidationError(原始URL长度不能超过2000字符) def save(self, *args, **kwargs): # 确保clean()被调用 self.full_clean() super().save(*args, **kwargs)注意db_indexTrue对short_code至关重要。我们压测发现当短链表达100万行时无索引的SELECT * FROM link WHERE short_codeabc123平均耗时210ms加索引后降至0.8ms。别小看这200ms——在电商大促期间用户点击短链等待超3秒就会流失35%。3.2 GraphQL Schema白名单驱动的安全查询GraphQL最大的安全风险在于where参数的任意字段查询。我们的解决方案是放弃通用过滤器为每个业务场景定制白名单。# backend/schema.py import graphene from graphene_django import DjangoObjectType from graphene_django.filter import DjangoFilterConnectionField from django.db.models import Q class LinkNode(DjangoObjectType): class Meta: model Link filter_fields { # 【关键】只开放绝对安全的字段 short_code: [exact, icontains], # 仅支持精确匹配和模糊搜索 clicks_count: [gt, lt, gte, lte], # 数值比较无注入风险 } interfaces (graphene.relay.Node,) class Query(graphene.ObjectType): # 【核心】禁用通用where改用语义化查询 link_by_code graphene.Field( LinkNode, short_codegraphene.String(requiredTrue), description根据短码精确查询区分大小写 ) active_links DjangoFilterConnectionField( LinkNode, description查询所有激活状态的短链管理员专用 ) def resolve_link_by_code(self, info, short_code): # 强制大小写敏感匹配避免 ABC123 和 abc123 冲突 return Link.objects.get(short_code__exactshort_code) def resolve_active_links(self, info, **kwargs): # 权限校验仅管理员可查全部 if not info.context.user.is_staff: raise PermissionError(仅管理员可访问此接口) return Link.objects.filter(is_activeTrue)这个设计砍掉了所有危险的过滤器禁用__regex防止正则注入short_code__regex.*会拖垮数据库禁用__in避免{ links(where: {id_in: [1,2,3,4,5]}) }被用于批量探测ID禁用__date等时间字段防止通过时间范围枚举创建记录。实测对比开启白名单后GraphQL Playground中输入{ links(where: {shortCode_regex: .*}) }直接返回Unknown argument \regex\ on field \links\而旧版graphene-django-extras会静默执行正则导致CPU飙升至100%。3.3 点击记录模型高并发下的无锁设计短链跳转是典型的“写多读少”场景。每秒300次跳转意味着每秒300次INSERT。若用传统Click.objects.create(linklink, iprequest.META[REMOTE_ADDR])在PostgreSQL上会因行锁竞争导致TPS骤降。我们采用双写策略异步落库# backend/models.py class Click(models.Model): 点击记录 - 仅存储聚合后数据原始明细走Redis link models.ForeignKey(Link, on_deletemodels.CASCADE, related_nameclicks) ip_hash models.CharField(max_length64, db_indexTrue) # IP的SHA256哈希保护隐私 user_agent_hash models.CharField(max_length64) # UA哈希 count models.PositiveIntegerField(default1) # 同一IPUA的点击次数 last_clicked_at models.DateTimeField(auto_nowTrue) class Meta: # 复合唯一索引同一链接下同一IPUA只存一条记录 unique_together (link, ip_hash, user_agent_hash) # backend/views.py from django.http import HttpResponseRedirect, HttpResponse from django.views.decorators.csrf import csrf_exempt from django.utils import timezone import hashlib import redis from celery import shared_task # Redis连接池复用Django-Celery配置 redis_client redis.Redis( hostsettings.REDIS_HOST, portsettings.REDIS_PORT, db1, # 专用DB decode_responsesTrue ) csrf_exempt def redirect_view(request, short_code): 短链跳转主入口 - 无数据库写入极致轻量 try: link Link.objects.get(short_codeshort_code, is_activeTrue) except Link.DoesNotExist: return HttpResponse(Not Found, status404) # 步骤1记录原始点击Redis原子操作 ip_hash hashlib.sha256(request.META.get(REMOTE_ADDR, ).encode()).hexdigest() ua_hash hashlib.sha256(request.META.get(HTTP_USER_AGENT, ).encode()).hexdigest() # Redis key: click:{link_id}:{ip_hash}:{ua_hash} redis_key fclick:{link.id}:{ip_hash}:{ua_hash} redis_client.hincrby(redis_key, count, 1) redis_client.hset(redis_key, last_clicked_at, timezone.now().isoformat()) # 步骤2异步聚合10秒后触发 aggregate_clicks.delay(link.id, ip_hash, ua_hash) # 步骤3302重定向毫秒级 return HttpResponseRedirect(link.safe_redirect_url) shared_task def aggregate_clicks(link_id, ip_hash, ua_hash): Celery任务聚合Redis数据到MySQL redis_key fclick:{link_id}:{ip_hash}:{ua_hash} data redis_client.hgetall(redis_key) if not data: return # 原子性获取并删除Redis数据 pipe redis_client.pipeline() pipe.hgetall(redis_key) pipe.delete(redis_key) result pipe.execute()[0] # 写入MySQL利用Django ORM的get_or_create避免竞态 Click.objects.get_or_create( link_idlink_id, ip_haship_hash, user_agent_hashua_hash, defaults{ count: int(result.get(count, 1)), last_clicked_at: timezone.now() } )这套方案的优势跳转延迟15ms纯Redis操作无数据库IO抗突发流量Redis单实例轻松处理5万QPSCelery异步任务队列削峰数据一致性get_or_create保证同一IPUA只生成一条MySQL记录隐私合规存储IP哈希而非明文满足GDPR“匿名化处理”要求。我们在线上环境实测模拟1000并发用户点击同一短链跳转成功率100%平均延迟12.3msMySQL写入压力稳定在50TPS远低于800TPS的瓶颈值。4. 关键环节实现与实操步骤4.1 环境搭建Django 4.2 Graphene-Django最小可行配置别被网上教程吓到——一个可运行的GraphQL短链服务核心依赖只需5行# 创建虚拟环境 python -m venv venv source venv/bin/activate # Linux/Mac # venv\Scripts\activate # Windows # 安装核心依赖严格指定版本 pip install Django4.2,4.3 graphene-django3.1,3.2 django-filter23.0,24.0 celery[redis]5.3,5.4 # 初始化Django项目 django-admin startproject backend . python manage.py startapp links关键配置文件修改# backend/settings.py INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, # GraphQL相关 graphene_django, django_filters, # 业务App links, ] # GraphQL配置精简版去掉所有非必要选项 GRAPHENE { SCHEMA: backend.schema.schema, MIDDLEWARE: [ graphql_jwt.middleware.JSONWebTokenMiddleware, # JWT认证可选 ], } # Celery配置使用Redis作为Broker CELERY_BROKER_URL redis://127.0.0.1:6379/0 CELERY_RESULT_BACKEND redis://127.0.0.1:6379/0 CELERY_TASK_TRACK_STARTED True CELERY_TASK_TIME_LIMIT 300# backend/urls.py from django.contrib import admin from django.urls import path, include from graphene_django.views import GraphQLView from django.views.decorators.csrf import csrf_exempt urlpatterns [ path(admin/, admin.site.urls), # GraphQL端点禁用CSRF因前端通常跨域调用 path(graphql/, csrf_exempt(GraphQLView.as_view(graphiqlTrue))), # 短链跳转端点正则匹配8位短码 path(str:short_code/, views.redirect_view, nameredirect), ]实操心得csrf_exempt必须加在GraphQLView上否则前端调用fetch(/graphql/, {method:POST})会因CSRF token缺失返回403。但切记仅对GraphQL端点豁免跳转端点redirect_view必须保留CSRF保护Django默认启用因为跳转请求可能来自恶意网站的img srcyoursite.com/abc123。4.2 短码生成算法从随机到可控的演进早期我们用random.choices(string.ascii_letters string.digits, k8)结果在压测时发现碰撞率随数据量指数上升无法预测短码长度有时生成aB3有时xK9mL前端UI布局错乱无业务含义运营人员无法从短码推测链接用途。升级为Base62编码时间戳种子方案# links/utils.py import time import random import string BASE62 abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 def encode_base62(num: int) - str: 将整数编码为Base62字符串 if num 0: return BASE62[0] chars [] while num 0: chars.append(BASE62[num % 62]) num // 62 return .join(reversed(chars)) def generate_short_code() - str: 生成8位唯一短码 # 步骤1用当前毫秒时间戳13位 随机数3位生成16位种子 timestamp_ms int(time.time() * 1000) % 1000000000000000 random_suffix random.randint(100, 999) seed timestamp_ms * 1000 random_suffix # 步骤2Base62编码保证长度可控 code encode_base62(seed) # 步骤3截取8位不足补随机字符 if len(code) 8: code .join(random.choices(BASE62, k8-len(code))) return code[:8] # 在Link模型save()中调用 def save(self, *args, **kwargs): if not self.short_code: # 循环生成直到唯一最多尝试10次失败则抛异常 for _ in range(10): candidate generate_short_code() if not Link.objects.filter(short_codecandidate).exists(): self.short_code candidate break else: raise RuntimeError(无法生成唯一短码请检查数据库索引) super().save(*args, **kwargs)这个算法的保障长度恒定8位code[:8]确保UI一致时间局部有序同一毫秒生成的短码在Base62序列中相邻便于数据库索引优化碰撞率趋近于016位种子空间达10^16100万数据下理论碰撞概率0.0000001%。我们线上服务运行18个月累计生成2300万短码零碰撞。4.3 GraphQL查询实现从Hello World到生产就绪先写一个最简可用的查询验证基础链路# links/schema.py import graphene from graphene_django import DjangoObjectType from .models import Link class LinkType(DjangoObjectType): class Meta: model Link fields (id, short_code, original_url, clicks_count) class Query(graphene.ObjectType): all_links graphene.List(LinkType) link_by_code graphene.Field(LinkType, short_codegraphene.String(requiredTrue)) def resolve_all_links(self, info): return Link.objects.all() def resolve_link_by_code(self, info, short_code): try: return Link.objects.get(short_codeshort_code) except Link.DoesNotExist: return None # backend/schema.py import graphene from links.schema import Query as LinksQuery class Query(LinksQuery, graphene.ObjectType): pass schema graphene.Schema(queryQuery)启动服务后访问http://localhost:8000/graphql/执行query GetLink { linkByCode(shortCode: aB3xK9mL) { id shortCode originalUrl } }返回{ data: { linkByCode: { id: 1, shortCode: aB3xK9mL, originalUrl: https://example.com/course/101 } } }但这只是Demo。生产环境必须加入权限控制和性能监控# links/schema.py from django.contrib.auth.models import User from django.core.exceptions import PermissionDenied class LinkType(DjangoObjectType): class Meta: model Link fields (id, short_code, original_url, clicks_count, created_at) class Query(graphene.ObjectType): # 【权限】仅登录用户可查自己的链接 my_links graphene.List(LinkType) def resolve_my_links(self, info): if not info.context.user.is_authenticated: raise PermissionDenied(请先登录) return Link.objects.filter(created_byinfo.context.user) # 【性能】添加查询耗时监控 link_by_code graphene.Field( LinkType, short_codegraphene.String(requiredTrue), description根据短码查询带执行时间日志 ) def resolve_link_by_code(self, info, short_code): import time start_time time.time() try: link Link.objects.get(short_codeshort_code) # 记录慢查询100ms if time.time() - start_time 0.1: import logging logger logging.getLogger(__name__) logger.warning(fSlow query: link_by_code({short_code}) took {time.time()-start_time:.3f}s) return link except Link.DoesNotExist: return None注意resolve_link_by_code中不要用Link.objects.filter(short_code...).first()因为.filter()会生成SELECT *语句而.get()在命中索引时只查主键。我们压测发现.get()在100万数据下平均耗时0.8ms.filter().first()为1.2ms——别小看这0.4ms乘以每秒300次请求每天多消耗10.4GB内存。4.4 部署与性能调优从本地开发到生产环境本地python manage.py runserver只能应付开发生产必须用GunicornNGINX# 安装Gunicorn pip install gunicorn # 启动命令4个工作进程每个处理200并发 gunicorn backend.wsgi:application \ --bind 0.0.0.0:8000 \ --workers 4 \ --worker-class sync \ --timeout 30 \ --max-requests 1000 \ --access-logfile - \ --error-logfile -NGINX配置关键项# /etc/nginx/sites-available/shortener upstream django_app { server 127.0.0.1:8000; } server { listen 80; server_name short.example.com; # 【关键】短链跳转路径单独配置绕过Django中间件 location ~ ^/([a-zA-Z0-9]{8})/$ { proxy_pass http://django_app; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 直接透传不走Django的CSRF中间件 proxy_pass_request_headers on; } # 其他路径走标准Django流程 location / { proxy_pass http://django_app; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }性能调优三板斧数据库连接池Django默认每次请求新建连接。在settings.py中添加DATABASES { default: { # ...原有配置 CONN_MAX_AGE: 60, # 连接复用60秒 OPTIONS: { MAX_CONNS: 20, # 最大连接数 MIN_CONNS: 5, # 最小空闲连接 } } }Redis缓存热点数据为Link模型添加缓存层# links/models.py from django.core.cache import cache class Link(models.Model): # ...原有字段 classmethod def get_by_code(cls, short_code): cache_key flink:{short_code} link cache.get(cache_key) if link is None: try: link cls.objects.get(short_codeshort_code, is_activeTrue) # 缓存1小时短链很少修改 cache.set(cache_key, link, 3600) except cls.DoesNotExist: return None return link静态文件CDN化Django Admin的JS/CSS文件走Cloudflare# settings.py STATICFILES_STORAGE django.contrib.staticfiles.storage.ManifestStaticFilesStorage STATIC_URL https://cdn.example.com/static/ # 指向CDN域名我们线上环境4核8G云服务器实测指标跳转延迟P95 25ms含RedisMySQLGraphQL查询P95 80ms10万数据集并发承载稳定支撑1200QPSCPU使用率65%部署回滚从Git Tag切换版本5分钟内完成零停机。5. 常见问题与实战排障指南5.1 “短码生成重复”问题排查现象用户反馈“生成的短码打不开”后台查Link.objects.filter(short_codeabc123)返回空。排查路径检查数据库索引SELECT indexname, indexdef FROM pg_indexes WHERE tablename links_link;→ 若无short_code索引立即执行CREATE INDEX CONCURRENTLY idx_link_short_code ON links_link(short_code);检查生成逻辑是否绕过save()查Link.objects.create(short_codeabc123)调用位置create()不触发save()中的full_clean()和短码生成逻辑必须用Link(short_codeabc123).save()。检查事务隔离级别PostgreSQL默认READ COMMITTED但若用SELECT FOR UPDATE锁表可能阻塞生成。→ 改用乐观锁在generate_short_code()循环中捕获IntegrityError并重试。终极方案在数据库层加约束让错误提前暴露-- PostgreSQL中执行 ALTER TABLE links_link ADD CONSTRAINT chk_short_code_length CHECK (LENGTH(short_code) 8);5.2 “GraphQL查询超时”问题定位现象{ links(where: {shortCode_Icontains: abc}) }执行超30秒Gunicorn Worker被Kill。根因分析__icontains在无索引字段上触发全表扫描short_code虽有索引但LIKE %abc%无法使用B-Tree索引graphene-django默认将icontains转为ILIKEPostgreSQL需pg_trgm扩展支持。解决步骤启用PostgreSQL全文检索扩展CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX CONCURRENTLY idx_link_short_code_trgm ON links_link USING GIN (short_code gin_trgm_ops);在Django中显式指定索引class Link(models.Model): short_code models.CharField( max_length8, uniqueTrue, db_indexTrue, # 添加注释提醒DBA建GIN索引 help_text已配置GIN索引支持模糊搜索 )限制模糊