告别丑陋后台:某电商订单系统Admin现代化改造实录

半年前,运营同学盯着Django Admin默认的订单列表页,抱怨说找个用户订单得翻七八页,筛选条件只有那几个基础字段,而且整个页面还是那个十几年没变过的jQuery UI风格,点一下筛选按钮整个页面白屏闪一下,体验极差。我当时正喝着咖啡,心想这确实该改改了,毕竟我们用的已经是Django 5.1(2024年8月刚发布的版本),但Admin前端还是老样子。

我们那个订单系统当时每天新增大概3万笔订单,运营需要频繁根据手机号、订单状态、下单时间范围组合筛选。默认的Admin列表页加载一次要1.2秒左右,而且筛选条件不支持模糊搜索。我第一件事就是去看了Django 5.1的Admin源码,发现它虽然还是基于jQuery,但已经预留了一些钩子可以替换部分前端组件。

我决定先不动整体架构,从最影响体验的搜索和筛选入手。首先给OrderAdmin加自定义筛选器,支持手机号模糊匹配。原来的admin.SimpleListFilter只能精确匹配,我重写了lookupsqueryset方法:

from django.contrib import admin from django.db.models import Q from .models import Order class PhoneNumberFilter(admin.SimpleListFilter): title = '手机号' parameter_name = 'phone' def lookups(self, request, model_admin): # 这里不返回固定选项,因为手机号是动态的 return () def queryset(self, request, queryset): if self.value(): # 支持模糊搜索,比如输入138就能匹配所有138开头的 return queryset.filter(user__phone__icontains=self.value()) return queryset @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ('order_id', 'user_phone', 'total_amount', 'status', 'created_at') list_filter = (PhoneNumberFilter, 'status', 'created_at') search_fields = ('order_id', 'user__phone') def user_phone(self, obj): return obj.user.phone user_phone.short_description = '手机号'

改完这个,运营同学输入手机号前三位就能筛出结果,不用再翻页了。但列表页加载还是慢,因为默认的Admin会把所有关联字段都查出来。我们的订单关联了用户、商品、优惠券三个表,原来一个列表页会发出4条SQL查询。我用了list_select_relatedprefetch_related优化:

class OrderAdmin(admin.ModelAdmin): # ... 其他配置 list_select_related = ('user',) # 外键user直接JOIN查询 prefetch_related = ('items',) # 多对多的订单商品用prefetch def get_queryset(self, request): qs = super().get_queryset(request) return qs.select_related('user', 'coupon').prefetch_related('items__product')

优化后,列表页SQL查询从4条降到2条,加载时间从1.2秒降到400毫秒左右。但运营还是嫌页面丑,尤其是那个日期筛选器,还是老式的文本框输入格式。我查了Django 5.1的文档,发现Admin现在支持自定义Media类来引入现代前端库。我偷偷把jQuery UI的日期选择器换成了Flatpickr,只需要在Admin类里加:

class OrderAdmin(admin.ModelAdmin): # ... 其他配置 class Media: css = { 'all': ('https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css',) } js = ('https://cdn.jsdelivr.net/npm/flatpickr', 'admin/js/order_custom.js')

然后在order_custom.js里写几行代码替换默认日期输入框:

document.addEventListener('DOMContentLoaded', function() { // 替换所有日期筛选输入框 document.querySelectorAll('input[type="date"]').forEach(input => { flatpickr(input, { dateFormat: "Y-m-d", enableTime: false, allowInput: true }); }); });

改完这些,运营同学终于不说页面丑了。但我知道这只是表面功夫,社区里现在都在讨论Admin的现代化改造,有人甚至想把整个Admin用React重写。不过对于我们这种小团队,在Django 5.1的基础上做局部优化,性价比最高。毕竟大促期间,订单量峰值能到每秒50笔,Admin稳定性比花哨的功能更重要。

Django 5.1 异步视图与ORM性能对决:Prefetch vs Select实测

上个月我们API服务的订单详情接口突然变慢,从平均80ms涨到300ms,QPS从200掉到80。我盯着监控面板,发现是ORM查询的问题。当时我们正在把部分视图改成Django 5.1支持的异步视图,顺便测试了一下异步环境下select_relatedprefetch_related的表现。

先说说背景,我们的订单详情接口需要返回订单基本信息、关联的用户资料、订单里的商品列表以及每个商品的详情。原来的同步视图里,我用了select_related('user')prefetch_related('items__product'),代码是这样的:

# 同步视图 from django.http import JsonResponse from .models import Order def order_detail_sync(request, order_id): order = Order.objects.select_related('user').prefetch_related('items__product').get(id=order_id) data = { 'order_id': order.id, 'user_name': order.user.name, 'items': [{'product_name': item.product.name, 'quantity': item.quantity} for item in order.items.all()] } return JsonResponse(data)

这个同步版本在100并发测试下,平均响应时间是120ms,但Django 5.1已经支持异步视图了,我寻思着改成异步会不会更快?于是我写了个异步版本:

# 异步视图 import asyncio from django.http import JsonResponse from django.db import sync_to_async from .models import Order @sync_to_async def get_order_data(order_id): order = Order.objects.select_related('user').prefetch_related('items__product').get(id=order_id) return { 'order_id': order.id, 'user_name': order.user.name, 'items': [{'product_name': item.product.name, 'quantity': item.quantity} for item in order.items.all()] } async def order_detail_async(request, order_id): order_data = await get_order_data(order_id) return JsonResponse(order_data)

测试结果让我有点意外,异步版本在100并发下平均响应时间反而涨到了180ms。我查了Django 5.1的异步文档,发现select_relatedprefetch_related在异步环境下并没有什么魔法优化,因为ORM的异步支持还在完善中,sync_to_async本质上还是把同步代码包了一层,多了上下文切换的开销。

然后我专门做了个对比测试,用同样的查询,分别测select_related(用于外键一对一关系)和prefetch_related(用于多对多或反向外键)的性能。测试数据是我用Faker生成的10万条订单,每个订单关联1个用户、3个商品项。

# 测试代码 import time from django.test import TestCase from .models import Order class ORMPrefetchTest(TestCase): def test_select_vs_prefetch(self): # 测试select_related(外键user) start = time.time() orders = Order.objects.select_related('user')[:1000] for order in orders: _ = order.user.name # 触发查询 select_time = time.time() - start # 测试prefetch_related(多对多items) start = time.time() orders = Order.objects.prefetch_related('items')[:1000] for order in orders: _ = list(order.items.all()) # 触发prefetch prefetch_time = time.time() - start print(f"select_related 1000次查询耗时: {select_time:.3f}s") print(f"prefetch_related 1000次查询耗时: {prefetch_time:.3f}s")

测试结果:select_related耗时0.12秒,prefetch_related耗时0.28秒。为啥差这么多?因为select_related是用SQL的JOIN一次性把用户数据查出来,而prefetch_related是先查订单,再根据订单ID列表查商品项,多了一次数据库往返。但如果我不用prefetch_related,直接访问order.items.all(),每个订单都会发一条SQL查询,1000个订单就是1000条SQL,耗时直接飙到2.1秒。

所以结论是:外键关系用select_related,多对多用prefetch_related,别搞混。异步视图现在还不适合直接套ORM查询,除非你用的是Django 5.1以后完全异步的ORM(目前还在开发中)。我们后来还是把那个接口改回了同步视图,但加了数据库索引,响应时间稳定在90ms左右。

踩坑实录:百万级数据下Admin页面的卡顿排查与索引优化

今年年初,我们的用户行为日志表数据量突破100万条,Admin后台的日志列表页直接卡到打不开,点击进去要等5秒以上,有时候直接超时。我当时正准备下班,被运营同学叫住说日志页面崩了,只好坐回来打开电脑排查。

先看看这个日志表的结构,模型定义大概是这样的:

from django.db import models class UserLog(models.Model): user = models.ForeignKey('auth.User', on_delete=models.CASCADE) action = models.CharField(max_length=50) ip_address = models.GenericIPAddressField() created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-created_at'] # 默认按创建时间倒序

Admin配置也很简单,就是注册了一下:

@admin.register(UserLog) class UserLogAdmin(admin.ModelAdmin): list_display = ('user', 'action', 'ip_address', 'created_at') list_filter = ('action', 'created_at') search_fields = ('user__username', 'ip_address')

我打开Django的DEBUG工具栏,看了一下列表页的SQL查询,发现一条查询耗时4.2秒,SQL语句是这样的:

SELECT * FROM `user_log` WHERE `user_log`.`action` = 'login' ORDER BY `user_log`.`created_at` DESC LIMIT 100;

EXPLAIN了一下这条SQL,发现它做了全表扫描(type: ALL),因为actioncreated_at字段上都没有索引。100万条数据全表扫描,能不慢吗?

我第一反应是给actioncreated_at加索引,但Django的ORM加索引有两种方式:一种是在模型字段里加db_index=True,另一种是用Meta里的indexes选项。我选择了后者,因为可以建联合索引,更适合我们的查询场景:

class UserLog(models.Model): # ... 字段定义不变 class Meta: ordering = ['-created_at'] indexes = [ models.Index(fields=['action', 'created_at']), # 联合索引,匹配筛选+排序 models.Index(fields=['created_at']), # 单独给排序字段加索引 models.Index(fields=['ip_address']), # 搜索字段加索引 ]

跑完makemigrationsmigrate之后,索引建好了,再测那条SQL,耗时降到了80ms。但列表页还是有点慢,大概1.2秒。我再看SQL,发现还有个问题:Admin默认的count()查询,Django会执行SELECT COUNT(*) FROM user_log,这条在全表情况下也要耗时300ms。

我查了Django 5.1的Admin文档,发现可以用show_full_result_count = False来禁用底部的总记录数显示,这样就不会执行那个COUNT查询了。另外,我还重写了get_queryset方法,限制默认查询的时间范围,避免一进去就加载所有数据:

from datetime import datetime, timedelta @admin.register(UserLog) class UserLogAdmin(admin.ModelAdmin): # ... 其他配置 show_full_result_count = False # 不显示总记录数 date_hierarchy = 'created_at' # 添加日期下钻导航 def get_queryset(self, request): qs = super().get_queryset(request) # 默认只显示最近7天的日志,不然数据量太大 seven_days_ago = datetime.now() - timedelta(days=7) return qs.filter(created_at__gte=seven_days_ago)

改完这些,列表页加载时间稳定在200ms左右,运营同学再也不抱怨卡了。但我后来发现一个问题,给created_at加索引之后,插入日志的速度变慢了,原来每秒能插500条,现在只能插350条。这是因为每次插入都要更新索引,有性能开销。

我权衡了一下,日志表主要是写多读少,但Admin查询是运营同学每天都要用的,所以还是保留索引,只是把联合索引的顺序调整了一下,把action放在前面,因为筛选action的场景比按时间排序更频繁。调整后插入速度恢复到每秒420条,查询速度也没受太大影响。

这次排查让我明白,Admin页面卡顿不一定是大问题,大多是索引缺失或者查询没优化。Django 5.1的ORM虽然好用,但面对百万级数据时,还是得自己关注SQL层面的优化,不能全指望框架。

4. 从Flask迁移到Django:为何Admin与Auth系统让我选择了它

去年接手了一个跑了三年的内部数据运营平台,原版是用Flask 1.1.4写的,当时接手的时候代码量大概在1.2万行左右。这个系统主要管着我们公司大概80万条的商品SKU数据,还有运营每天手动录入的促销规则。最开始Flask确实轻快,一个app.py就能跑起来,但到了2023年底,问题开始集中爆发。

最让我头疼的是权限管理。原Flask项目里,权限判断全靠在视图函数里写if session.get('role') == 'admin',后来运营角色从2个变成了6个(比如商品运营、活动运营、数据审核员),代码里到处都是这种硬编码的判断。有一次运营配置了一个错误的促销规则,导致线上出现了0元单,排查下来发现是因为某个视图函数漏写了权限校验,任何人只要知道URL就能访问。那时候我就意识到,这种散落在各处的权限逻辑,维护成本太高了。

另一个痛点是后台管理。Flask虽然可以用Flask-Admin,但那个东西定制起来太费劲。我们当时需要运营能批量导入Excel的商品数据,还要在列表页直接看到每个商品的实时库存(从另一个库存系统API拉取)。Flask-Admin的列表页只能展示模型字段,要加个外部API的数据,得重写整个视图类,还要处理分页、排序的逻辑。我当时花了大概一周时间才勉强搞定,代码写得跟补丁一样。

后来我们决定迁移到Django 5.1,这个版本是2024年8月刚发布的,我当时选它是因为看中了内置的Auth系统和Admin后台。迁移过程其实没想象中那么痛苦,大概花了三周时间,核心逻辑重写,Admin配置基本是重新写的。

先说Auth系统,Django的User、Group、Permission是开箱即用的。我们原来的6个角色,直接对应到Django的Group,每个Group配置具体的Model权限。比如商品运营组,我只需要在Admin后台勾选Product模型的addchange权限,他们登录后就只能看到商品相关的菜单,而且操作按钮也会自动隐藏删除权限。这比之前在Flask里手动写中间件判断省了至少80%的权限相关代码。我们现在的用户量大概有120个内部运营,权限配置从原来的每次新增角色要改3-4个文件,变成了现在Admin后台点几下就行。

再说Admin后台,这才是真正让我觉得值的地方。迁移后,我们的商品管理页面,原来Flask-Admin里要写200多行代码的功能,Django Admin里几十行配置就搞定了。比如运营需要批量更新商品的上下架状态,我只需要加一个actions配置:

# admin.py from django.contrib import admin from .models import Product import requests @admin.register(Product) class ProductAdmin(admin.ModelAdmin): list_display = ('sku', 'name', 'price', 'stock_status', 'is_active') list_filter = ('is_active', 'category') search_fields = ('sku', 'name') actions = ['update_stock_status', 'batch_activate'] # 自定义列:显示实时库存状态(从库存API获取) def stock_status(self, obj): try: # 假设库存API地址,实际项目会配到settings里 resp = requests.get(f'http://inventory-api/v1/stock/{obj.sku}', timeout=0.5) if resp.status_code == 200: stock = resp.json().get('stock', 0) return f'库存{stock}件' return '获取失败' except Exception: return 'API异常' stock_status.short_description = '实时库存' # 自定义批量操作:更新库存状态 def update_stock_status(self, request, queryset): # 这里可以调用批量更新逻辑,比如触发异步任务 count = queryset.count() self.message_user(request, f'已触发{count}个商品的库存更新任务') update_stock_status.short_description = '批量更新库存状态' # 批量上下架 def batch_activate(self, request, queryset): queryset.update(is_active=True) self.message_user(request, f'成功上架{queryset.count()}个商品') batch_activate.short_description = '批量上架'

这个代码里,stock_status方法直接把外部API的数据整合到了列表页,而且不影响Admin自带的分页和排序。原来在Flask里要实现这个,得重写index_view,还要处理分页参数和排序参数,代码量至少是现在的3倍。

迁移后最直观的变化是,运营提的后台需求,我以前平均要2天才能搞定,现在大部分配置一下就能上线。比如上个月运营要加一个“即将售罄”的筛选条件,我只在list_filter里加了个自定义的StockFilter,半天就完成了。如果还是用Flask,估计又得改视图、改模板、改权限判断。

还有一个细节,Django 5.1的Auth系统支持更灵活的用户模型扩展。我们后来需要用户绑定企业微信ID,只需要在settings里配置AUTH_USER_MODEL指向我们自定义的User模型,原来的Permission、Group逻辑完全不用动。这要是在Flask里,可能得重新设计用户表结构,还要改所有引用用户的地方。

现在回头看,那个Flask项目当时选它确实是因为“轻”,但业务复杂到一定程度,框架自带的“重”功能反而成了优势。Django的Admin和Auth不是额外的负担,而是帮我们省掉了大量重复造轮子的精力。我们那个平台现在每天大概有3000次左右的Admin操作,QPS峰值到过50,Django 5.1跑在2核4G的服务器上,CPU占用稳定在15%左右,比我预想的要稳。

5. 超越默认:集成Custom Action与Inline多表编辑的高级技巧

上个月运营提了个需求,说他们现在管理订单的时候,一个订单里可能有多个商品,每次要改订单状态,还得点进去一个个改商品的状态,太麻烦。我们的订单系统现在每天大概有2000多个新订单,高峰时每小时能到300单,这种操作效率问题确实得解决。

先说说我们的数据模型,订单Order和订单商品OrderItem是一对多的关系,原来的Admin里,订单列表页只能看到订单基本信息,要改商品状态得点进订单详情,再一个个改。运营反馈说,处理一个售后订单,平均要点击5-6次,耗时大概2分钟,一天处理50个售后就得花近2小时。

我第一反应是给OrderAdmin加个自定义Action,批量修改订单状态。但后来发现,运营其实更需要在订单详情页直接编辑多个商品的信息,而不是每次都跳转到商品列表。这时候Django Admin的Inline就派上用场了。

先贴一下我们的模型定义,这是真实项目里简化过的版本:

# models.py from django.db import models from django.contrib.auth.models import User class Order(models.Model): ORDER_STATUS = ( ('pending', '待支付'), ('paid', '已支付'), ('shipped', '已发货'), ('refunding', '退款中'), ('done', '已完成'), ) order_no = models.CharField(max_length=32, unique=True, verbose_name='订单号') user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='用户') status = models.CharField(max_length=20, choices=ORDER_STATUS, default='pending', verbose_name='订单状态') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') total_amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='总金额') def __str__(self): return self.order_no class OrderItem(models.Model): ITEM_STATUS = ( ('normal', '正常'), ('refunded', '已退款'), ('replaced', '已换货'), ) order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items', verbose_name='所属订单') product_name = models.CharField(max_length=100, verbose_name='商品名称') price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='单价') quantity = models.IntegerField(verbose_name='数量') status = models.CharField(max_length=20, choices=ITEM_STATUS, default='normal', verbose_name='商品状态') refund_reason = models.TextField(blank=True, null=True, verbose_name='退款原因') def __str__(self): return f'{self.order.order_no}-{self.product_name}'

原来的OrderAdmin是这样的,只能看到订单基本信息:

# admin.py (原版) from django.contrib import admin from .models import Order, OrderItem @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ('order_no', 'user', 'status', 'total_amount', 'created_at') list_filter = ('status',) search_fields = ('order_no',)

后来我加了Inline,让订单详情页直接显示并编辑订单商品:

# admin.py (改进版) from django.contrib import admin from .models import Order, OrderItem from django.utils.html import format_html class OrderItemInline(admin.TabularInline): model = OrderItem extra = 0 # 不显示额外的空行 fields = ('product_name', 'price', 'quantity', 'status', 'refund_reason') readonly_fields = ('product_name', 'price', 'quantity') # 商品基本信息只读 can_delete = False # 不允许直接删除订单商品,防止误操作 # 自定义显示:退款原因只在商品状态为已退款时显示 def refund_reason_display(self, obj): if obj.status == 'refunded': return obj.refund_reason or '未填写' return '-' refund_reason_display.short_description = '退款原因' @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ('order_no', 'user', 'status', 'total_amount', 'created_at', 'item_count') list_filter = ('status',) search_fields = ('order_no',) inlines = [OrderItemInline] # 加入Inline # 显示订单商品数量 def item_count(self, obj): return obj.items.count() item_count.short_description = '商品数量' # 自定义Action:批量处理退款订单 actions = ['batch_process_refund'] def batch_process_refund(self, request, queryset): # 只处理状态为退款中的订单 refund_orders = queryset.filter(status='refunding') count = 0 for order in refund_orders: # 这里实际会调用退款接口,这里简化为更新状态 order.status = 'done' order.save() # 同时更新所有商品状态为已退款 order.items.filter(status='normal').update(status='refunded') count += 1 self.message_user(request, f'成功处理{count}个退款订单,商品状态已同步更新') batch_process_refund.short_description = '批量处理退款订单'

这个改进后,运营在订单详情页就能直接看到所有商品,并且可以修改商品状态(比如把某个商品标记为已退款),不用再跳转到其他地方。我们测了一下,处理一个售后订单的点击次数从原来的5-6次降到了2-3次,平均耗时从2分钟降到了40秒左右,按每天50个售后订单算,能省出1小时多的时间。

这里有个实际遇到的问题,我一开始在Inline里加了refund_reason字段的编辑权限,结果运营反馈说,有时候不小心点到了退款原因输入框,会误改内容。后来我把refund_reason改成了根据商品状态动态显示,并且在Inline里设置了readonly_fields,只让运营修改status字段,这样误操作就少了很多。

还有一个细节,Django 5.1的Admin Inline支持autocomplete_fields,如果OrderItem里有关联其他模型(比如商品SKU),可以用这个来优化选择体验。我们之前有个类似的场景,Inline里的商品选择用的是下拉框,当商品数量超过1000个时,加载会很慢。后来加了autocomplete_fields = ['sku'],加载速度从原来的800ms降到了120ms左右,因为只会在输入时异步加载匹配的选项。

另外,自定义Action里我加了一个过滤,只处理status='refunding'的订单。如果不做这个过滤,运营可能会误选已完成的订单执行退款操作,之前的Flask项目里就出过这种问题,导致财务对账对不上。Django的Action里可以拿到request对象,我后来还加了一个权限判断,只有财务角色才能执行这个批量退款Action:

def batch_process_refund(self, request, queryset): # 检查用户是否有财务权限 if not request.user.has_perm('orders.can_process_refund'): self.message_user(request, '无权限执行此操作', level='error') return # 后续逻辑...

这个权限判断直接复用了Django的Auth系统,不用自己写权限校验逻辑。我们现在的财务角色有3个人,他们登录后只能看到自己有权限的Action,其他运营看不到这个批量退款按钮,安全性比之前高了很多。

现在这个订单管理页面,运营用下来反馈比之前的Flask版本顺手太多。尤其是Inline编辑,让他们不用在多个页面之间跳转,数据关联性看得更清楚。我们那个订单表现在有大概15万条数据,Admin列表页加载速度保持在300ms以内,这得益于Django ORM默认的查询优化,当然我也在list_display里避免了不必要的关联查询,比如item_count用的是count()而不是在模型里加个属性去查,防止N+1查询问题。

站长实战手记

一个真实项目:给物流公司做运单管理

去年接了个活儿,帮一家做零担物流的小公司重写运单管理系统。他们之前用 Excel 记单子,每天几千条数据,财务对账对到想哭。我选了 Django 5.1,主要就是冲着 Admin 后台去的。

业务场景很直接:司机扫码录运单,仓库改状态,财务看报表。我用了 Django 自带的 Auth 做权限,给财务、调度、司机分了三个角色。Admin 里定制了 list_displaylist_filter,把运单号、收货人、付款状态都展示出来。

碰到的真问题是运单量到三十万条时,后台列表页打开要 8 秒。我当时以为是 SQL 慢,开了 DEBUG 看日志,发现是 Admin 的 list_select_related 没设,导致每显示一条运单都要额外查一次司机信息。加上 list_select_related = ('driver',) 后,时间掉到了 1.2 秒。后来又在 search_fields 里加了个 SearchVector,配合 PostgreSQL 的索引,搜索快得飞起。

我的取舍看法

* 适合用的场景:内部工具、CMS、数据录入后台。如果你需要快速给非技术人员一个管理界面,Django Admin 是神器,别自己造轮子。

* 没必要上的场景:面向 C 端的高并发产品。Admin 的权限系统太粗,界面改起来也费劲,不如自己写前端。

* 选型坑:别为了用 Admin 而用 Django。我见过有人为了一个只有两个接口的小项目硬上 Django,结果部署配置比写代码还累。

给读者的真心话

学 Django Admin 别光看文档里的那些选项,去改改源码里的 templates/admin 目录,看看它到底怎么渲染的。遇到卡顿别急着换框架,先看看是不是自己忘了加索引或者没做 select_related。技术选型没有绝对的对错,只有合不合适,别被网上的争论带偏了。