我一直在用Python做数据分析类的项目,最近一段时间,把整套外卖配送分析流程搬到了Django上,从订单数据清洗、指标统计到图表可视化,全部在一个Web项目里闭环完成。这个系统说到底解决的是一个很常见的尴尬:运营手里攒着大量订单数据、配送记录和商家信息,但每次想看某个时段的配送情况、哪个区域单量爆发、哪位骑手效率下降,都得让技术临时跑SQL或手动拉Excel,数据口径换一个就要重新算一遍。把分析固化成一个Django系统之后,日常运营打开浏览器就能看到所有关键指标,这比我以前用脚本处理数据再生成报告要直观得多。
适合谁看?两个方面:一是刚学完Django基础、想找一个完整体验数据建模、接口开发、图表渲染全流程的新手;二是需要用Web方式做数据分析可视化的开发人员,比如餐饮、即时配送、本地生活这类业务场景。如果你只是想把CSV文件画个图,那用Jupyter Notebook就够,没必要上系统;但如果你希望最终交付的是“一个能给别人用的产品”,这篇文章的经验应该对你有价值。项目代号里的“35k9z86f”不用纠结,就是当时内部仓库的一个标识编号,跟技术方案没有关系。
1. 系统整体设计与技术选型思路
1.1 外卖配送分析系统到底要解决什么问题
外卖配送的本质是“订单-商家-配送员-时间-位置”五要素的协同。单看一张订单表没有任何意义,把订单、配送员、商家、时间、位置联合起来才能回答以下问题:
- 门店的订单集中在哪些时段?午高峰和夜宵时段是不是同样需要运力?
- 外卖平均配送时长是多少?哪个环节拖后腿?是接单慢、到店慢还是送达慢?
- 哪些商圈或配送区域单量密集?需不需要调整配送运力?
- 每个骑手每天承接多少单?平均配送用时多少?有没有异常超时单?
- 哪些商家贡献了主要销售额?客单价水平又如何?
这些问题的共同点是它们都不是单表查询可以解决的,必须把多张表关联起来做分组聚合。所以数据建模时就要按“业务流程”去建模,而不是按“报表需求”去建。一开始如果只想着“我要一个订单表”,最后做出来的系统一定会在统计时处处碰壁,比如想分析骑手效率时发现订单表里根本没有骑手ID。先把业务链路理清楚,再设计表结构,这套思路放到任何数据分析项目里都通用。
1.2 为什么选择Django而非Flask或Node
我经常被问到一个问题:做数据分析可视化,为什么不用Flask?说实话,Flask确实也能做,但我更看重Django的自带能力。下面这个对比表可以直观看出来:
| 能力 | Django | Flask | Node/Express |
|---|---|---|---|
| ORM | 内置,迁移机制完善 | 需要自己选配SQLAlchemy | 需要单独选型 |
| Admin后台 | 自带,可直接录入数据 | 需要扩展包 | 需要额外开发 |
| 用户认证 | 内置User/Permission | 自己集成 | 自己集成 |
| 模板渲染 | 自带模板引擎 | Jinja2可选 | 自己选 |
| 适合场景 | 完整业务系统 | 轻量API/原型 | 高并发实时系统 |
Django是水电齐全的精装房,Flask是毛坯房。做外卖配送分析系统时,我们需要的东西——用户管理、数据库迁移、模型关系、后台录入、模板渲染——Django全都提供了,我只需要把业务逻辑填进去。这也是我在众多方案里选Django的核心原因。而且对新手来说,Django的“约定优于配置”特性会减少很多决策成本;对团队来说,Django的项目结构规范性对后期维护帮助很大。
1.3 可视化方案:ECharts还是Chart.js还是AntV
数据可视化分两层:后端负责把统计结果算出来,前端负责把统计结果画出来。选型时我主要考虑三点:
- 图表类型是否覆盖需求:需要折线图、柱状图、饼图、热力图、地图,ECharts覆盖最全。
- 中文资料与社区成熟度:ECharts文档很完善,遇到问题可以快速找到解决方案。
- 部署与定制成本:ECharts支持按需引入构建,也支持直接用静态js文件,很适合企业内部系统。
Chart.js能画基础图表但热力、地图类较弱;AntV功能强但学习成本高。最终选择ECharts,核心原因是它的“开箱即用”程度最高。另外一个实际经验是:ECharts的官方示例库非常丰富,很多需求直接改官方示例代码就能完成,这对不擅长前端样式的后端开发者尤其友好。
2. 环境准备与项目骨架搭建
2.1 开发环境与版本选择指南
写代码首先是环境。我的建议是:
- Python版本:3.10、3.11、3.12都行。Django 4.2 LTS官方支持到2026年,Django 5.x支持到2029年,生产环境优先选LTS版本,本地开发可以用较新版本。
- 数据库:本地演示用SQLite,部署用MySQL 8.0或PostgreSQL。如果是面向正式运营的系统,不建议用SQLite,因为并发写入能力有限,多个后台用户同时录单时容易锁库。
- 代码编辑:VSCode搭配Python插件,或PyCharm;选哪种都行,只要把虚拟环境和Django环境跑通。
安装步骤:
python -m venv venv source venv/bin/activate pip install django==4.2.16 pip install pillowPillow是后面处理图片和地图素材时需要用到的基础库。Windows下如果使用MySQL,安装mysqlclient容易编译失败,有两个解决路径:安装whl包,或者改用pymysql,在项目的__init__.py中加入import pymysql; pymysql.install_as_MySQLdb()。
我个人的习惯是:项目早期统一用SQLite,让团队先跑通功能;等数据量上来或者并发需求出现,再迁移到MySQL。Django迁移工具在两种数据库之间切换成本很低,这也是选Django的一个明显好处。
2.2 创建Django项目和App
项目名叫takeout_analysis,下面建两个业务app:orders和analysis。命令如下:
django-admin startproject takeout_analysis . python manage.py startapp orders python manage.py startapp analysis有人会问“为什么把orders和analysis分开,用一个app不行吗?”这里有一个模块划分的考虑。orders只负责数据实体:Store、Courier、Order、DeliveryRecord;analysis负责统计视图、API接口和图表页面。后续如果需要把orders换成其他业务系统,analysis能独立复用。数据、分析两个模块的解耦,比一个app里所有代码堆在一起好维护得多。
2.3 settings配置要点
打开settings.py,重点改三块。第一块是INSTALLED_APPS:
INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'orders', 'analysis', ]第二块是数据库。本地先用SQLite:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', } }第三块是静态文件。ECharts的js文件放到static目录:
STATIC_URL = 'static/' STATICFILES_DIRS = [ BASE_DIR / 'static', ]把这三块配置写在项目早期,能避免后面开发到一半再去补基础配置。这里特别注意:一定要提前配好时区:
TIME_ZONE = 'Asia/Shanghai' USE_TZ = False统计折线图按时段聚合时,如果时区不对,你会很痛苦地发现数据全部偏移了几个小时。第5章我会专门说这个坑。
3. 数据模型设计与业务逻辑实现
3.1 外卖配送分析需要哪些核心数据表
数据模型设计是整个系统的地基。我建议首版先建下面五张表:
| 表名 | 主要字段 | 说明 |
|---|---|---|
| Shop 店铺表 | name、category、address、longitude、latitude、rating | 保存商家基础信息,供订单关联和区域分析 |
| Courier 配送员表 | name、phone、status、region | 配送员信息,关联配送记录 |
| Order 订单表 | order_no、shop、customer_phone、created_at、amount、delivery_fee、status | 订单主体,分析业务量、销售额 |
| DeliveryRecord 配送记录表 | order、courier、accepted_time、arrived_shop_time、delivered_time、distance_km | 配送链路的时间戳,用于计算各环节耗时 |
| Region 区域表 | city、district、name、latitude、longitude、radius | 商圈/区域定义,用于热力分析与运力规划 |
这里有一个设计经验:不要把所有东西都塞在订单表里,尤其是配送时间戳。原因在于订单的“交易属性”和“配送履约属性”是两类数据:交易属性相对固定,配送履约属性变更频繁,如果都放一张表,一个骑手改派订单会导致记录不断被更新,历史报表追踪会很麻烦。拆分之后,订单表只管订单本身,配送记录可以保留多条历史轨迹,甚至能给同一条订单保留改派前后两条配送记录,分析时取最新一条即可。
ORM示例:
from django.db import models class Shop(models.Model): name = models.CharField(max_length=100) category = models.CharField(max_length=50, blank=True) longitude = models.FloatField() latitude = models.FloatField() rating = models.FloatField(default=0) class Order(models.Model): STATUS_CHOICES = [ ('pending', '待接单'), ('delivering', '配送中'), ('completed', '已完成'), ('cancelled', '已取消'), ] order_no = models.CharField(max_length=32, unique=True) shop = models.ForeignKey(Shop, on_delete=models.CASCADE, related_name='orders') created_at = models.DateTimeField(db_index=True) amount = models.DecimalField(max_digits=10, decimal_places=2) delivery_fee = models.DecimalField(max_digits=8, decimal_places=2) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')在订单的created_at上加db_index=True,因为后面所有趋势分析都会按这个字段分组聚合,有索引和没索引的区别非常明显。
3.2 用Django ORM完成日常查询、删除与聚合
热搜词里有“django执行查询-删除对象”,这里展开说。Django ORM最常用的几个操作。
新增一条订单:
shop = Shop.objects.get(id=1) Order.objects.create( order_no='202501010001', shop=shop, created_at='2025-01-01 12:00:00', amount='35.50', delivery_fee='5.00', status='completed', )查询已完成订单数量:
completed_count = Order.objects.filter(status='completed').count()删除对象:
# 删除单条 order = Order.objects.get(order_no='202501010001') order.delete() # 批量删除,delete() 会返回(coll_count, {model_name: count}) Order.objects.filter(status='cancelled').delete()删除时需要注意外键行为。在on_delete=models.CASCADE下,删除店铺会连带删除该店铺的订单,如果只想保留历史订单,选models.PROTECT或SET_NULL更安全。
聚合统计是分析系统的核心,用aggregate和annotate:
from django.db.models import Count, Sum, Avg total_amount = Order.objects.filter( status='completed' ).aggregate(total=Sum('amount'), avg=Sum('amount') / Count('id'))按店铺统计订单数:
result = ( Order.objects.filter(status='completed') .values('shop__name') .annotate(order_count=Count('id')) .order_by('-order_count') )看到shop__name这种写法代表跨表关联查询。Django ORM可以用双下划线把关系链串起来,这是分析系统里最常用的写法。我经常跟新手强调:数据量不大的时候,先在Django shell里验证SQL的结果对不对,再写进视图,不要直接在浏览器里刷页面调试。
3.3 Admin后台快速录入与CSV批量导入
Django自带Admin是这套方案的一个隐性优势。orders/admin.py:
from django.contrib import admin from .models import Shop, Order, Courier, DeliveryRecord @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ('order_no', 'shop', 'created_at', 'amount', 'status') list_filter = ('status', 'created_at')这样运营人员就能直接用后台录单、查单。但是手动录入大量历史数据不现实,最好做一个CSV导入接口。最简单的版本:
import csv from django.http import HttpResponse from django.db import transaction def import_orders(request): ... with transaction.atomic(): for row in reader: shop = Shop.objects.get(id=row['shop_id']) Order.objects.create( order_no=row['order_no'], shop=shop, created_at=row['created_at'], amount=row['amount'], delivery_fee=row['delivery_fee'], status=row['status'], )用transaction.atomic()包住整个导入过程,万一中间有一行数据异常,要么全部回滚,要么全部写入,不会出现“导了一半数据库里脏数据”的情况。另外,CSV导入前建议先做字段完整性校验,比如订单号是否重复、店铺ID是否存在,减少入库后才发现问题的概率。
4. 数据分析指标与可视化实现
4.1 外卖配送分析的指标怎么定义才不跑偏
在写聚合查询之前,先把指标口径统一,否则后面返工特别痛苦。我常用的口径定义:
- 订单量趋势:按天或按小时统计“已完成”订单数,取消单不计入业务量。
- 平均配送时长:
delivered_time - created_at,只统计已完成订单。 - 高峰时段:按小时分桶统计订单量,找出top10时段,往往午高峰和夜宵时段一起出现。
- 骑手效率:统计每位骑手的日均完成单量、平均配送时长、超时率。
- 区域热度:把所有完成订单的店铺经纬度聚合到区域网格,用热力图表示。
- 商家贡献:按店铺聚合销售额、订单量、客单价。
这里我特别建议加一个“取消率”指标:某一时段取消率突然升高,很可能代表配送压力过大或恶劣天气影响,比单纯看订单量更早发现问题。比如周五晚高峰突降暴雨,订单量可能没变,但取消率翻倍,这时候运营就应该提前安排增援运力,而不是等到订单积压才反应。
4.2 后端API如何返回可视化需要的数据
把统计逻辑放在视图函数里,返回JSON给前端。一个订单量趋势接口的完整写法:
from django.http import JsonResponse from django.db.models import Count from django.db.models.functions import TruncHour from .models import Order def order_trend_api(request): trend_data = ( Order.objects.filter(status='completed') .annotate(hour=TruncHour('created_at')) .values('hour') .annotate(order_count=Count('id')) .order_by('hour') ) items = [{ 'hour': item['hour'].strftime('%Y-%m-%d %H:00'), 'count': item['order_count'], } for item in trend_data] return JsonResponse({'items': items})注意几个细节:
TruncHour是Django 2.2之后内置的数据库函数,可以在数据库层面按小时截断时间,比Python里循环处理快很多。- 不能用
item['hour']直接丢给JsonResponse,datetime对象不是JSON可序列化类型,所以要先用strftime格式化。 - 如果列表里中文乱码,在JsonResponse里加上
json_dumps_params={'ensure_ascii': False}即可。
如果要做骑手效率排行接口,可以仿照这种写法:
from django.db.models import Count, Avg, F def courier_stats_api(request): stats = ( DeliveryRecord.objects .values('courier__name') .annotate( order_count=Count('id'), avg_minutes=Avg(F('delivered_time') - F('accepted_time')) ) .order_by('-order_count')[:20] ) items = [{ 'name': item['courier__name'], 'count': item['order_count'], 'avg_minutes': round(item['avg_minutes'].total_seconds() / 60, 1) if item['avg_minutes'] else 0, } for item in stats] return JsonResponse({'items': items})F表达式用于在数据库层面对同一行的多个字段做运算,不需要把整行加载到Python内存中,性能上更优。这也是热词里“django执行查询”被频繁搜索的原因——ORM的查询能力直接决定了分析系统的开发效率。
4.3 前端渲染:让图表动起来
前端需要两张核心图表:订单趋势折线图和店铺排行柱状图。模板页面templates/analysis/dashboard.html:
<div id="trendChart" style="width:100%; height:400px;"></div> <div id="shopChart" style="width:100%; height:400px;"></div> <script src="{% static 'echarts.min.js' %}"></script> <script> function renderTrend(items) { var chart = echarts.init(document.getElementById('trendChart')); chart.setOption({ title: { text: '订单量趋势' }, tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: items.map(item => item.hour) }, yAxis: { type: 'value' }, series: [{ type: 'line', smooth: true, areaStyle: {}, data: items.map(item => item.count) }] }); } fetch('/analysis/order-trend/') .then(res => res.json()) .then(data => renderTrend(data.items)); </script>前端拿到数据后,只用负责把数组映射到坐标轴,数据计算逻辑全在后端,这个分离结构对后期维护友好得多。改图表类型、加第二个Y轴都不需要动后端代码。不建议在服务端动态拼接大段JS,调试起来非常痛苦。模板里的console和onerror在开发阶段可以保留,等稳定了再清理掉。
4.4 大屏模式的扩展思路
做演示的时候,会发现单张卡片式图表不够直观。我的做法是在dashboard页面基础上再做一个大屏页面:三行多列,顶部放核心数字卡片(今日订单量、平均配送时长、订单完成率),中部放订单趋势折线图和商家饼图,底部放热力图和时间维度切换按钮。ECharts的grid布局可以自由控制每张图的位置。如果需要大屏自动刷新,用setInterval(() => { refreshData(); }, 30000)每30秒重新请求一次接口。这样业务团队把大屏投到会议室电视上,不需要手动刷新就能实时掌握运力情况。
5. 常见问题与排查技巧实录
5.1 时区问题直接导致统计折线图偏移8个小时
这个是新手最容易崩溃的问题。本地测试的时候数据正常,部署到服务器后,图表全部比实际晚8小时或早8小时。原因就是Django的USE_TZ默认是True,数据库保存的是UTC时间,你看到的本地时间其实是模板渲染时转换的,但分组统计TruncHour按UTC截断后,算出来的自然不是北京时间。
我的处理方式是把项目定位为“本地业务系统”,直接在settings里设TIME_ZONE = 'Asia/Shanghai'、USE_TZ = False,所有时间戳在录入时先统一转成本地时间再入库。如果以后要展示给海外用户,再把USE_TZ改成True,并在模板里调用localtime过滤器。用USE_TZ=False牺牲了一点国际化弹性,但换来的是统计口径简单直接,尤其在按小时聚合这种场景下能避免大量误判。
5.2 N+1查询:列表页面卡成PPT
当我在店铺列表页面展示所有店铺及其订单量时,一开始直接写循环:
shops = Shop.objects.all() for shop in shops: shop.order_count = shop.orders.count()表面上看没问题,实际上每循环一次就执行一条SQL,100个店铺就是101条查询。优化方法是在查询时用annotate一次性搞定:
from django.db.models import Count shops = Shop.objects.annotate(order_count=Count('orders'))使用Django调试工具django-debug-toolbar可以在页面底部看到SQL查询数量和耗时。我通常把这个工具当作性能检查的第一道关卡。如果发现查询次数激增,优先检查循环内部有没有调用ORM查询,然后把外键关联的取值改成select_related或prefetch_related,比如:
orders = Order.objects.select_related('shop', 'deliveryrecord').all()这样关联表会通过JOIN一次取回,避免循环中触发额外SQL。
5.3 图表不显示的几种原因
我遇到过几种情况:
- ECharts的容器高度是0。div设置了宽度但没设置高度,或者高度百分比失效,图表直接不显示。记得给图表容器写固定高度。
- js文件路径404。模板里写了
{% static %},但settings没配STATICFILES_DIRS,或者没执行collectstatic。本地开发时用python manage.py runserver会自动找静态目录,生产环境必须执行collectstatic并把文件复制到STATIC_ROOT。 - 数据为空时数组为空,图表坐标轴没有分类数据,看起来像“白屏”。后端保证返回的数组不为空,前端可以增加空数据提示。
- script放在head里,DOM还未渲染完就初始化图表,也会导致不显示。我习惯把图表相关script放到body的最后,或者用
window.onload包一层。
5.4 接口中文乱码与跨域
JSON返回的数据如果含中文,需要:
return JsonResponse(data, json_dumps_params={'ensure_ascii': False})如果不指定,默认会把中文转成\uXXXX格式,浏览器里显示为“乱码”。这个其实不影响功能,但排障时很难读。后来考虑把前端独立做成Vue或React应用,就需要CORS跨域支持。用pip安装django-cors-headers,settings里加CORS_ALLOW_ALL_ORIGINS = True(开发环境),生产时改成白名单。这步提前配好不亏。
5.5 数据库索引:统计查询慢的元凶
订单表数据量起来后,Order.objects.filter(status='completed', created_at__range=[start, end])这类查询会变慢。解决方式很简单:
class Order(models.Model): ... created_at = models.DateTimeField(db_index=True) status = models.CharField(max_length=20, db_index=True)对于经常做group by shop_id的场景,可以添加联合索引:
class Meta: indexes = [ models.Index(fields=['status', 'created_at']), ]用联合索引可以让“按状态+时间”的过滤和聚合都更快。我在数据达到几十万行时做过对比,加索引前后单次统计从秒级下降到了毫秒级,这个优化成本很低,收益却很明显。
6. 部署上线与后续扩展思路
6.1 使用Gunicorn和Nginx把系统跑起来
开发环境runserver只适合调试,正式上线用Gunicorn。流程:
pip install gunicorn gunicorn takeout_analysis.wsgi:application --bind 0.0.0.0:8000 --workers 3workers数量一般按CPU核心数*2+1设置。然后配置Nginx反向代理:
server { listen 80; server_name your_domain; location /static/ { alias /path/to/takeout_analysis/static/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }静态文件交给Nginx托管,动态请求转发给Gunicorn。如果服务器是Linux系统,提前安装Python环境和相关依赖,操作流程与本地安装类似。Docker部署也是常见的路线,把项目写成Dockerfile,创建镜像后运行容器,环境一致性问题能大幅减少。
6.2 后续可以扩展哪些能力让系统更完整
第一个是实时推送。如果业务希望大屏页面能实时更新订单数据,可以在Django基础上引入Channels,用WebSocket把新订单推送前端。Django和WebSocket的整合并不复杂,核心是配置ASGI应用、定义消费者,前端用WebSocket对象接收消息再刷新图表数据。
第二个是权限管理。热搜词里提到的“django rabc”,本质上是基于角色的访问控制。Django自带User、Group和Permission,可以在管理员后台分配权限,比如运营只能看报表但不能修改历史数据,管理员才能管理店铺和配送员。这个模块做好后,系统就不再只是开发者的工具,可以放心交给业务部门。
第三个是定时任务。每天凌晨跑一次数据汇总,把前一日的订单量、配送时效、取消率写入一个汇总表,页面只读汇总表而不是实时聚合并表,响应速度会更快。实现方式可以用Celery的beat任务,或者简单点用crontab调用管理命令。
第四个是地理围栏。给店铺配置半径,判断用户下单地址是否在配送范围内,超范围自动提示,这个对实际运营有直接价值。整体来看,这套“Django + ECharts + 分析指标”的组合,做外卖配送分析系统绰绰有余,而且很容易扩展成其他业务场景的可视化分析平台。
写到这里,我想分享一个真实的体会:这类分析可视化项目,技术栈框架并不是最难的,真正决定成败的是数据指标口径是否清晰、数据模型设计是否合理。我一开始拿到“外卖配送分析”需求时,第一版系统什么都想展示,结果做了几个图表后发现口径互相矛盾——有的按北京时间统计,有的按UTC统计,有的把取消订单也计算进销售额,返工的时间远超预期。后来我把所有指标口径写进项目README,每一个接口注释里写清楚计算逻辑,项目才真正稳定下来。所以,如果你准备上手做这样一个系统,先花两天时间整理清楚指标定义和字段含义,绝对比急着写代码有价值得多。
另外再分享一个小技巧:一定要把系统里所有查询放在数据库层面聚合,用Django ORM的annotate和aggregate,不要在Python端循环求和。我踩过的坑是刚开始图省事,把数据拉到Python里算,结果订单只有几万条还能忍受,到了几十万条,接口直接卡到十几秒。优化到数据库层面之后,同一套统计逻辑稳定在几百毫秒。这是我个人在这个项目里收获最大的一点,希望对你也有帮助。