所属专题:Python 专题导航:基础语法、工具与实践

Django 访问分析后台设计:从原始请求到状态码、耗时与留存策略

Django 访问分析后台设计:从原始请求到状态码、耗时与留存策略 - 暂无配图,技术文章默认封面

访问量只是访问记录最容易看到的一列。真正有用的后台还应该回答:请求是否成功、页面响应有多快、访问者大致来自哪里、它更像用户还是自动化客户端,以及这些数据应该保存多久。

本文以 Django 为例,整理一套可落地的访问分析后台设计。重点不是把每一个 IP 都贴上绝对标签,而是建立可查询、可复核、可定期清理的数据链路。关于 User-Agent、GeoIP 和爬虫分类的基本边界,可以先参考SEO访问日志分析实战:识别人类用户、搜索引擎与AI爬虫;本文继续解决“如何把这些判断做成后台功能”。

一、访问记录要回答五个问题

访问日志字段越多不一定越好。每个字段都应当服务于一个明确的问题:

问题 可用字段 用途
请求访问了什么 path、request_method 排查热门页面、扫描路径和异常方法
请求是否成功 status_code、content_type 区分正常访问、重定向和错误页面
响应是否足够快 response_time_ms 找出慢页面,避免只看平均值
访问者来自哪里 country、city、country_code 做区域分布的近似分析
更像谁 visitor_type、bot_name、bot_confidence 区分用户、搜索爬虫、AI 爬虫和扫描器

原始记录和统计指标要分开理解。原始记录适合定位单次异常,统计结果适合观察趋势;“独立 IP”是去重后的地址数量,不等于真实用户数,同一个人可能使用多个网络,同一个代理也可能代表很多人。

二、先设计可演进的数据模型

访问记录通常会从一个简单的 IP 表逐渐变成分析表。下面是一个精简示例,实际项目可以根据隐私要求删减字段:

from django.db import models


class Visitor(models.Model):
    ip_address = models.GenericIPAddressField()
    user_agent = models.TextField(blank=True)
    request_method = models.CharField(max_length=32)
    path = models.CharField(max_length=2048)
    referer = models.CharField(max_length=2048, blank=True)
    timestamp = models.DateTimeField(auto_now_add=True, db_index=True)

    country = models.CharField(max_length=100, blank=True, null=True)
    city = models.CharField(max_length=100, blank=True, null=True)
    country_code = models.CharField(max_length=2, blank=True)
    geo_source = models.CharField(max_length=20, blank=True)

    visitor_type = models.CharField(max_length=20, default="unknown", db_index=True)
    is_bot = models.BooleanField(default=False)
    bot_name = models.CharField(max_length=80, blank=True)
    bot_confidence = models.CharField(max_length=10, default="low", db_index=True)
    classification_source = models.CharField(max_length=20, blank=True)
    classification_reason = models.CharField(max_length=200, blank=True)
    device_type = models.CharField(max_length=20, blank=True)
    device_os = models.CharField(max_length=40, blank=True)
    browser = models.CharField(max_length=60, blank=True)

    status_code = models.PositiveSmallIntegerField(default=200, db_index=True)
    response_time_ms = models.PositiveIntegerField(default=0)
    content_type = models.CharField(max_length=100, blank=True)

这里有几个容易被忽略的细节:

  1. request_method 不要只预留 10 个字符。正常请求大多是 GET 或 POST,但扫描器可能发送非标准方法,长度太小会让严格数据库迁移失败。
  2. country_code 比国家名称更适合聚合。名称可以有中文、英文和旧数据混用,代码应统一为大写 ISO 代码。
  3. bot_confidence 和 classification_reason 要和 visitor_type 一起保存。后台只显示“爬虫”而不显示依据,会让使用者误以为这是事实验证。
  4. 给 timestamp、visitor_type、status_code 建索引。国家和时间的组合索引适合常用的国家筛选,但不应为每个展示字段都建立索引。

三、请求结束后再写入记录

中间件的时序会直接影响状态码和响应耗时。如果在调用视图前保存,记录到的状态码只能是预设值,也无法知道模板渲染和数据库查询花了多久。

import logging
import time
from django.db import DatabaseError
from .models import Visitor


logger = logging.getLogger(__name__)


class VisitorTrackingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def should_track(self, path):
        ignored = ("/admin/", "/static/", "/media/", "/healthz", "/robots.txt", "/sitemap")
        return not path.startswith(ignored)

    def get_client_ip(self, request):
        # 生产环境应只在 REMOTE_ADDR 属于可信代理时读取转发头。
        return request.META.get("REMOTE_ADDR") or "0.0.0.0"

    def __call__(self, request):
        if not self.should_track(request.path):
            return self.get_response(request)

        started = time.perf_counter()
        classification = classify_user_agent(
            request.META.get("HTTP_USER_AGENT", ""),
            request.path,
        )
        response = None
        try:
            response = self.get_response(request)
            return response
        except Exception:
            self.save_record(request, classification, started, None)
            raise
        finally:
            if response is not None:
                self.save_record(request, classification, started, response)

    def save_record(self, request, classification, started, response):
        try:
            Visitor.objects.create(
                ip_address=self.get_client_ip(request),
                user_agent=request.META.get("HTTP_USER_AGENT", ""),
                request_method=request.method[:32],
                path=request.path[:2048],
                referer=request.META.get("HTTP_REFERER", "")[:2048],
                status_code=getattr(response, "status_code", 500),
                response_time_ms=round((time.perf_counter() - started) * 1000),
                content_type=(response.get("Content-Type", "") if response else "")[:100],
                **classification,
            )
        except DatabaseError:
            # 记录失败不能让正常页面变成 500。
            logger.exception("visitor record was not saved")

上面的 classify_user_agent 函数在第五节给出,Visitor 是本节模型。为了让代理头不能被任意客户端伪造,get_client_ip 的简化实现只使用直接连接地址;接入可信反向代理后,再按代理白名单读取 X-Real-IP 或 X-Forwarded-For,并逐个校验地址格式。

生产环境还需要排除管理后台、静态文件、媒体文件、健康检查、robots 和 sitemap 等路径,否则统计会被系统请求淹没。排除路径只是降噪,不应当用“是否为爬虫”决定是否记录;搜索爬虫的访问同样是 SEO 诊断数据。

异常请求要保留 500 状态的记录,但不能把异常对象或完整堆栈写进公开的访问表。详细错误仍然交给应用日志,访问表只保存定位请求所需的最小字段。

四、GeoIP 只能提供近似位置

本地 GeoIP 数据库适合做国家和城市的粗粒度统计,不适合确认个人住址。查询时要同时兼容标准 MaxMind 结构和自定义导出结构,因为不同数据库版本可能出现以下差异:

from django.conf import settings
import maxminddb

# maxminddb.open_database 返回的 reader.get(ip) 是字典或 None。
reader = maxminddb.open_database(settings.GEOIP_DATABASE_PATH)

def lookup_geo(ip):
    record = reader.get(ip) or {}
    country_data = record.get("country") or {}
    city_data = record.get("city") or {}

    if isinstance(country_data, dict) and not city_data:
        city_data = country_data.get("city") or {}

    def read_name(value, fallback_key):
        if isinstance(value, dict):
            names = value.get("names") or {}
            if isinstance(names, dict):
                return names.get("zh-CN") or names.get("en")
            if isinstance(value.get(fallback_key), str):
                return value[fallback_key]
        return value if isinstance(value, str) else None

    country = read_name(country_data, "country")
    city = read_name(city_data, "city")
    country_code = ""
    if isinstance(country_data, dict):
        country_code = (
            country_data.get("iso_code")
            or country_data.get("en_short_code")
            or ""
        ).upper()
    return {"country": country or "", "city": city or "", "country_code": country_code}

如果使用 geoip2.database.Reader,不要调用 get;应改用 reader.city(ip),再从返回对象的 country.names、country.iso_code 和 city.names 读取字段。两种库的返回类型不同,项目中应固定一种接口,避免把对象当字典处理。

查不到国家或城市时应保存空值,而不是随意填入“未知国家”。空值可能表示数据库没有覆盖该地址、地址属于代理或网络出口,也可能是 IPv6 或私网地址。后台统计需要把“无定位数据”单独列出。

五、分类结果必须能解释

User-Agent 是客户端自己发送的文本,任何客户端都可以伪造。因此可以用它做初筛,不能把它当成真实身份验证。一个可复核的分类顺序如下:

  1. 匹配明确的搜索引擎或 AI 客户端标记,记录为高置信度的 UA 命中。
  2. 匹配通用的 bot、spider 或 crawler 标记,记录为中置信度。
  3. 命中 /.env、/.git/、wp-login.php、xmlrpc.php 等明显扫描路径时,记录路径依据。
  4. 浏览器样式的 UA 默认视为低置信度用户,不能据此证明一定是真人。
  5. 缺少 UA 的请求归为未知客户端,不要强行归入用户或爬虫。
def classify_user_agent(user_agent, path=""):
    normalized = (user_agent or "").lower()
    if "googlebot" in normalized:
        return {
            "is_bot": True,
            "visitor_type": "search_bot",
            "bot_name": "Googlebot",
            "bot_confidence": "high",
            "classification_source": "known_ua",
            "classification_reason": "匹配明确的 Googlebot 标记",
        }

    suspicious = ("/.env", "/.git/", "/wp-login.php", "/xmlrpc.php")
    marker = next((item for item in suspicious if item in path.lower()), None)
    if marker:
        return {
            "is_bot": True,
            "visitor_type": "scanner",
            "bot_name": "路径扫描",
            "bot_confidence": "medium",
            "classification_source": "path",
            "classification_reason": f"命中可疑路径:{marker}",
        }

    return {
        "is_bot": False,
        "visitor_type": "unknown",
        "bot_name": "",
        "bot_confidence": "low",
        "classification_source": "missing_ua" if not normalized else "browser_ua",
        "classification_reason": "仅依据客户端文本推断,未进行来源验证",
    }

如果业务确实需要确认某个搜索引擎身份,还要结合反向 DNS、官方 IP 段或搜索平台抓取报告进行二次核验。后台可以把这类验证结果单独作为字段,不能悄悄提高启发式规则的置信度。

六、后台统计要服务于排查

访问记录后台至少应提供四组能力:

  • 按 1、7、30 天查看访问量、独立 IP、用户请求、机器人请求、4xx/5xx 和平均响应耗时。
  • 按国家、设备、操作系统、浏览器、访问类型、机器人名称和热门路径排行。
  • 按状态码、置信度、国家、设备和关键词筛选单条记录。
  • 点击统计数字后能够回到对应的明细,而不是只展示一个无法解释的总数。

Django 聚合查询可以从一个筛选后的 QuerySet 开始:

from django.db.models import Avg, Count, Q

robot_types = ("search_bot", "ai_bot", "automation", "scanner")
summary = queryset.aggregate(
    total=Count("id"),
    unique_ips=Count("ip_address", distinct=True),
    human=Count("id", filter=Q(visitor_type="human", is_bot=False)),
    bots=Count("id", filter=Q(is_bot=True)),
    errors=Count("id", filter=Q(status_code__gte=400)),
    average_ms=Avg("response_time_ms", filter=Q(response_time_ms__gt=0)),
)

top_paths = (
    queryset.values("path")
    .annotate(total=Count("id"))
    .order_by("-total", "path")[:10]
)

平均耗时适合快速概览,但慢请求排查还应观察 P95 或 P99。SQLite 小站可以先用分段统计替代精确百分位,例如统计超过 500ms、1s 和 3s 的请求数;数据量增大后,再把聚合任务移到专用分析库。

七、30 天留存要和备份一起规划

保存期限应出现在隐私政策中,并由自动任务执行,而不是依赖管理员手工删除。清理命令要使用明确的截止时间、事务和互斥锁:

from datetime import timedelta
from django.db import transaction
from django.utils import timezone

cutoff = timezone.now() - timedelta(days=30)
with transaction.atomic():
    Visitor.objects.filter(timestamp__lt=cutoff).delete()

生产任务还应做到:

  1. 使用 flock 防止清理任务重叠。
  2. 先提供 --dry-run,输出待清理数量。
  3. 清理在线库后检查 SQLite 完整性。
  4. 明确哪些数据库备份是可变归档,哪些是不可变回滚材料;不要让普通应用用户误删 root 所有的发布备份。
  5. 在日志中记录截止日期、删除数量和失败原因,但不要记录完整 IP 或 User-Agent。

访问记录和备份的保存范围必须能向用户解释。若备份因为回滚需求保留更久,就应在运维策略中规定访问权限和最终销毁时间。

八、测试和上线检查

至少覆盖以下测试:

  • 普通浏览器请求能保存 200 状态、响应类型和耗时。
  • 视图抛出异常时能保存 500 状态,且异常继续交给 Django 处理。
  • 明确搜索爬虫、通用爬虫、可疑路径和缺少 UA 的分类结果符合预期。
  • 标准 GeoIP、英文名称、空记录和自定义数据库结构都不会导致请求失败。
  • 日期范围、状态码、国家和关键词筛选在列表与概览页得到相同结果。
  • 清理命令的 dry-run 不删除数据,正式运行只删除截止日期以前的记录。

上线顺序可以保持简单:先备份数据库,再迁移字段和索引;执行 Django system check 和 tracking 测试;用一条真实请求确认状态码、耗时和 GeoIP;最后观察 SQLite 锁、应用错误日志和后台页面。不要在没有备份的情况下直接对十几万条历史记录做全量重算,应该先用小批次观察耗时,再按主键分段处理。

结语:让记录支持决策,而不是制造标签

访问分析后台的价值不在于收集尽可能多的字段,而在于让每个数字都能追溯到一条请求,让每个分类都显示判断依据,让每项保留策略都能被用户理解。状态码告诉你页面是否可用,响应耗时告诉你哪里需要优化,GeoIP 提供近似分布,User-Agent 只提供待核验的线索。

完成后台后,可以继续用SEO 发布后监控实战观察搜索爬虫是否真正请求新页面,用Django 部署后的日志排查与性能监控处理应用层异常,并结合SEO 进阶测量与验证区分“已提交”“已抓取”和“已收录”。如果站内链接发生变化,再用站内链接审计实战检查孤立页面和断链;后台输入和查询安全还可以参考Django 博客站内搜索实战。涉及 IP、User-Agent 和保存期限时,应同步检查站点的隐私政策。

分享这篇文章:

评论 (0)

请 登录 后发表评论, 还没有账户?立即注册

暂无评论,快来抢沙发吧!