Django电商项目实战:SPU/SKU、Redis缓存与订单事务设计
2026/9/10 2:13:51 网站建设 项目流程

简介:本资源是面向Python后端开发者与Django初学者的电商项目实战学习包,聚焦「天天生鲜」线上商城系统开发全流程,覆盖Web框架应用、异步任务调度、分布式文件存储及高性能缓存等核心后端能力。压缩包共376个文件,含47个核心Python源码(含models、views、celery配置及redis集成逻辑)、70个HTML模板页、104张PNG与38张JPG界面截图、16个JS交互脚本及12个CSS样式文件,辅以docx教程文档与sql数据库脚本,整体26.32MB,结构清晰,便于按模块研读源码与对照笔记。已有805人学习下载,资源提供完整可运行的Django项目骨架,包含用户认证、商品管理、购物车、订单流程、FastDFS图片上传集成、Celery异步邮件通知及Redis缓存优化等真实场景实现,配套笔记详述各模块设计思路与关键配置,是掌握电商系统架构与Django工程化实践的优质入门范例。

1. 为什么一个“天天生鲜”Django商城项目,比官方教程更能教会你电商后端的真实逻辑?

你写过python manage.py runserver,也照着文档配过settings.py,但真要从零搭一个能上线的生鲜商城——用户要能注册、商品要能分类展示、购物车得实时更新、订单生成后库存得扣减、图片得上传到独立存储、下单成功还得发邮件通知……这时候你会发现:Django 的auth模块不是开箱即用,Model关系设计稍有偏差就会卡在支付回调校验,Redis缓存键命名不带业务前缀会导致后台管理页和前台商品页互相污染,而Celery任务失败后连日志都找不到在哪查。这个“天天生鲜”项目不是玩具 Demo,它是一套完整跑通的生产级骨架:用户中心用AbstractUser扩展了手机号登录,商品 SKU 和 SPU 分离建模支撑多规格(如苹果按“500g/箱”“1kg/箱”),购物车数据既存session又落Redis实现登录态同步,订单号用datetime + random拼接避免碰撞,FastDFS客户端封装成FdfsStorage类直接挂载到DEFAULT_FILE_STORAGE。它不教你怎么“学会 Django”,而是让你看清——当流量进来时,请求怎么穿过中间件、视图怎么调用服务层、事务怎么在select_for_updateatomic之间取舍、缓存失效策略怎么配合库存扣减。适合刚写完博客系统的 Python 后端,也适合想补全电商链路认知的 5 年开发者。

2. MTV 架构落地:从模型设计到视图分层,拆解天天生鲜的 5 大核心模块

2.1 商品与库存模型:SPU/SKU 分离设计与事务安全扣减

天天生鲜没有把“苹果”直接建一张表,而是拆成GoodsType(水果)、GoodsSPU(红富士苹果)、GoodsSKU(红富士苹果-500g装-¥12.8)、GoodsImage(多张详情图)。这种设计让同一款商品支持不同规格、价格、库存,且图片可独立管理。关键在于GoodsSKU模型中stock字段的更新逻辑:

# apps/goods/models.py class GoodsSKU(models.Model): stock = models.IntegerField(default=0) sales = models.IntegerField(default=0) def reduce_stock(self, num): """原子性扣减库存,防止超卖""" from django.db import transaction with transaction.atomic(): # 加锁确保并发安全 sku = GoodsSKU.objects.select_for_update().get(id=self.id) if sku.stock < num: raise ValueError("库存不足") sku.stock -= num sku.sales += num sku.save()

提示:select_for_update()必须在atomic块内使用,否则锁会立即释放;若用F表达式(如stock=F('stock')-num)虽快但无法判断扣减后是否为负,需额外filter(stock__gte=num)验证,而验证与更新非原子操作,高并发下仍有超卖风险。

GoodsType表还关联了goods_type_idGoodsSPU,实现品类聚合;GoodsImage通过sku外键绑定,支持同一 SKU 多图轮播。迁移命令执行后,数据库生成 4 张表,其中goods_sku是实际交易主体,goods_spu仅用于前端分类页聚合展示。

2.2 用户认证扩展:手机号登录+短信验证码的完整链路

Django 默认User模型只支持用户名密码,天天生鲜继承AbstractUser新建User模型,并添加mobile字段和is_delete软删除标记:

# apps/user/models.py class User(AbstractUser): mobile = models.CharField(max_length=11, unique=True, null=True, blank=True) is_delete = models.BooleanField(default=False) class Meta: db_table = 'df_user' verbose_name = '用户' verbose_name_plural = verbose_name

登录视图不再调用authenticate(username=..., password=...),而是先查User.objects.filter(mobile=mobile),再校验密码:

# apps/user/views.py def login(request): if request.method == 'POST': mobile = request.POST.get('mobile') pwd = request.POST.get('pwd') user = User.objects.filter(mobile=mobile).first() if user and user.check_password(pwd): login(request, user) # Django 内置 login 函数 return redirect(reverse('goods:index'))

注意:mobile字段设为unique=True,但允许null=True,兼容老用户无手机号场景;is_delete替代物理删除,所有查询需加.filter(is_delete=False),Admin 后台重写get_queryset()方法自动过滤。

短信验证码未集成第三方 SDK,而是用django.core.mail模拟发送(开发环境),生产环境替换为阿里云 SMS 接口,参数结构严格匹配send_sms(mobile, template_code, data)data字典传入{code: "123456"}渲染模板。

2.3 购物车双存储机制:Session 与 Redis 的协同策略

购物车数据不只存在request.session,更同步写入Redis,解决用户登录前后购物车合并问题:

# utils/cart.py def merge_cart_cookie_to_redis(request, user): """登录时合并 cookie 购物车到 redis""" cart_str = request.COOKIES.get('cart') if not cart_str: return cart_dict = json.loads(cart_str) # cart_dict 格式:{sku_id: count} conn = get_redis_connection('default') cart_key = f'cart_{user.id}' pl = conn.pipeline() for sku_id, count in cart_dict.items(): pl.hset(cart_key, sku_id, count) pl.execute()

Redis中购物车以hash结构存储,keycart_{user_id}fieldsku_idvalue为数量。未登录用户购物车存于cookiemax_age=3600;登录后触发merge_cart_cookie_to_redis,并清空cookie。前端 JS 通过/cart/add/接口操作购物车,接口内根据request.user.is_authenticated自动路由到Redissession存储逻辑。

2.4 订单创建与状态机:从草稿到支付成功的全流程控制

订单模型OrderInfo包含order_id(唯一字符串)、pay_method(1 支付宝/2 微信)、status(1 待支付/2 待发货/3 已发货/4 完成/5 已关闭)、total_counttotal_price等字段。创建订单核心逻辑在OrderPlaceView.post()

# apps/order/views.py def post(self, request): # 1. 获取用户选择的地址和商品 addr_id = request.POST.get('addr_id') pay_method = request.POST.get('pay_method') sku_ids = request.POST.getlist('sku_ids') # 2. 校验库存(逐个 SKU 扣减) for sku_id in sku_ids: try: sku = GoodsSKU.objects.get(id=sku_id) sku.reduce_stock(1) # 调用前面定义的原子扣减方法 except ValueError as e: return JsonResponse({'res': 2, 'errmsg': str(e)}) # 3. 创建订单主记录 order_id = datetime.now().strftime('%Y%m%d%H%M%S') + '%06d' % user.id order = OrderInfo.objects.create( order_id=order_id, user=user, addr_id=addr_id, pay_method=pay_method, total_count=total_count, total_price=total_price ) # 4. 创建订单商品明细 for sku_id in sku_ids: sku = GoodsSKU.objects.get(id=sku_id) OrderGoods.objects.create( order=order, sku=sku, count=1, price=sku.price )

提示:订单创建必须在单次事务中完成,否则出现“订单生成但库存未扣减”或“库存扣减但订单未生成”的数据不一致。此处虽未显式atomic,但OrderInfo.objects.create()和后续OrderGoods.objects.create()在同一 HTTP 请求内,需确保整个post()方法被@transaction.atomic装饰,否则高并发下仍可能出错。

3. 第三方组件集成:Celery 异步任务、FastDFS 文件上传与 Redis 缓存实战

3.1 Celery 配置与邮件异步发送:避免阻塞主流程

项目使用Celery + Redis作为消息中间件,celery.py配置文件位于项目根目录:

# celery.py from __future__ import absolute_import, unicode_literals import os from celery import Celery os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dailyfresh.settings') app = Celery('dailyfresh') app.config_from_object('django.conf:settings', namespace='CELERY') app.autodiscover_tasks()

邮件发送任务定义在apps/user/tasks.py

# apps/user/tasks.py from celery import shared_task from django.core.mail import send_mail from django.conf import settings @shared_task def send_register_active_email(to_email, username, token): """发送激活邮件""" subject = '天天生鲜欢迎信息' message = '' sender = settings.EMAIL_FROM receiver = [to_email] html_message = '<h1>%s, 欢迎注册天天生鲜</h1>请点击以下链接激活账户<br/><a href="http://127.0.0.1:8000/user/active/%s">http://127.0.0.1:8000/user/active/%s</a>' % (username, token, token) send_mail(subject, message, sender, receiver, html_message=html_message)

视图中调用方式为send_register_active_email.delay(to_email, username, token).delay()触发异步执行。启动 Celery worker 命令为:

celery -A dailyfresh worker -l info

注意:-A参数指定应用模块名(即celery.py所在包),-l info输出日志级别;若报错Connection refused,检查Redis是否运行(redis-server)及settings.pyCELERY_BROKER_URL = 'redis://127.0.0.1:6379/1'地址是否正确。

3.2 FastDFS 文件上传:自定义 Storage 类对接 Django 文件系统

项目未使用 Django 默认FileSystemStorage,而是封装FdfsStorage类,使ImageField自动上传至 FastDFS:

# utils/fastdfs/fdfs_storage.py from django.core.files.storage import Storage from fdfs_client.client import Fdfs_client class FdfsStorage(Storage): def __init__(self, client_conf=None, base_url=None): if client_conf is None: client_conf = settings.FDFS_CLIENT_CONF if base_url is None: base_url = settings.FDFS_URL self.client_conf = client_conf self.base_url = base_url def _open(self, name, mode='rb'): pass def _save(self, name, content, max_length=None): client = Fdfs_client(self.client_conf) res = client.upload_by_buffer(content.read()) if res.get('Status') != 'Upload successed.': raise Exception('上传失败') filename = res.get('Remote file_id') return filename def url(self, name): return self.base_url + name

settings.py中配置:

# settings.py FDFS_CLIENT_CONF = os.path.join(BASE_DIR, 'utils/fastdfs/client.conf') FDFS_URL = 'http://192.168.200.133/' DEFAULT_FILE_STORAGE = 'utils.fastdfs.fdfs_storage.FdfsStorage'

client.conf文件需指定tracker_server地址,FDFS_URL为 Nginx 访问地址。商品图片字段定义为:

# apps/goods/models.py class GoodsImage(models.Model): sku = models.ForeignKey(GoodsSKU, on_delete=models.CASCADE) image = models.ImageField(upload_to='goods', verbose_name='图片路径')

upload_to='goods'仅作路径前缀,实际存储由FdfsStorage._save()决定,返回值为 FastDFS 返回的group1/M00/00/00/wKgZhVnZQz-AJkEFAABaXqJvGcI032.jpg这类 ID,url()方法拼接FDFS_URL即可生成可访问 URL。

3.3 Redis 缓存策略:首页商品分类与热门商品的分级缓存

首页index视图对商品分类和热门商品做两级缓存:一级缓存Redis(TTL=3600),二级缓存DjangoLocMemCache(仅开发环境):

# apps/goods/views.py def index(request): # 尝试从 Redis 获取缓存 conn = get_redis_connection('default') category_list = conn.get('index_category_list') if category_list: category_list = json.loads(category_list) else: # 查询数据库 category_list = GoodsType.objects.all() # 序列化并存入 Redis conn.setex('index_category_list', 3600, json.dumps([{ 'id': c.id, 'name': c.name, 'logo': c.logo.url if c.logo else '' } for c in category_list]))

热门商品缓存键为index_goods_list_{type_id},每个品类单独缓存,避免全量刷新。缓存更新时机在GoodsType模型保存后触发信号:

# apps/goods/signals.py from django.db.models.signals import post_save from django.dispatch import receiver from .models import GoodsType @receiver(post_save, sender=GoodsType) def clear_index_cache(sender, instance, **kwargs): conn = get_redis_connection('default') conn.delete('index_category_list') conn.delete(f'index_goods_list_{instance.id}')

提示:post_save信号在save()方法提交后触发,确保数据库已写入;delete()操作是主动失效,而非更新缓存,符合“缓存一致性”中“先删缓存,再更新 DB”的推荐模式(本例 DB 更新即save(),故删缓存放最后)。

4. 部署与调试:Nginx + Gunicorn + Supervisor 上线配置与常见错误排查

4.1 Gunicorn 启动配置:进程数、超时与日志分离

项目使用Gunicorn替代runservergunicorn.conf.py配置如下:

# gunicorn.conf.py import multiprocessing bind = '127.0.0.1:8000' bind_network = '127.0.0.1:8000' workers = multiprocessing.cpu_count() * 2 + 1 worker_class = 'sync' worker_connections = 1000 timeout = 30 keepalive = 2 max_requests = 1000 max_requests_jitter = 100 # 日志 accesslog = '/var/log/dailyfresh/access.log' errorlog = '/var/log/dailyfresh/error.log' loglevel = 'info'

启动命令为:

gunicorn -c gunicorn.conf.py dailyfresh.wsgi:application

workers设为CPU 核数×2+1是经验公式,避免 I/O 阻塞;timeout=30防止长连接耗尽资源;accesslogerrorlog必须提前创建目录并赋权chown www-data:www-data /var/log/dailyfresh

4.2 Nginx 反向代理与静态文件处理:分离动态与静态请求

Nginx配置将/static//media/直接映射到文件系统,其余请求转发给 Gunicorn:

# /etc/nginx/sites-available/dailyfresh upstream dailyfresh { server 127.0.0.1:8000; } server { listen 80; server_name www.tiantianshengxian.com; location /static/ { alias /home/www/dailyfresh/static/; expires 1y; add_header Cache-Control "public, immutable"; } location /media/ { alias /home/www/dailyfresh/media/; expires 1y; } location / { proxy_pass http://dailyfresh; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

注意:alias末尾必须带/,否则路径拼接错误;expires 1y对静态资源启用强缓存;proxy_set_header三行确保 Django 能正确获取客户端 IP 和域名。

4.3 Supervisor 进程守护:自动重启与日志轮转

supervisord配置文件/etc/supervisor/conf.d/dailyfresh.conf

[program:dailyfresh] command=/home/www/dailyfresh/venv/bin/gunicorn -c /home/www/dailyfresh/gunicorn.conf.py dailyfresh.wsgi:application directory=/home/www/dailyfresh user=www-data autostart=true autorestart=true redirect_stderr=true stdout_logfile=/var/log/dailyfresh/gunicorn.log stdout_logfile_maxbytes=10MB stdout_logfile_backups=10

生效命令:

sudo supervisorctl reread sudo supervisorctl update sudo supervisorctl start dailyfresh

supervisorctl status显示FATAL,检查command中虚拟环境路径是否正确,directory是否为项目根目录,user是否有权限读取gunicorn.conf.py

4.4 常见错误定位:从 502 Bad Gateway 到 Redis 连接拒绝

  • 502 Bad Gateway:Nginx 无法连接 Gunicorn。先ps aux | grep gunicorn看进程是否存在;再netstat -tuln | grep :8000确认端口监听;最后检查gunicorn.conf.pybind地址是否与 Nginxproxy_pass一致。
  • Redis Connection Error:Django 报ConnectionRefusedError。执行redis-cli ping,若返回PONG则服务正常,否则sudo systemctl start redis-server;检查settings.pyCACHESCELERY_BROKER_URLhostport是否与redis.conf一致。
  • Static files not found:Nginx 日志报open() "/home/www/dailyfresh/static/css/base.css" failed (2: No such file or directory)。确认python manage.py collectstatic --noinput已执行,且STATIC_ROOT指向/home/www/dailyfresh/static/;检查 Nginxalias路径末尾/是否遗漏。
  • Celery task not executed:Worker 日志无输出。执行celery -A dailyfresh inspect active_queues查看队列是否注册;确认tasks.py@shared_task装饰器存在,且模块路径在INSTALLED_APPS中已注册。

5. 性能优化技巧:利用 Redis Pipeline 批量查询商品库存与价格

5.1 购物车页面加载优化:单次 Pipeline 替代多次 Redis 查询

购物车页面需显示每个 SKU 的名称、价格、库存,若对每个 SKU 调用hget()会引发 N+1 查询。天天生鲜改用Pipeline批量获取:

# apps/cart/views.py def cart_show(request): user = request.user conn = get_redis_connection('default') cart_key = f'cart_{user.id}' # 获取所有 sku_id 和 count cart_dict = conn.hgetall(cart_key) # {b'1': b'2', b'3': b'1'} skus = [] total_price = 0 for sku_id_bytes, count_bytes in cart_dict.items(): sku_id = int(sku_id_bytes) count = int(count_bytes) # Pipeline 批量查询 pl = conn.pipeline() pl.hget(f'sku_info_{sku_id}', 'name') pl.hget(f'sku_info_{sku_id}', 'price') pl.hget(f'sku_info_{sku_id}', 'image') result = pl.execute() # 一次网络往返获取全部字段 sku = { 'id': sku_id, 'name': result[0].decode() if result[0] else '', 'price': Decimal(result[1].decode()) if result[1] else 0, 'image': result[2].decode() if result[2] else '', 'count': count } skus.append(sku) total_price += sku['price'] * count context = {'skus': skus, 'total_price': total_price} return render(request, 'cart.html', context)

pl.execute()将三条hget命令打包发送,网络开销从 3 次降为 1 次。sku_info_{id}是预热缓存的 hash 结构,由后台定时任务每日凌晨更新:

# apps/goods/management/commands/preload_sku_cache.py from django.core.management.base import BaseCommand from apps.goods.models import GoodsSKU from django_redis import get_redis_connection class Command(BaseCommand): def handle(self, *args, **options): conn = get_redis_connection('default') for sku in GoodsSKU.objects.all(): conn.hmset(f'sku_info_{sku.id}', { 'name': sku.name, 'price': str(sku.price), 'image': sku.image.url if sku.image else '' }) conn.expire(f'sku_info_{sku.id}', 86400) # 24小时

提示:hmset已废弃,新版本用hset,但hset(name, mapping)语法兼容;expire必须在hmset后立即调用,否则缓存永不过期;预热脚本通过python manage.py preload_sku_cache手动触发,或用crontab每日 2:00 执行。

5.2 数据库查询优化:select_related 与 prefetch_related 的精准使用

商品详情页需展示GoodsSKU、其所属GoodsSPUGoodsType及多张GoodsImage。若用默认查询:

# 错误:N+1 查询 sku = GoodsSKU.objects.get(id=sku_id) # 1次 spu = sku.spu # 第2次 goods_type = spu.type # 第3次 images = sku.goodsimage_set.all() # 第4次,且每张图再查1次

正确写法为:

# 正确:1次查询 sku = GoodsSKU.objects.select_related('spu__type').prefetch_related('goodsimage_set').get(id=sku_id) # select_related 用于 ForeignKey(spu 和 spu.type 是外键链) # prefetch_related 用于 ManyToMany 和 reverse ForeignKey(goodsimage_set 是反向外键)

生成 SQL 仅 2 条:一条JOIN查询skuspugoods_type;另一条IN查询所有关联图片。select_related最多支持两级外键(spu__type),三级需拆分;prefetch_related不限层级,但会多一次查询。

5.3 缓存穿透防护:空值缓存与布隆过滤器的轻量实现

用户恶意请求不存在的sku_id=9999999,每次都会穿透到数据库。天天生鲜采用空值缓存:

# utils/cache.py def get_sku_or_none(sku_id): conn = get_redis_connection('default') cache_key = f'sku_{sku_id}' sku_data = conn.get(cache_key) if sku_data is not None: return json.loads(sku_data) if sku_data != b'' else None try: sku = GoodsSKU.objects.get(id=sku_id) conn.setex(cache_key, 3600, json.dumps({ 'id': sku.id, 'name': sku.name, 'price': str(sku.price), 'stock': sku.stock })) return sku except GoodsSKU.DoesNotExist: # 缓存空值,防止穿透 conn.setex(cache_key, 300, '') # 5分钟空缓存 return None

cache_key值为''(空字符串)表示该 SKU 不存在,setex设置 5 分钟过期,既防穿透又避免永久缓存错误。布隆过滤器未引入第三方库,因项目规模小,空值缓存已足够应对常规攻击。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询