从"Flask和Django二选一"的纠结说起吧。我做网上书店这类项目带过不少人,十有八九都会卡在同一道选择题上:Python后端到底用Flask还是Django?我的回答通常很直接——如果你做的不是几十行代码的小脚本,而是一个图书销售商城,最好两个都用。Django扛主业务,Flask做轻量辅助接口,这样既不会被Django的"全家桶"束缚,也不用在Flask里从零拼数据库模型和后台管理。下面这篇文章,就是一套完整的"基于Python、由Flask与Django联合驱动"的网上书店设计与实现思路,从项目拆分、表结构设计到下单事务、部署踩坑,全流程梳理一遍。想拿去交毕设、练手、或者扩展成简历项目的读者,按这个路径走能少走不少弯路。
1. 为什么我推荐用"Flask+Django"组合做网上书店
1.1 网上书店到底在练什么能力
选项目先要看它能不能覆盖足够多的典型场景。图书销售商城这套业务,天然包含用户注册登录、图书分页展示、分类筛选、关键词搜索、购物车增删改、订单生成、库存扣减、支付回调、订单状态流转、后台图书管理。这些模块几乎把Web系统最常遇到的CRUD、认证、事务、状态机全占齐了。
只做博客系统当然也行,但博客的"业务"太单薄,很多关键点根本碰不到,比如并发扣库存时怎么保证数据不错、订单生成时价格怎么不被后续改价影响、购物车数据怎么跨设备保留。这些才是面试时真正能聊出东西的细节。图书商城项目把这些点一个不少地暴露出来,做完之后你对"一个系统是怎么被设计出来的"会有一个完整的骨架感,而不是只学会几个框架API。
1.2 两个框架的合理分工:谁当主力,谁当辅助
网上不少文章把Flask和Django做成对立面,实际上它们完全可以协同工作。我的拆分逻辑是这样的:
| 模块 | 归属框架 | 主要职责 | 服务端口 |
|---|---|---|---|
| 用户认证与资料 | Django | 注册、登录、地址管理 | 8000 |
| 图书展示与管理 | Django | 列表、详情、后台维护 | 8000 |
| 购物车 | Django | 加购、改数量、删除 | 8000 |
| 订单与支付状态 | Django | 下单事务、状态流转 | 8000 |
| 热销推荐接口 | Flask | TOP10榜单、新书推荐JSON | 8001 |
| 模拟支付通知 | Flask | 接收支付结果回调 | 8001 |
Django负责的是"重活":它有内置ORM、Admin后台、用户认证体系、CSRF防护、事务管理。图书管理后台几乎可以直接基于Django Admin二次开发,用户和权限也不用自己造轮子,省下的时间足够把订单和库存的业务逻辑打磨清楚。
Flask负责的是"轻活":热销榜、新书推荐这类接口数据简单,只需要对外返回JSON,不需要模板渲染、不需要复杂ORM。用Flask写这类纯API接口十分顺手,代码量极小,而且天然和Django主服务隔离。万一以后推荐逻辑要调优、要频繁更新算法,只动Flask那个服务就够了,不会牵扯到整个商城主站。
实际部署时,两个服务分别跑在8000和8001端口,前端页面统一走Django,推荐数据要么由前端直接请求Flask、要么由Django侧转发一次。这套结构稍微带了一点微服务拆分的味道,但又不至于复杂到需要上消息队列和注册中心,最适合课程设计、毕业设计和中小型练手项目。
1.3 这套方案适合谁
三类读者适合直接抄这份实现路径。第一类是毕业设计选了"网上书店系统"题目的人,这套架构能在答辩时讲出"双框架整合"的亮点,比单纯的CRUD加分不少;第二类是学完Python基础、想通过一个完整项目找工作的转行者,能展示出你对数据事务和部署流程的理解,这在简历里是很能打的点;第三类是已经在单独用Django或Flask做过项目、想理解服务拆分的人,可以借这个项目体会"什么时候该把模块拆出去"。
2. 项目脚手架搭建:环境、目录与底层配置
2.1 环境准备与依赖清单
我建议直接用Python 3.10以上版本,配合虚拟环境安装依赖。虚拟环境的作用是让每个项目的依赖互相隔离,避免这台机器上跑着五六个项目时Django版本互相打架。
python -m venv venv # Linux/macOS 激活 source venv/bin/activate # Windows 激活 venv\Scripts\activate pip install -r requirements.txtrequirements.txt的内容大致如下:
django==4.2.* flask==3.0.* django-cors-headers==4.* flask-cors==4.* requests==2.* gunicorn==21.* redis==5.* PyMySQL==1.*说明一点:PyMySQL是生产环境连MySQL时用的,开发阶段用SQLite就可以,不用装。django-cors-headers解决的是"浏览器直接请求Flask推荐接口时的跨域问题";requests用于Django侧转发Flask接口;gunicorn是生产环境的进程管理器,runserver只适合开发调试。
2.2 Django的项目初始化与App划分
用命令创建项目,然后按业务域拆成四个App:
django-admin startproject store_books cd store_books python manage.py startapp users python manage.py startapp books python manage.py startapp cart python manage.py startapp ordersApp边界的建议是:一个App只服务一个业务域。user只管用户资料与地址,books只管图书与分类,cart只管购物车条目,orders只管订单和订单项。有人会把购物车和订单合并成一个App,我试过,后期会后悔——购物车是"临时意向单",订单是"已确定的交易记录",生命周期和状态转换完全不同,混在一起你不得不在同一个models.py里来回横跳。分开之后,订单事务里操作购物车时用明确的API调用,而不是共享一堆状态变量。
settings.py里注册App时记得加入:
INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "corsheaders", "users", "books", "cart", "orders", ]注意AUTH_USER_MODEL要在第一次migrate之前设好,否则已有user表后再改自定义用户模型,迁移会非常烦人,这属于Django的一个经典坑,下面章节会专门讲。
2.3 Flask服务的初始化与蓝图规划
Flask部分的目录结构我习惯分成两层:最外层一个run_flask.py作为进程入口,内部按"api"和"service"区分。api目录放路由蓝图,service目录放业务逻辑,比如读写Redis的代码。
from flask import Flask from flask_cors import CORS def create_app(): app = Flask(__name__) app.config["JSON_SORT_KEYS"] = False app.json.ensure_ascii = False # Flask 2.3+ 让JSON输出正常显示中文 CORS(app, resources={r"/api/*": {"origins": ["http://127.0.0.1:8000", "http://your_domain.com"]}}) from api.recommend import recommend_bp app.register_blueprint(recommend_bp, url_prefix="/api") return app app = create_app()run_flask.py里直接创建app实例,后面gunicorn启动时就是指向这个文件里的app对象。
2.4 配置文件的统一约定
开发环境和生产环境的最大区别是DEBUG、数据库、密钥这三项。我建议不要在settings.py里写死这些值,而是用python-dotenv读取.env文件:
from dotenv import load_dotenv import os load_dotenv() SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-insecure-key") DEBUG = os.getenv("DJANGO_DEBUG", "True") == "True" ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")数据库连接我放在一个if判断里,开发默认SQLite,生产自动切换到MySQL:
if os.getenv("DB_ENGINE") == "mysql": DATABASES = { "default": { "ENGINE": "django.db.backends.mysql", "NAME": os.getenv("DB_NAME"), "USER": os.getenv("DB_USER"), "PASSWORD": os.getenv("DB_PASSWORD"), "HOST": os.getenv("DB_HOST", "127.0.0.1"), "PORT": os.getenv("DB_PORT", "3306"), } } else: DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3", } }Flask侧配置同理,单独维护一个config.py,把Redis地址、服务端口都放进去。把密钥和数据库密码从代码里分离出去,无论毕设展示还是将来上线,都属于必须养成的习惯。
3. 数据库设计:图书、用户、购物车、订单四张核心表怎么串起来
3.1 图书模型:字段设计和类型选择
图书是商城的核心资产,字段设计直接影响后续所有模块。我的Book模型长这样:
class Category(models.Model): name = models.CharField(max_length=50, unique=True) parent = models.ForeignKey( "self", null=True, blank=True, on_delete=models.CASCADE, related_name="children", ) class Book(models.Model): title = models.CharField(max_length=200, db_index=True) author = models.CharField(max_length=100) publisher = models.CharField(max_length=100, blank=True) isbn = models.CharField(max_length=13, unique=True) category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="books") price = models.DecimalField(max_digits=8, decimal_places=2) stock = models.PositiveIntegerField(default=0) sales_count = models.PositiveIntegerField(default=0) cover = models.ImageField(upload_to="covers/", blank=True) description = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True)有几个选择必须解释一下。title加db_index,是因为图书列表页最常见的操作就是按书名搜索,索引能让模糊查询不至于全表扫一遍。isbn用unique=True,图书国际标准书号天然唯一,如果不设唯一约束,后台录入重复书号会导致展示混乱。price必须用DecimalField而不是FloatField——Float在计算0.1+0.2时会出现浮点误差,涉及到金额的事最好一开始就选对类型。cover允许blank,是因为不是所有书都有封面图,没图时前端展示默认封面。
Category做成自关联,是为了支持"计算机->编程语言->Python"这样的多级分类。parent为空就是顶级分类,查询时用select_related把父类别一次性带出来,避免N+1查询。
3.2 用户模型:扩展Django内置User的做法
Django自带的User有username、password、email等字段,够用于登录,但商城还需要手机号、收货地址。正确的扩展方式不是新建一张UserProfile表去外键关联,而是直接继承AbstractUser:
class User(AbstractUser): mobile = models.CharField(max_length=11, blank=True)然后在settings.py里加上:
AUTH_USER_MODEL = "users.User"这句必须写在第一次执行makemigrations之前。如果你已经migrate出auth_user表再改这里,Django会报一堆依赖错误,处理起来相当难受。我刚上手Django时在这上面耗过两三个小时,现在都会提醒项目一创建就先配好自定义用户模型。
地址信息单独建表:
class Address(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="addresses") receiver = models.CharField(max_length=50) phone = models.CharField(max_length=20) province = models.CharField(max_length=30) city = models.CharField(max_length=30) detail = models.CharField(max_length=200) is_default = models.BooleanField(default=False)related_name="addresses"的含义是:拿到一个user对象后,可以直接user.addresses.all()读取该用户全部地址。下单时默认选中is_default=True那条,没有默认地址就让用户现填。
3.3 购物车模型:为什么选数据库表而不是Session
购物车实现有两条路线。Session方案不建表,把购物车数据塞进session,简单但致命缺陷是用户换设备、清浏览器缓存后购物车直接消失;而且服务端无法统计流失数据。数据库表方案需要登录才能加购,但是数据永久保留、跨设备一致,管理员还能分析加购转化率。
图书商城面对的是一定需要注册下单的真实用户,我选数据库方案:
class CartItem(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="cart_items") book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="cart_items") quantity = models.PositiveIntegerField(default=1) created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ("user", "book")unique_together保证同一个用户对同一本书只有一条购物车记录。这样加购接口的逻辑就是:查得到就增加数量,查不到就新建,而不是每次点击都插入一行。
on_delete参数这里也要慎重。user删除时购物车一并清空,所以用CASCADE;book删除时购物车条目也删掉,因为商品不存在了。这和订单项里的PROTECT策略形成对比,下面会讲。
3.4 订单与订单项:价格快照为什么如此重要
订单是商城系统的"交易凭证",不能随便变。模型的要点是订单主表和订单项表分开:
class Order(models.Model): STATUS_CHOICES = [ ("PENDING", "待支付"), ("PAID", "已支付"), ("SHIPPED", "已发货"), ("COMPLETED", "已完成"), ("CANCELED", "已取消"), ] order_no = models.CharField(max_length=32, unique=True, db_index=True) user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="orders") address = models.ForeignKey(Address, on_delete=models.PROTECT) total_amount = models.DecimalField(max_digits=10, decimal_places=2) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING") created_at = models.DateTimeField(auto_now_add=True) class OrderItem(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items") book = models.ForeignKey(Book, on_delete=models.PROTECT) book_title = models.CharField(max_length=200) book_price = models.DecimalField(max_digits=8, decimal_places=2) quantity = models.PositiveIntegerField(default=1)OrderItem里出现book_title和book_price的快照字段,这是很多新手会省略的部分。为什么要冗余这份数据?场景是这样的:用户今天下单时《Python实战》卖98元,明天运营把价格改成128元——订单已经生效,总价就应该按98元结算,不能因为图书表被改就跟着变。同样,图书也可能因为版权原因下架,但历史订单的内容必须仍然可查。所以在下单那一刻,把书名和价格复制一份到订单项里,之后无论图书表怎么变动,订单数据都保持稳定。
on_delete的设计也有讲究:Order对User和Address都用PROTECT,意思是只要该用户下过单、该地址被订单引用过,就不能被硬删,防止历史订单查无此人、查无地址。OrderItem对Order用CASCADE则是因为订单删除时子项自然没有意义,跟随主单清理是合理的。
4. 商城核心链路编码:从搜索到下单的完整逻辑
4.1 图书列表、分类过滤与模糊搜索
列表页是用户进入商城的第一站。Django ORM是懒加载的,所以可以按参数动态拼查询集,最后再统一执行,不需要写又臭又长的if分支去拼接SQL字符串:
from django.db.models import Q from django.core.paginator import Paginator def book_list(request): books = Book.objects.all().select_related("category") kw = request.GET.get("kw", "").strip() if kw: books = books.filter( Q(title__icontains=kw) | Q(author__icontains=kw) | Q(publisher__icontains=kw) ) category_id = request.GET.get("cat", "") if category_id.isdigit(): books = books.filter(category_id=int(category_id)) sort = request.GET.get("sort", "default") if sort == "price_asc": books = books.order_by("price") elif sort == "sales": books = books.order_by("-sales_count") paginator = Paginator(books, 12) page_obj = paginator.get_page(request.GET.get("page", 1)) return render(request, "books/list.html", {"page_obj": page_obj})Q对象在这里的作用是把多个搜索条件用OR连接起来。icontains是大小写不敏感的包含匹配,对中文虽然没有大小写概念,但对英文书名和作者名很关键。select_related("category")能把分类信息一次性join出来,列表页渲染分类名不会每条都查一次数据库。
4.2 购物车增删改:数量更新不要用"先读后写"
加购接口比较简单:接收book_id和quantity,校验参数后用get_or_create处理。真正容易踩坑的是数量修改。前端传来的通常是一个delta,表示增加1还是减少1,很多人的第一反应是"读出来再加减后save",但并发情况下这种先读后写会丢更新。
正确做法是用F表达式让数据库在原子操作里完成加减:
from django.db.models import F def update_quantity(request, item_id, delta): item = CartItem.objects.filter( pk=item_id, user=request.user ).first() if not item: return JsonResponse({"code": 404, "msg": "购物车条目不存在"}) new_qty = item.quantity + int(delta) if new_qty <= 0: item.delete() return JsonResponse({"code": 0, "msg": "已删除"}) CartItem.objects.filter(pk=item.pk).update(quantity=F("quantity") + int(delta)) return JsonResponse({"code": 0, "msg": "ok"})F("quantity") + 1在数据库内部执行等价于"UPDATE cart_item SET quantity = quantity + 1 WHERE id = ...",整个过程不经过Python解释器读取旧值,两个并发请求不会互相覆盖。订单详情页的"加减数量"按钮连续快速点击时,这个写法能保证数量正确。
下单后购物车条目需要清理,这就是OrderItem生成后批量删除对应CartItem的原因,交易真的"落地"了,临时清单就清空。
4.3 下单事务与并发扣库存:select_for_update的正确用法
图书有库存字段,下单时必然要扣减库存。最危险的场景是:两个用户同时买最后一本书,都先读到stock=1,都判断库存充足,然后各自执行stock-1,最终库存变成-1。这是典型的并发写冲突。
Django的解决方案是行级锁select_for_update,但它必须配合transaction.atomic才能生效。单独拿出来调用时Django会直接忽略锁逻辑,这个细节文档里写得不显眼,很多人踩坑。
from django.db import transaction @login_required def create_order(request): selected_items = request.POST.getlist("cart_item_ids") address_id = request.POST.get("address_id") address = Address.objects.filter(pk=address_id, user=request.user).first() if not address: return JsonResponse({"code": 1, "msg": "地址无效"}) try: with transaction.atomic(): cart_items = CartItem.objects.filter( pk__in=selected_items, user=request.user ).select_related("book") order = Order.objects.create( order_no=generate_order_no(), user=request.user, address=address, total_amount=0, status="PENDING", ) total = 0 for item in cart_items: book = Book.objects.select_for_update().get(pk=item.book_id) if book.stock < item.quantity: raise ValueError(f"《{book.title}》库存不足") book.stock -= item.quantity book.sales_count += item.quantity book.save() OrderItem.objects.create( order=order, book=book, book_title=book.title, book_price=book.price, quantity=item.quantity, ) total += book.price * item.quantity item.delete() order.total_amount = total order.save(update_fields=["total_amount"]) except ValueError as e: return JsonResponse({"code": 1, "msg": str(e)}) return JsonResponse({"code": 0, "order_id": order.id, "total_amount": str(total)})select_for_update的作用是在事务内对那行Book记录加锁:事务A先拿到锁,读取、扣库存、提交;事务B在A提交之前会一直等待,等A提交后B再读到的是已经减过的库存,接着走"库存不足"分支。这样就把并发情况下库存变负数的窗口堵死了。
整个过程都要包在同一个事务里,还有一个原因:订单生成、库存扣减、订单项创建、购物车清理这四件事必须"全成功或全失败"。如果订单建好了、库存没扣成,交易就乱了,页面用户明明下单成功,仓库却还有货;反过来库存扣了但订单项插入失败,用户付款后发现订单列表是空的。transaction.atomic保证了要么全部落库,要么全部回滚。
4.4 模拟支付与订单状态机
真正接微信/支付宝需要商户号、证书、异步回调验签,毕设和练手阶段完全可以用模拟支付代替。但是模拟支付不代表随便写,状态流转必须用状态机的方式管理,否则时间长了代码里到处是if status == "PAID"的判断,乱成麻。
class Order(models.Model): def can_transition_to(self, new_status): allowed = { "PENDING": {"PAID", "CANCELED"}, "PAID": {"SHIPPED"}, "SHIPPED": {"COMPLETED"}, "COMPLETED": set(), "CANCELED": set(), } return new_status in allowed.get(self.status, set())配上这张流转表:
| 当前状态 | 允许跳转到的状态 | 触发动作 |
|---|---|---|
| PENDING | PAID / CANCELED | 用户支付 / 用户取消 |
| PAID | SHIPPED | 后台发货 |
| SHIPPED | COMPLETED | 用户确认收货 |
| COMPLETED | 无 | 终态 |
| CANCELED | 无 | 终态 |
支付接口的逻辑就是:订单属于当前登录用户、状态是PENDING,才允许改成PAID。这样杜绝了"还没支付就发货""取消后还能支付"这类逻辑漏洞。
Flask那边可以提供一个支付回调接口,模拟第三方支付平台把支付结果推给主站。前端在订单页点"立即支付"后,Flask接口收到通知,再通过requests回调Django的一个内部视图去更新状态。这正好呼应了前面提到的Flask辅助模块角色,除了推荐榜单,支付通知这种非页面型接口也很适合放在Flask侧。
5. Flask辅助模块的实现:热销榜单与支付回调
5.1 热销推荐接口为什么拆出去
热销榜的逻辑很简单,Book表按sales_count倒序取前十就行。但简单不代表应该塞在Django主进程里。实际收益有三个:第一,推荐逻辑可能频繁调整,比如加"本周上升榜""同分类推荐",如果都在主站里改,每次部署都要重启整个商城;第二,销量数据写入Redis后,推荐接口可以不查数据库,访问高峰期给数据库减负;第三,从工程练习角度看,这是理解"服务拆分"的入门级案例——拆出去的Flask服务仍然独立可运行、可单独测试。
5.2 基于Redis的销量榜缓存
实现思路是:Django下单成功后,把book_id写入Redis的有序集合,score是销量累加值:
import redis REDIS_CLIENT = redis.Redis( host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0, decode_responses=True, ) def incr_book_sales(book_id, quantity=1): REDIS_CLIENT.zincrby("book_sales", quantity, str(book_id))Flask推荐接口里直接取前10:
from flask import Blueprint, jsonify recommend_bp = Blueprint("recommend", __name__) @recommend_bp.route("/recommend") def recommend(): top_book_ids = REDIS_CLIENT.zrevrange("book_sales", 0, 9) books = get_book_brief_info(top_book_ids) # 对应书目信息 return jsonify({"code": 0, "data": books})zrevrange按score从大到小返回前10个元素,也就是销量最高的十本书。要注意decode_responses=True保证返回的是字符串而非字节串,否则还要多做一次decode。
榜单数据允许一定程度的偏差。用户下单后Redis加1,假设Redis刚重启丢了数据,榜单最多暂时不准,不影响交易。所以这里不需要强一致性,也不需要搞分布式锁。Redis挂了怎么办?Flask接口加一个try except,降级为查数据库:
try: top_book_ids = REDIS_CLIENT.zrevrange("book_sales", 0, 9) except redis.ConnectionError: top_book_ids = []接口降级之后仍然返回结果,只是可能不是热销前十,用户体验不受影响。这个兜底逻辑在实际部署中很重要。
5.3 Django调用Flask接口的两种接线方式
拆出去的服务总得和主站对接,对接方式可以选前端直连和后端代理两种。
方式A:前端直接请求Flask。页面里写一段fetch,请求http://127.0.0.1:8001/api/recommend,然后渲染榜单。这种方式实现最直接,但前端要能跨域访问8001端口,需要靠Flask-CORS放行。由于榜单是公开数据,不涉及登录态,前端直连可用。
方式B:由Django视图代理请求Flask,再把结果塞进模板。这种方式对浏览器透明,浏览器只认识Django,跨域问题从根本上不存在。
import requests def home(request): top_books = [] try: resp = requests.get( f"{settings.FLASK_SERVICE_URL}/api/recommend", timeout=2, ) if resp.ok: top_books = resp.json().get("data", []) except requests.RequestException: pass return render(request, "home.html", {"top_books": top_books})生产环境我推荐方式B,因为Nginx只需要把所有流量都指向Django,Flask服务地址只存在于后端配置里,不会暴露给用户。requests库要设置timeout,否则Flask服务挂掉时Django视图会一直等到连接超时,页面卡死。
5.4 CORS配置与中文JSON输出
如果前端直连方式,Flask侧必须处理跨域。我第一次做的时候直接在CORS里写origins="*",结果前端带着Cookie请求时报错。实际原因是跨域场景携带Cookie时,服务端不允许使用通配符来源,必须明确指定允许的域名。
CORS(app, resources={r"/api/*": {"origins": ["http://127.0.0.1:8000", "http://your_domain.com"]}})还要处理中文显示成\uXXXX的问题。Flask 2.3之后配置项变了,新手常在这卡住:
# Flask 2.3+ app.json.ensure_ascii = False # 老版本写法 # app.config["JSON_AS_ASCII"] = False不设置的话,jsonify返回的中文书名会变成"\u56fe\u4e66"这种转义序列,虽然前端解析后能还原,但接口测试直接看输出就是一堆乱码,非常容易误判成编码问题。
6. 部署实战与避坑记录
6.1 用gunicorn同时托管两个Python服务
开发时用python manage.py runserver没问题,但上线不能这么干。runserver是Django自带的开发服务器,单进程、性能上限低,只服务调试场景跑到几十个并发请求就开始吃力。生产环境用gunicorn起真正的多进程服务。
Django侧:
gunicorn store_books.wsgi:application --bind 127.0.0.1:8000 --workers 3 --timeout 30Flask侧:
gunicorn run_flask:app --bind 127.0.0.1:8001 --workers 2 --timeout 30workers数量不必盲目加大。我习惯先各开2到3个worker,压测后发现请求排队再增加。worker太多反而会因频繁上下文切换降低单机吞吐。
两个服务分别占8000和8001端口,互不干扰。这样改动推荐逻辑时只需要重启Flask进程,不影响正在处理订单的Django主进程。
6.2 Nginx反向代理与静态资源
部署结构一般长这样:浏览器请求Nginx,Nginx按路径分发到两个后端服务。
server { listen 80; server_name your_domain.com; client_max_body_size 20m; location /static/ { alias /home/deploy/store_books/staticfiles/; } location /media/ { alias /home/deploy/store_books/media/; } location /api/ { proxy_pass http://127.0.0.1:8001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }静态文件为什么要交给Nginx而不是Django?因为Django处理静态文件需要经过Python的整个WSGI链路,效率远低于Nginx直接读磁盘文件。部署时先执行python manage.py collectstatic收集所有App静态文件到STATIC_ROOT指定目录,Nginx的alias指到那个目录即可。
media目录放的是用户上传的图书封面和头像,也要固定持久化目录,不然服务器重启或者重新部署代码时图片全部丢失。
6.3 部署期踩过的几个坑
把整个过程里最典型的问题整理成一张表,每个都值得记一下:
| 现象 | 根本原因 | 排查与解决 |
|---|---|---|
| 登录后提交表单报403 CSRF验证失败 | 模板里没渲染csrf_token,或跨域POST未加白名单 | 表单加{% csrf_token %};API接口按实际场景校验或配置CSRF_TRUSTED_ORIGINS |
| SQLite在并发写入时报database is locked | 多gunicorn worker同时写SQLite,文件锁冲突 | 开发期可接受;生产切换到MySQL;临时做压测可先降为单worker |
| 上传的图书封面图片404 | settings没配置MEDIA_URL/MEDIA_ROOT,或Nginx少了media location | settings里补齐全并save后重启;Nginx挂/media/目录 |
| 页面展示时间比北京时间慢8小时 | TIME_ZONE与USE_TZ配置不一致 | 统一设TIME_ZONE="Asia/Shanghai",按需使用django.utils.timezone.localtime转换输出 |
| Flask接口返回\u4E66\u7C4D | JSON默认ASCII转义 | Flask 2.3+设置app.json.ensure_ascii=False |
| 请求量大时页面偶发超时 | gunicorn timeout太短或Flask没有兜底降级 | 调大--timeout,Redis挂掉时降级为查数据库 |
每个坑的通用排查顺序是:先看服务日志——Django的runserver控制台、gunicorn的stderr、Flask的访问日志都别关;再curl对应接口看返回;最后才怀疑代码逻辑问题。95%的部署问题都是配置层面的,不是代码bug。
6.4 部署前必须检查的安全配置
上线前过一遍这几个点:DEBUG设为False,否则报错页面会直接把配置信息、数据库连接串暴露给访问者;ALLOWED_HOSTS填实际域名,不让陌生Host访问;SECRET_KEY用环境变量注入,不要出现在git仓库里;数据库账号不要用root,给应用分配一个最小权限账号。这些步骤花不了十分钟,但很多新手项目对外一演示就被挖出后台地址,到时再补救就很被动了。
Flask侧同理,监听地址如果不是需要外部直接访问,就绑定127.0.0.1,让Nginx做唯一入口。
最后说说我做完这个项目最大的体会。网上书店的表结构和业务逻辑并不算难,真正拉开差距的地方全在"边界"上:哪些模块放Django、哪些放Flask是高一层的主从边界;库存扣减和订单生成之间的事务边界是数据正确性的底线;购物车条目和订单项之间的快照关系是业务稳定性的边界。把这些边界想清楚了,编码就是往骨架里填肉。如果你正准备拿这个题目交毕设或者练手,我建议先别急着敲代码,花半天时间把用户、图书、购物车、订单这四张表的关系和订单状态流转画在纸上,表结构定稿之后再动手写views,整个过程会顺畅非常多。