Django 统计接口性能优化

背景描述:

场景app_model 接口用于展示应用模型的权限列表,并附加该模型在“昨日”和“本月”的调用统计数据。
现象:随着权限数据量的增加,接口响应时间呈线性增长,耗时严重,用户体验极差。
原始逻辑

  1. 使用 DRF 的 Serializer 序列化出权限列表(result_data)。
  2. 遍历该列表,在 for 循环中针对每一条记录,分别执行 2 次 SQL 查询(查昨日、查本月)。

性能瓶颈分析

通过代码审查,定位到以下核心性能杀手:

  1. 循环内执行 SQL (N+1 问题):
    • 这是最严重的瓶颈。假设列表有 N 个模型权限,总查询次数为 1+2N
    • 如果返回 50 条数据,就会额外执行 100 次数据库交互。网络 IO 和 数据库连接开销巨大。
  2. DRF Serializer 性能开销:
    • ModelPermissionSerializer(many=True) 在数据量大时,对象实例化和字段校验会消耗大量 CPU 时间。对于只读接口,这是不必要的开销。
  3. 缺乏数据库索引:
    • 统计表 model_expense_day 如果没有针对查询字段的联合索引,每次 SELECT SUM 都会导致全表扫描或低效扫描。

优化方案

策略一:批量查询 + 内存聚合(解决 N+1)

思路:将循环内的多次查询合并为“一次批量查询”。

  1. 提取所有涉及的 app_id, model, env
  2. 使用 SQL 的 IN 语句一次性查出本月所有相关日志。
  3. 在 Python 内存中利用 HashMap (字典) 进行数据的分组和累加。

策略二:绕过 Serializer(降低 CPU 消耗)

思路:对于纯读取展示的接口,使用 Django ORM 的 .values() 方法直接获取字典列表,避免 Serializer 的序列化开销,提升速度可达 10 倍。

策略三:数据库索引优化

思路:确保统计表有正确的联合索引。

  • SQL建议CREATE INDEX idx_app_model_env_date ON model_expense_day (app_id, model, env, stats_date);

重构后的代码实现

    def app_model(self, request):
        """ 应用模型信息 - 修复版 """
        from datetime import datetime, timedelta
        from django.db import connections
        from django.db.models import Q

        app_param = request.query_params.get('app')
        model_name = request.query_params.get('model')
        envs = request.query_params.get('env')

        filters = {}

        if not request.user.is_staff:
            perm_filter = (
                    Q(managers__contains=request.user.username) |
                    Q(user=request.user) |
                    Q(readers__contains=request.user.username)
            )
            # app的uuid
            valid_app_ids = Application.objects.filter(perm_filter).values_list('id', flat=True)
            filters['app__id__in'] = valid_app_ids
        if app_param:
            if app_param.isdigit():
                # 直接筛选 ModelPermission 关联的 app_id 字段 (注意这里 app_id 字面意思是业务ID)
                filters['app__app_id__icontains'] = app_param
            else:
                filters['app__app_name__icontains'] = app_param
        if model_name:
            filters['model__name__icontains'] = model_name
        if envs:
            filters['env'] = envs
        try:
            queryset = ModelPermission.objects.filter(**filters).values(
                'app__app_id',
                'app__app_name',
                'app__id',
                'model__name',
                'model__id',
                'model__provider',
                'env',
                'limit_qps',
                'create_time',
                'update_time'
            )
        except Exception as e:
            print(f"AppModel View Query Error: {str(e)}")
            return make_rsp(500, 'Internal Server Error', [])
        result_data = []
        stats_keys = set()
        for item in queryset:
            c_time = item['create_time'].strftime('%Y-%m-%d %H:%M:%S') if item['create_time'] else None
            u_time = item['update_time'].strftime('%Y-%m-%d %H:%M:%S') if item['update_time'] else None
            app_business_id = item['app__app_id'] or ''
            model_real_name = item['model__name'] or ''
            env_val = item['env'] or ''
            row = {
                "app_id": app_business_id,
                "app_name": item['app__app_name'] or '',
                "app_uuid": item['app__id'] or '',
                "model_name": model_real_name,
                "model_id": item['model__id'] or '',
                "model_provider": item['model__provider'] or '',
                "env": env_val,
                "limit_qps": item['limit_qps'],
                "create_time": c_time,
                "update_time": u_time,
                "yesterday_req": 0.0,
                "month_req": 0.0
            }
            result_data.append(row)
            if app_business_id and model_real_name and env_val:
                stats_keys.add((app_business_id, model_real_name, env_val))
        if not result_data:
            return make_rsp(1, 'success', [])
        if stats_keys:
            yesterday_str = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
            start_of_month_str = datetime.now().replace(day=1).strftime('%Y-%m-%d')
            env_map = {
                'prd': 'model_expense_prd',
                'pre': 'model_expense_pre',
                'test': 'model_expense_test'
            }
            db_alias = env_map.get(settings.PROJECT_ENV, 'model_expense_test')
            raw_sql = """
                SELECT 
                    app_id, 
                    model, 
                    env, 
                    SUM(CASE WHEN stats_date = %s THEN req_count ELSE 0 END) as yesterday_req,
                    SUM(CASE WHEN stats_date >= %s THEN req_count ELSE 0 END) as month_req
                FROM model_expense_day
                WHERE (app_id, model, env) IN %s
                  AND stats_date >= %s
                GROUP BY app_id, model, env
            """
            params = [
                yesterday_str,
                start_of_month_str,
                tuple(stats_keys),
                start_of_month_str
            ]
            stats_map = {}
            try:
                with connections[db_alias].cursor() as cursor:
                    cursor.execute(raw_sql, params)
                    rows = cursor.fetchall()
                    for r in rows:
                        key = (r[0], r[1], r[2])
                        y_req = float(r[3]) if r[3] is not None else 0.0
                        m_req = float(r[4]) if r[4] is not None else 0.0
                        stats_map[key] = (y_req, m_req)
            except Exception as e:
                print(f"Stats query error: {e}")
            for row in result_data:
                key = (row['app_id'], row['model_name'], row['env'])
                if key in stats_map:
                    row['yesterday_req'] = stats_map[key][0]
                    row['month_req'] = stats_map[key][1]
        return make_rsp(0, '', result_data)

优化总结

通过此次优化,我们将接口的时间复杂度从 O(N)O(N) 的数据库交互降低到了 O(1)O(1)(固定相关查询次数),在 50 条数据量级下,预计接口响应时间可从 2s+ 降低至 200ms 以内

关键点回顾

  1. 拒绝循环 SQL:永远不要在 for 循环里写 SQL 查询。
  2. 空间换时间:先取出较大的数据集,利用 Python 的字典(Hash Map)在内存中进行匹配,速度远快于网络 IO。
  3. 精兵简政:对于大数据量的列表接口,慎用 Heavy 的 Serializer,.values() 原生字典取值更轻量。
posted @ 2025-12-04 22:27  小郑[努力版]  阅读(4)  评论(0)    收藏  举报