
简介云招聘系统设计.zip是一套基于Django框架构建的招聘信息采集与展示项目资源适合Python Web开发学习者、毕业设计或课程设计人群使用。资源围绕招聘数据全流程展开利用爬虫抓取职位信息通过ORM完成数据存储配合Ajax实现无刷新加载并借助ECharts呈现招聘趋势与热门职位等可视化图表。压缩包内含2000个文件以JavaScript、CSS、Python源码、HTML页面为主并包含示例数据、文本记录及配置文件等整体约27.25MB目录结构覆盖后端模型、视图、URL配置与前端交互逻辑便于按模块研读和二次扩展。目前已有94人学习下载对于想掌握Django实战、爬虫开发及数据可视化整合的开发者是一份完整度较高的参考案例。1. 先把这个 zip 拆开看云招聘系统到底是一套什么方案拿到“云招聘系统设计.zip”这种压缩包里面通常装的是三样东西一个 Django 工程目录、一套爬虫脚本、一份说明文档。标题里写得很直白——用 Django 框架做 Web 服务端再靠爬虫把招聘网站的职位信息抓回来自动入库最终在网页上提供搜索、筛选和展示。它的核心价值在于“动态数据源”不靠人工录入职位库自己能长出来。适合谁用毕业设计选型的学生、想快速搭一套垂直招聘站点的团队、以及需要把“爬虫 Web 展示”串成完整链路的 Django 初学者。这类系统最容易被低估的不是爬虫而是数据模型设计——职位、公司、城市之间怎么关联直接决定后面你能不能做复合筛选。先把这条主线想清楚再动手不迟。2. 用 Django 搭建招聘系统骨架项目结构、数据模型与后台配置2.1 为什么这个场景适合 DjangoMTV 架构对爬虫项目的天然匹配做招聘信息聚合系统核心诉求是“数据抓下来 → 入库 → 展示 → 可筛选”。Django 的 MTV 架构恰好把这三段拆成了清晰的分工Model 负责定义职位数据结构Template 负责页面渲染View 负责业务逻辑。相比 Flask 这类微框架Django 的 ORM、Admin 后台、迁移机制是开箱即用的——对于爬虫项目来说最值钱的就是 ORM 和 Admin。ORM 让你不用写原生 SQL 就能完成去重、筛选、聚合Admin 则直接给你一个免费的数据管理界面能可视化地检查爬虫抓回来的数据对不对。我一般会先建一个虚拟环境再动手避免把系统级的 Python 环境搅乱。Django 的安装和项目初始化是一个固定套路核心命令如下# 创建虚拟环境Windows 和 Linux 都适用 python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate # 安装 Django 和爬虫常用的 requests pip install django requests beautifulsoup4 lxml # 创建项目名为 recruit 的 Django 工程 django-admin startproject recruit . # 创建名为 jobs 的 app专门放职位相关的模型和视图 python manage.py startapp jobs这段命令里有两处需要说明第一startproject recruit .后面的点号表示在当前目录生成工程文件不加上会在recruit下再套一层recruit后面所有manage.py命令都要多绕一级目录比较别扭。第二爬虫依赖装在这里是因为我打算让爬虫脚本直接使用 Django 的 ORM 来写库这样爬虫抓到的数据能顺着同一个模型定义落库不用在爬虫和 Web 应用之间再搞一套数据同步。jobs这个 app 是系统的业务核心后续的模型、视图、URL 都围绕它展开。2.2 先设计好三张核心表职位、公司、城市招聘系统的数据模型最容易犯的错是“一张大表搞定一切”——把所有字段堆在职位表里。等到要做城市筛选时才发现冗余再拆表就痛苦了。常见做法是拆三张表Company存公司信息Job存职位信息通过外键关联到公司城市、学历、经验这类带枚举性质的字段单独抽出来做选项不建表用 Django 的choices约束。这样设计的目的是让“按公司查职位”和“按职位反查公司”都走索引而且爬虫更新数据时只动Job表公司信息不用反复写。# jobs/models.py from django.db import models class Company(models.Model): name models.CharField(max_length200, uniqueTrue, verbose_name公司名称) industry models.CharField(max_length100, blankTrue, verbose_name行业) size models.CharField(max_length50, blankTrue, verbose_name规模) created_at models.DateTimeField(auto_now_addTrue, verbose_name创建时间) class Meta: db_table recruit_company ordering [-created_at] def __str__(self): return self.name class Job(models.Model): # 学历和经验用 choices 约束避免脏数据混进来 EDUCATION_CHOICES [ (high_school, 高中), (college, 大专), (bachelor, 本科), (master, 硕士), (doctor, 博士), ] EXPERIENCE_CHOICES [ (intern, 在校生), (entry, 应届生), (junior, 1-3年), (mid, 3-5年), (senior, 5-10年), ] title models.CharField(max_length200, verbose_name职位名称) company models.ForeignKey(Company, on_deletemodels.CASCADE, related_namejobs, verbose_name所属公司) salary_min models.IntegerField(default0, verbose_name最低薪资(K)) salary_max models.IntegerField(default0, verbose_name最高薪资(K)) city models.CharField(max_length50, db_indexTrue, verbose_name工作城市) education models.CharField(max_length20, choicesEDUCATION_CHOICES, defaultbachelor, verbose_name学历要求) experience models.CharField(max_length20, choicesEXPERIENCE_CHOICES, defaultentry, verbose_name经验要求) description models.TextField(blankTrue, verbose_name职位描述) source_url models.URLField(uniqueTrue, verbose_name来源链接) created_at models.DateTimeField(auto_now_addTrue, verbose_name入库时间) updated_at models.DateTimeField(auto_nowTrue, verbose_name更新时间) class Meta: db_table recruit_job ordering [-created_at] indexes [ models.Index(fields[city, education, experience], namejob_city_edu_exp_idx), ] def __str__(self): return f{self.title} {self.company.name}这段模型有三个关键设计点需要展开说。第一source_url字段加了uniqueTrue这是整个系统防重复的底线——爬虫重复抓同一职位时靠这个字段做“已存在则更新否则新建”的判断后面讲爬虫时会用到。第二city字段加了db_indexTrue并且在Meta里建了city education experience的联合索引这是为了支撑招聘网站最常见的筛选场景用户先选城市再筛学历和经验。没有这个索引数据量过三万条后筛选接口会明显变慢。第三公司表用uniqueTrue约束公司名配合ForeignKey保证一个公司名下可以挂多个职位但不会出现同一家公司被爬虫写成两条记录的数据脏问题。2.3 迁移、Admin 注册与后台体验验证模型写完后需要把模型映射成数据库表。Django 的迁移机制在这一步的价值就体现出来了——不需要手动建库一条命令自动同步。我习惯把迁移和 Admin 注册放在一起做因为注册 Admin 后能在浏览器里直接检查数据这在爬虫开发阶段比任何调试工具都直观。# 生成迁移文件并执行迁移 python manage.py makemigrations jobs python manage.py migrate# jobs/admin.py from django.contrib import admin from .models import Company, Job admin.register(Company) class CompanyAdmin(admin.ModelAdmin): list_display (name, industry, size, created_at) search_fields (name,) admin.register(Job) class JobAdmin(admin.ModelAdmin): list_display (title, company, city, salary_min, salary_max, education, experience, updated_at) list_filter (city, education, experience) search_fields (title, company__name)这段 Admin 配置的作用不只是“后台能看数据”。list_filter按城市、学历、经验生成了右侧筛选栏爬虫跑完后你能在后台用鼠标点几下就完成数据质量检查——比如看看某个城市的职位数量是否合理学历分布是否和招聘市场的基本逻辑一致。search_fields里的company__name是 Django 的外键跨表查询语法搜索框里输入公司名能找到对应职位这在排查爬虫错误数据时很管用。Admin 后台还免费给了分页、排序、批量删除这些功能爬虫阶段几乎天天要用。3. 爬取招聘信息的两种方案Scrapy 框架与 requests 轻量脚本的选型对比3.1 先想清楚抓什么、去哪抓招聘网站的页面结构与数据埋点分析爬虫部分是整个系统里不确定性最高的一环。写爬虫前先明确两个问题抓什么字段、从哪个页面抓。职位详情页通常包含标题、公司、薪资、城市、学历、经验、描述这些字段要和上一章Job模型的字段逐一对应。至于来源网站常见做法是选招聘聚合站或公司官网的招聘频道因为它们页面结构相对规整反爬强度比头部招聘平台低适合学习落地和中小规模数据采集。我一般会先用浏览器的开发者工具F12看两个东西一是页面是服务端渲染还是 JavaScript 动态渲染二是列表页和详情页的 URL 规律。如果是服务端渲染直接解析 HTML 就能拿到数据如果是动态渲染就得追接口——按 F12 切到 Network 标签页刷新列表页找一个返回 JSON 数据的 XHR 请求那个就是数据接口。判断标准就一条如果页面里能看到的数据在 HTML 源码里搜不到说明是接口渲染得换思路。这一步花十分钟能省掉后面写完整套解析逻辑结果全是空数据的翻车风险。3.2 轻量方案requests BeautifulSoup适合数据量小、结构简单的场景如果目标网站是服务端渲染、页面结构稳定我推荐先用 requests BeautifulSoup 跑通全流程。这个方案依赖少、调试直观代码量控制在 200 行以内适合作为第一版实现。下面是一个典型的列表页 详情页两段式爬虫骨架# crawler/job_spider.py import time import random import requests from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept: text/html,application/xhtmlxml, } def fetch_page(url, retry3): 带重试的页面请求返回 HTML 文本 for attempt in range(retry): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp.text # 429 是限流503 是服务端过载都需要等待后重试 if resp.status_code in (429, 503): time.sleep(5 * (attempt 1)) except requests.RequestException: time.sleep(2) return None def parse_list_page(html): 解析列表页提取详情页 URL 列表 soup BeautifulSoup(html, lxml) # 常见做法是选中职位卡片容器再逐条提取链接 items soup.select(div.job-list-item a.job-title) return [item.get(href) for item in items if item.get(href)] def parse_detail_page(html): 解析详情页提取职位字段 soup BeautifulSoup(html, lxml) title soup.select_one(h1.job-title).get_text(stripTrue) city soup.select_one(span.job-city).get_text(stripTrue) salary_text soup.select_one(span.salary).get_text(stripTrue) # 薪资解析把15K-25K拆成数字便于数据库排序 # 用正则提取所有数字取第一个和最后一个作为上下限 import re nums re.findall(r\d, salary_text) salary_min int(nums[0]) if nums else 0 salary_max int(nums[-1]) if nums else 0 # 省略 company、education、experience 的同类提取逻辑 return { title: title, city: city, salary_min: salary_min, salary_max: salary_max, }这段代码里有两个参数值得专门说。第一个是timeout10设短了容易误判超时设长了在目标网站响应慢时会让整个爬虫卡死10 秒是相对稳妥的起始值。第二个是重试逻辑里的sleep(5 * (attempt 1))——第一次失败等 5 秒第二次等 10 秒这是最简单的退避策略。很多新手踩坑是失败后立即重试不仅没用还会加重对方服务器负担反而更容易被拉黑。parse_list_page里我用了 CSS 选择器div.job-list-item a.job-title这只是示例实际要根据目标站点的 class 名称调整——这也是爬虫代码里最常改动的地方。3.3 进阶方案Scrapy Item Pipeline适合数据量大、需要扩展的场景如果目标网站页面多、字段杂或者你预期要长期维护这个爬虫Scrapy 是更合适的选择。它的架构天然分了几个层Spider 负责解析页面、Item 定义数据结构、Pipeline 负责入库——这个分层思想和 Django 的 MTV 不谋而合。Scrapy 的并发下载、自动限速AutoThrottle、中间件机制能省掉自己写重试和防封的不少功夫。# spiders/job_spider.py import scrapy from scrapy.loader import ItemLoader from scrapy.loader.processors import MapCompose, TakeFirst def clean_salary(value): 清洗薪资字段15K-25K - (15, 25) import re nums re.findall(r\d, value or ) return {min: int(nums[0]), max: int(nums[-1])} if len(nums) 2 else {min: 0, max: 0} class JobSpider(scrapy.Spider): name job_spider start_urls [https://example.com/jobs] def parse(self, response): 列表页提取详情页链接并跟进分页 for job_url in response.css(a.job-title::attr(href)).getall(): yield response.follow(job_url, callbackself.parse_detail) # 翻页逻辑取下一页链接递归调用 parse next_page response.css(a.next::attr(href)).get() if next_page: yield response.follow(next_page, callbackself.parse) def parse_detail(self, response): 详情页用 ItemLoader 统一处理字段提取与清洗 loader ItemLoader(itemJobItem(), responseresponse) loader.default_output_processor TakeFirst() loader.add_css(title, h1.job-title::text) loader.add_css(city, span.job-city::text) loader.add_css(salary, span.salary::text, MapCompose(clean_salary)) item loader.load_item() item[source_url] response.url return itemScrapy 这段代码和 requests 版最大的区别在ItemLoader。它把“提取字段”和“清洗字段”拆成了独立逻辑add_css负责从页面里取原始文本MapCompose(clean_salary)负责把“15K-25K”这样的字符串清洗成结构化字典。这个设计的价值在于后续如果字段清洗规则变了只改处理函数不动提取逻辑。response.follow是 Scrapy 的相对 URL 处理方法比手动拼 URL 更安全。整体上Scrapy 的缺点是学习曲线比 requests 陡中间件的调试也比较依赖经验——但如果你判断这个爬虫要长期跑、数据量会持续增长这笔学习投入是值得的。3.4 入库策略把爬虫数据写进 Django 模型的正确姿势爬虫把数据抓回来后怎么高效写进数据库这是整个系统最容易出性能问题的地方。最朴素的做法是循环里一条条Job.objects.create()数据量小没问题但爬虫一轮抓几百上千条时这个方式不仅慢而且每插入一条都要走一次数据库会话压力全在数据库端。常见做法是改用bulk_create一次性批量写入。同时因为有source_url唯一约束入库时要先过滤掉已存在的记录避免唯一约束报错。# crawler/save_jobs.py import os import django os.environ.setdefault(DJANGO_SETTINGS_MODULE, recruit.settings) django.setup() from jobs.models import Company, Job def save_jobs_to_db(job_list): 批量入库存职位已存在则跳过 existing_urls set(Job.objects.filter( source_url__in[job[source_url] for job in job_list] ).values_list(source_url, flatTrue)) new_jobs [] for item in job_list: if item[source_url] in existing_urls: continue company, _ Company.objects.get_or_create(nameitem[company_name]) new_jobs.append(Job( titleitem[title], companycompany, salary_minitem[salary][min], salary_maxitem[salary][max], cityitem[city], educationitem[education], experienceitem[experience], descriptionitem.get(description, ), source_urlitem[source_url], )) if new_jobs: Job.objects.bulk_create(new_jobs, batch_size500) print(f新增 {len(new_jobs)} 条职位记录) else: print(无新增记录)这段入库代码里有几个细节要特别注意。第一行os.environ.setdefault(DJANGO_SETTINGS_MODULE, ...)是独立脚本使用 Django ORM 的固定写法——不设置环境变量直接导入模型会报AppRegistryNotReady错误。get_or_create是 Django 的原子操作先按name查公司查不到就创建返回(对象, 是否新建)二元组这里用下划线忽略了第二个值。bulk_create的batch_size500是分批参数一次写 500 条避免单条 SQL 过长导致数据库拒绝。这个入库函数要在爬虫脚本里被调用无论你用 requests 版还是 Scrapy 版最后都是把解析结果组装成job_list传给这个函数。4. 位系统避坑指南爬虫与 Django 集成的 5 个高频翻车现场4.1 现象爬到的数据中文乱码页面里全是“锟斤拷”原因目标网站的编码和 requests 解析编码不一致。大多招聘网站用的是UTF-8但部分老站点还在用GBK或GB2312。requests 在无charset声明时可能错误推断编码BeautifulSoup 拿到的是乱码文本存进数据库后就再也救不回来了。解决请求时显式指定编码或者在拿到响应后手动设置。在fetch_page函数里加一行resp.encoding resp.apparent_encoding用响应内容自动检测编码。如果已知目标站是 GBK直接写死resp.encoding gbk更可靠。存入数据库后要再验证打开 Django Admin随机抽几条记录看中文是否正常。这个检查在爬虫上线第一天就要做拖到数据量大了再发现清洗成本极高。4.2 现象爬虫跑了一会儿突然被对方服务器拒绝连接原因请求频率太高触发了对方的基础反爬机制。很多新手在循环里不加延迟地连续请求哪怕只开了几个线程对方服务器也能通过访问日志识别出异常行为。解决实现限速策略。requests 版在两次请求之间加随机休眠例如time.sleep(random.uniform(1, 3))模拟人工浏览节奏。Scrapy 版则直接在settings.py里配置DOWNLOAD_DELAY 2和AUTOTHROTTLE_ENABLED True后者会根据服务器响应速度自动调整请求频率。同时每个请求都带上真实浏览器的User-Agent并在请求头里补上Referer字段指向目标站的首页——很多站点会校验这个字段。如果依然被封考虑轮换 IP。4.3 现象职位数据抓回来但详情页的关键字段全为空原因列表页里直接能看到的字段抓到了但详情页的字段是 JavaScript 动态渲染的requests.get拿到的 HTML 里根本没有对应标签。用requests BeautifulSoup解析动态页面这是最常见的误解。解决先确认页面渲染方式再写解析逻辑。F12 打开 Network 面板刷新页面后搜关键词如果数据隐藏在名为api或ajax的 XHR 请求里直接请求那个 JSON 接口。不要硬用 HTML 解析。如果需要渲染后页面换selenium或playwright来抓但要注意这会显著降低抓取速度并且对服务器资源占用更大——能走接口就别模拟浏览器。4.4 现象python manage.py migrate报 “Table already exists” 错误原因用了旧数据库文件比如之前删了工程但没删 SQLite 库或者模型改过但迁移文件没同步。Django 的迁移系统依靠迁移文件记录状态如果数据库表还在但迁移记录丢了就会对不上。解决开发阶段最省事的做法是把本地db.sqlite3文件删掉重新执行makemigrations和migrate。如果已经线上有数据则不能删库——需要弄清楚是哪一次迁移出了问题用python manage.py showmigrations查看迁移状态再用python manage.py migrate app 迁移名回滚到指定版本。这条经验是开发期删库重启只要五秒比花半小时排查迁移冲突值多了上线前就不要再随便删了。4.5 现象爬虫脚本单独跑没问题在 Django 里运行时AppRegistryNotReady原因爬虫模块直接放在 Django 工程目录下但没有加载 Django 配置就调用了模型。Django 模型只有在应用注册完成后才能使用独立执行的脚本必须手动走初始化流程。解决在脚本文件顶部加上初始化代码和 3.4 节里save_jobs_to_db.py开头那两行一样——先设DJANGO_SETTINGS_MODULE再执行django.setup()。记住一个规律任何不经过manage.py启动直接执行的 Python 脚本只要用了 ORM都要做这两步初始化。否则第一行from jobs.models import Job就报错。5. 让系统“活”起来定时爬取、关键词搜索、部署上线5.1 用 Cron 定时跑爬虫让职位数据保持新鲜爬虫写完只在命令行手动跑一次招聘系统的数据就会停在上线那天。常见做法是配置系统级 Cron 任务每天凌晨定时执行爬虫脚本。这时爬虫脚本需要设计成可独立运行的程序而不是依赖手动触发。把 3.2 节的抓取逻辑和 3.4 节的入库逻辑整合到一个入口文件里# 编辑定时任务每天凌晨 2 点执行一次爬虫 crontab -e # 在打开的编辑器里加入以下一行 0 2 * * * cd /path/to/your/project /path/to/your/venv/bin/python crawler/run_spider.py /path/to/your/project/logs/spider.log 21这里有几个关键参数要解释0 2 * * *分表分时——第一个星号代表分钟0 分第二个代表小时2 点后面三个星号分别代表日、月、周全部匹配。是追加输出爬虫的print日志会被写进spider.log第二天排查跑没跑成功就看这个文件。cd /path/to/your/project不能省因为爬虫脚本里用了相对路径加载 Django 配置不在项目目录下跑会直接报找不到设置模块。定时任务调试时先把 Cron 时间设成当前时间后一分钟跑一次确认无误再改回凌晨。5.2 列表页与搜索实现Django ORM 的复合筛选接口数据入库后Web 端能不能快速筛出来取决于视图层的查询写法。招聘网站最常用的筛选组合是“城市 关键词 薪资范围”。Django 的 ORM 用链式filter能轻松实现但有几个隐蔽的性能坑。看下面的视图代码# jobs/views.py from django.db.models import Q from django.core.paginator import Paginator from django.shortcuts import render from .models import Job def job_list(request): # 从 GET 参数里取筛选条件 city request.GET.get(city, ) keyword request.GET.get(keyword, ) min_salary request.GET.get(min_salary, 0, typeint) max_salary request.GET.get(max_salary, 0, typeint) # 基础查询集先不加筛选条件方便后续链式调用 queryset Job.objects.select_related(company).all() # 城市筛选 if city: queryset queryset.filter(citycity) # 关键词筛选职位标题或公司名包含关键词 if keyword: queryset queryset.filter( Q(title__icontainskeyword) | Q(company__name__icontainskeyword) ) # 薪资筛选最低薪资 ≤ 期望上限且最高薪资 ≥ 期望下限 if min_salary: queryset queryset.filter(salary_max__gtemin_salary) if max_salary: queryset queryset.filter(salary_min__ltemax_salary) # 分页每页显示 20 条 paginator Paginator(queryset, 20) page_obj paginator.get_page(request.GET.get(page)) return render(request, jobs/list.html, {page_obj: page_obj})这段查询有两个经验点第一select_related(company)是必须的——职位列表页要显示公司名不加这个会触发 N1 查询数据量大时页面会明显卡顿。加了之后Django 会用一次 JOIN 把职位和公司一起查出来。第二薪资筛选的逻辑不是“薪资字段在范围内”而是“两个区间有交集”——用salary_max__gtemin_salary和salary_min__ltemax_salary两个条件取交集才能把“15K-25K”这类区间型数据筛出来。这才是符合真实招聘场景的筛选逻辑你按“最低薪资不超过期望上限且最高薪资不低于期望下限”来理解就对了。5.3 部署要点从 SQLite 到 PostgreSQL再配上 Gunicorn Nginx本地开发时 SQLite 够用但部署到公网服务器上就必须换 PostgreSQL。原因很简单SQLite 不支持并发写入爬虫在写库时用户查询会锁表。另外Django 的字段类型在不同数据库上有细微差异上线前要把迁移跑在新库上验证一遍。换库只需要改settings.py里的数据库配置# recruit/settings.py部署环境 DATABASES { default: { ENGINE: django.db.backends.postgresql, NAME: recruit_db, USER: recruit_user, PASSWORD: 你的强密码, HOST: 127.0.0.1, PORT: 5432, } }部署时我一般习惯这样安排Gunicorn 接管 Django 应用的 WSGI 接口对外监听 8000 端口Nginx 做反向代理监听 80/443 端口把请求转发给 Gunicorn同时托管静态文件。这样做的原因在于Nginx 处理静态文件的效率远高于 Django 自身而且能屏蔽掉直接访问 Gunicorn 的风险。静态文件要先执行python manage.py collectstatic把 Admin 后台的 CSS/JS 收集到指定目录否则打开后台会发现样式全丢。防火墙里要放行 80/443 端口同时关闭 8000 端口的外部访问——只允许本机回环访问 Gunicorn这一步千万不要省。5.4 数据要有后悔药定期导出备份与恢复流程爬虫系统最怕的不是网站改版导致抓不到数据而是数据库损坏或误操作清空了整张表。手动删除职位数据在 Admin 后台太容易误触发了。给自己留一条后悔药路径定期用 Django 的 dumpdata 命令导出全量数据# 导出数据为 JSON 文件 python manage.py dumpdata jobs --output backup_jobs_$(date %Y%m%d).json # 恢复数据的命令 python manage.py loaddata backup_jobs_20250101.jsondumpdata后面的jobs是 app 名只导出这个模块的数据。生成的 JSON 文件包含所有模型记录的完整字段loaddata能原样导回。这个命令有一点要特别注意导出的 JSON 里包含 id 主键恢复时如果库里已有数据会报唯一约束错误。所以恢复的正确姿势是先清空目标表python manage.py flush会清掉所有数据慎用再执行loaddata。我在实际项目中是把备份文件按日期归档在项目外的目录里——比如/backups/recruit/和代码仓库分开存储避免代码回滚时把备份也带没了。6. 进阶技巧用 Django 缓存与全文检索优化搜索体验系统数据量上万条后搜索和筛选的响应速度会开始被用户感知。除了加索引最实用的优化方案有两层第一层是给热门筛选条件加缓存第二层是引入真正的全文检索。先说缓存——Django 自带的缓存框架支持内存、文件、数据库多种后端优先用本地内存缓存LocMemCache零依赖、适合单机部署。改造一下搜索视图# jobs/views.py增加缓存 from django.core.cache import cache def job_list_with_cache(request): city request.GET.get(city, ) keyword request.GET.get(keyword, ) # 以筛选条件拼接的字符串作为缓存 key cache_key fjob_list_{city}_{keyword}_{request.GET.get(page, 1)} # 尝试从缓存取数据未命中时再查询 page_obj cache.get(cache_key) if page_obj is None: # 5.2 节中的完整查询逻辑写在这里 page_obj build_job_list_page(city, keyword) # 缓存 5 分钟 cache.set(cache_key, page_obj, 300) return render(request, jobs/list.html, {page_obj: page_obj})缓存的有效时间是 300 秒——爬虫每天只更新一次数据所以缓存 5 分钟完全不会影响数据新鲜度却能把热门城市页面的响应时间缩短十倍以上。缓存 key 必须包含所有筛选参数漏掉任何一个参数都会导致用户看到别人的搜索结果。这个我踩过坑第一次做缓存只用了城市做 key结果不同关键词的用户互相串数据排查了半天才发现是 key 设计的问题。全文检索方面icontains在数据量上两万条后就会明显变慢因为LIKE %keyword%无法走普通索引。生产级别推荐引入 PostgreSQL 的全文检索功能或者通过 Django 的SearchVector字段实现——这是 PostgreSQL 专用的SQLite 下无法使用。如果坚持用 SQLite 做轻量部署一个折衷方案是把职位标题拆成关键词存到单独的表里用精确匹配代替模糊匹配但这属于手工优化维护成本高。我的建议是系统有计划部署上线就一开始用 PostgreSQL省得中途换库还要改查询。最后一个习惯想分享给你我在上线任何爬虫系统前都会手动跑一次全流程从爬虫脚本到数据库查询到页面渲染然后把每一步的时间记下来。抓取 1000 条职位要多久、列表页带缓存和不带缓存各要多久这些数据比任何测试报告都直观。等到系统出问题需要排查时这些数字能快速帮你定位瓶颈是在网络层、数据库层还是页面渲染层。希望这篇笔记能帮你把这个系统顺利搭起来少走几趟我走过的弯路。本文还有配套的精品资源点击获取