Django+MySQL从零搭建图书管理系统:ORM模型与事务实战
简介这是一份基于Python、Django与MySQL构建的图书管理系统完整源码并附带数据库文件适用于Python Web初学者、高校课程设计以及小型图书室的信息化管理。系统围绕图书、用户、借阅三条主线展开包含图书信息增删改查、读者注册登录、借书还书及借阅记录查询等核心功能后端采用Django框架进行模型定义、视图逻辑与模板渲染前端借助HTML、CSS与JavaScript实现交互页面MySQL则负责存储图书、用户和借阅等关键数据。压缩包共2000个文件其中以JavaScript1609个、HTML271个和CSS50个为主并含Python源码、JSON及XML配置文件整体仅5.79MB结构清晰紧凑。目前已有197人学习浏览。通过完整目录可快速理解Django项目分层、MySQL数据表关联以及前端请求与后端响应的完整闭环既可作为课程设计、毕业设计的直接参考也能为开发者提供一套从环境部署到功能实现的实战范例。1. 图书管理系统为什么值得用 DjangoMySQL 重做一遍一套“图书管理系统”听起来很基础但真拿 Django MySQL 从零搭过一遍的人会告诉你真正卡住你的不是业务代码而是环境配置、字符集、时区和数据库连接这些边角料。这个标题的核心价值在于它给出一条从空目录到能借书、还书、检索、统计的完整链路而且附带数据库导出文件意味着你不用从空表开始造数据导入就能看效果。适合三类人准备毕业设计的学生、想拿一个经典案例练手 Django ORM 的转行者以及小团队需要一个内部图书登记工具但不想买现成 SAAS 的情况。下面我会按“先立环境、再建模型、再写业务、最后排坑”的顺序把每一步的命令和参数讲透。2. 从零搭建 Django 图书管理系统环境、项目骨架与 MySQL 连接2.1 环境准备Python、虚拟环境与依赖选择不管你是跟着 python 安装教程装好了 3.x还是机器上已经有多个版本第一步永远是建虚拟环境。常见做法是python -m venv venv # Windows 激活 venv\Scripts\activate # macOS / Linux 激活 source venv/bin/activate为什么必须用虚拟环境因为 Django 版本和 MySQL 驱动版本会互相影响比如 Django 3.x 和 4.x 在settings.py的配置写法有差异如果全局环境里已经装了一个老版本后面照样翻车。虚拟环境把依赖锁在当前目录干净、可删、可复现。激活后先升级 pip再安装三样东西pip install --upgrade pip pip install django pip install mysqlclientmysqlclient是 Django 官方文档推荐的 MySQL 驱动C 语言实现查询性能好支持 Python 3.8。但它在 Windows 上安装经常报错因为它依赖 MySQL C Client 库。如果你装不上或者你只是想快速跑通项目可以用pymysql代替这个会单独说。另外如果你的 MySQL 用了caching_sha2_password认证插件MySQL 8.0 默认还需要装cryptographypip install pymysql cryptography2.2 创建 Django 项目并配置 MySQLsettings.py 关键参数环境就绪后用命令创建项目和应用django-admin startproject library_system cd library_system python manage.py startapp books这里library_system是项目外壳books是业务 app负责图书、读者、借阅记录相关的模型和视图。创建完先别急着写业务把数据库切到 MySQL。打开library_system/settings.py找到DATABASES配置改成下面这样DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: library_db, USER: root, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, OPTIONS: { charset: utf8mb4, init_command: SET sql_modeSTRICT_TRANS_TABLES, }, } }每个参数我都解释一下。ENGINE告诉 Django 用哪个数据库后端MySQL 固定填django.db.backends.mysql。NAME是数据库名要注意这个库必须已经存在Django 不会自动帮你建库。HOST本地开发填127.0.0.1如果连远程库就填 IPPORT默认 3306。OPTIONS里的charset必须写成utf8mb4这是 MySQL 真正支持 Emoji 和全中文的字符集只写utf8在遇到生僻字时会直接报错。init_command里的sql_mode设置是为了匹配 Django 对严格模式的预期避免后面插入数据时静默截断。2.3 用 pymysql 还是 mysqlclient驱动选型与配置示例我个人的经验Linux 上用mysqlclientWindows 上如果编译失败就降级用pymysql。项目里如果用了pymysql需要在他的__init__.py里打一段补丁否则 Django 不认# library_system/__init__.py import pymysql pymysql.install_as_MySQLdb()这段代码的作用是把pymysql伪装成MySQLdb因为 Django 的 MySQL 后端默认导入的是MySQLdb模块。pymysql是纯 Python 实现安装零依赖但性能比mysqlclient差一截并发高的时候能感觉到差距。如果你只是做毕设或小项目两者都没问题如果以后要上线扛流量建议直接上mysqlclient。选型后别忘了检查 MySQL 服务本身。常见坑是 MySQL 8.0 的认证插件默认是caching_sha2_password而mysqlclient老版本不认识。解决办法是安装最新版mysqlclient或者在 MySQL 里把用户改成mysql_native_passwordALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY your_password;改完记得重启服务。这一步不处理后面连接时会报Authentication plugin caching_sha2_password cannot be loaded之类的一长串错误。配置好settings.py和驱动后运行python manage.py migrate来验证连接。如果看到System check identified some issues且没有报错说明 Django 和 MySQL 已经通了。这时候你已经有了一套能跑 Django 默认表结构auth、admin 等的 MySQL 库。接下来要做的就是把自己的图书相关模型建起来。3. 数据模型设计把图书、读者、借阅记录建模成 Django 模型3.1 模型字段设计从 ER 图到 Django models.py图书管理系统的核心是三个实体图书、读者、借阅关系。按我习惯的设计还有一个“出版社”或“分类”作为附加属性但最小可用版本建议先砍掉避免一开始就被外键搞晕。打开books/models.py写下最基础的三个模型from django.db import models from django.contrib.auth.models import User class Book(models.Model): title models.CharField(书名, max_length200) author models.CharField(作者, max_length100) isbn models.CharField(ISBN, max_length20, uniqueTrue) publisher models.CharField(出版社, max_length100, blankTrue) total_count models.IntegerField(库存总量, default1) available_count models.IntegerField(可借数量, default1) created_at models.DateTimeField(入库时间, auto_now_addTrue) def __str__(self): return self.title class Reader(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, verbose_name关联账号) student_id models.CharField(学号/工号, max_length20, uniqueTrue) phone models.CharField(手机号, max_length20, blankTrue) def __str__(self): return self.user.username class BorrowRecord(models.Model): book models.ForeignKey(Book, on_deletemodels.CASCADE, related_nameborrow_records, verbose_name图书) reader models.ForeignKey(Reader, on_deletemodels.CASCADE, related_nameborrow_records, verbose_name读者) borrow_date models.DateField(借书日期, auto_now_addTrue) due_date models.DateField(应还日期) return_date models.DateField(实际归还日期, nullTrue, blankTrue) status models.CharField(状态, max_length10, choices[ (BORROWED, 借出中), (RETURNED, 已归还), (OVERDUE, 已逾期), ], defaultBORROWED) def __str__(self): return f{self.reader} - {self.book}字段设计有几个关键决策。available_count不能只用total_count减去借出数量因为如果直接做减法每次查询都要聚合借阅记录性能差且容易算错。库存总量和可借数量分开存借书时减一、还书时加一简单可靠。ISBN加uniqueTrue是必需约束不然同本书会被插入多行。BorrowRecord用ForeignKey关联图书和读者related_name设成borrow_records这样从 Book 对象可以直接book.borrow_records.all()拿到所有借阅记录语义清晰不需要手工写 JOIN。这里的Reader没有单独建用户名密码字段而是用OneToOneField关联Django自带的User这是 Django 项目推荐做法直接复用认证系统登录、权限都免费拿到。如果不需要登录功能也可以把Reader改成独立的姓名、证件号字段但那样后面借书时就得自己写“当前用户是谁”的逻辑比较绕。3.2 外键与查询设计避免 N1 查询的写法模型定好后写查询时要特别小心外键产生的 N1 问题。比如你要列出每本书的所有借阅记录新手会这样写books Book.objects.all() for book in books: records book.borrow_records.all() # 每条都查一次数据库这会在循环里触发大量查询记录一多页面就卡。正确做法是用prefetch_related或select_related。select_related用于外键是单值的情况比如 BorrowRecord 查 Book它用 JOIN 把关联表一次性查出来prefetch_related用于反向关联和 ManyToMany它查两次然后 Python 侧合并。records BorrowRecord.objects.select_related(book, reader).filter(statusBORROWED) for record in records: print(record.book.title, record.reader.student_id)这里select_related(book, reader)生成的 SQL 是单条 JOIN不会在循环里发第二条查询。你可以用connection.queries查看实际执行的 SQL 数量来验证。在查询层面还有一个常见误用filter(statusBORROWED)之后如果要对due_date排序、对borrow_date范围过滤建议把条件放在filter()里而不是all()之后用 Python 切片否则会加载全表到内存浪费严重。3.3 迁移与初始数据库migrate 后导入 MySQL 的完整流程模型写好后执行迁移生成数据库表python manage.py makemigrations books python manage.py migratemakemigrations会检查模型变化并生成一个0001_initial.py文件你可以打开看它内部的结构它是一个描述表结构变化的 Python 字典而不是 SQL 语句。migrate把这个变化应用到 MySQL。如果你想看实际 SQL可以用python manage.py sqlmigrate books 0001这会输出 CREATE TABLE 语句方便你排查字段类型。如果标题里的“数据库”是已经被导出好的.sql文件比如项目自带的library_db.sql导入方式是mysql -u root -p -h 127.0.0.1 library_db library_db.sql导入前先确认库已经创建CREATE DATABASE library_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;。如果导出的 SQL 里有CREATE DATABASE语句那mysql命令后可以不指定库名但强烈建议先建库再导入因为你不知道原文件里用的字符集。反过来如果你想把当前库导出成文件给别人用mysqldump -u root -p --default-character-setutf8mb4 library_db library_db_dump.sql导出时加--default-character-setutf8mb4是必须的否则生成的 SQL 文件头只写SET NAMES utf8导入到新库后中文可能乱码。完整源码里的数据库文件一般就是这种 dump 格式导入后配合migrate检查一下如果提示No migrations to apply说明表结构已存在如果有迁移没应用要手动补跑否则项目启动后模型和数据表不一致查询会报Table doesnt exist或列缺失错误。4. 核心功能实现借书、还书、检索与分页的落地代码4.1 借书/还书业务逻辑事务与并发控制借书还书的业务看起来就是改两个数字但并发场景下很容易出错两个读者同时借最后一本书available_count会被减到负数。Django 的ORM并不提供UPDATE ... SET available_count available_count - 1 WHERE available_count 0这样的原子操作直接用 Python 读改写是不安全的。正确做法是使用F表达式和事务。from django.db import transaction, models from django.shortcuts import get_object_or_404 transaction.atomic def borrow_book(request, book_id): book get_object_or_404(Book, pkbook_id) if book.available_count 0: raise ValueError(这本书已无可借库存) # 使用 F 表达式原子扣减避免并发覆盖 updated Book.objects.filter(pkbook_id, available_count__gt0)\ .update(available_countmodels.F(available_count) - 1) if updated 0: raise ValueError(库存不足借书失败) BorrowRecord.objects.create( bookbook, readerrequest.user.reader, due_datetimezone.now().date() timedelta(days30), statusBORROWED ) return 借书成功代码里的transaction.atomic保证扣库存和创建借阅记录要么都成功要么都失败。update里的filter(pkbook_id, available_count__gt0)和F(available_count) - 1让数据库自己完成“判断库存大于 0 并扣减”这个过程是原子的两个请求同时进来只有一个会update返回 1。updated 0就说明当前没库存或图书不存在直接抛错。还书逻辑类似但要处理“已逾期”状态transaction.atomic def return_book(request, record_id): record get_object_or_404(BorrowRecord, pkrecord_id, statusBORROWED) record.return_date timezone.now().date() record.status RETURNED record.save() # 归还后恢复库存 Book.objects.filter(pkrecord.book_id).update( available_countmodels.F(available_count) 1 ) return 还书成功这里有个细节如果只是record.status RETURNED没有用update或并发控制另一个请求同时还同一本书会把库存加两次。所以归还时也需要原子更新F表达式依然是首选。不要在这里用record.book.available_count 1那会重新读一遍数据库再写回去两个并发的加操作会丢失一次更新。4.2 图书搜索与分页ORM 查询参数的实战搜索功能最常见的实现是搜书名、作者、出版社三个任意字段包含关键字。Django 的filter默认是 AND 关系所以要用Q对象组合成 OR 关系。from django.db.models import Q from django.core.paginator import Paginator def book_list(request): keyword request.GET.get(q, ).strip() books Book.objects.all() if keyword: books books.filter( Q(title__icontainskeyword) | Q(author__icontainskeyword) | Q(publisher__icontainskeyword) ) books books.order_by(-created_at) paginator Paginator(books, 10) # 每页 10 条 page_number request.GET.get(page, 1) page_obj paginator.get_page(page_number) return render(request, books/book_list.html, {page_obj: page_obj})icontains是大小写不敏感的子串匹配MySQL 默认 utf8mb4 排序规则下中文没影响英文搜索不区分大小写符合直觉。order_by(-created_at)按入库时间倒序。分页用 Django 自带Paginatorget_page比page更宽容页码越界时会自动返回最后一页不会抛 404。模板里渲染分页导航的常规写法ul classpagination {% if page_obj.has_previous %} lia href?q{{ request.GET.q }}page{{ page_obj.previous_page_number }}上一页/a/li {% endif %} li第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页/li {% if page_obj.has_next %} lia href?q{{ request.GET.q }}page{{ page_obj.next_page_number }}下一页/a/li {% endif %} /ul注意链接里要带上q参数不然翻页后搜索关键词会丢失。这是新手最容易忽略的一步我在项目里见过好几个人点击第二页就直接跳回全量列表。4.3 视图与模板复用直接复制的 Django 套路如果你不想手写那么多if request.method POST可以用 Django 的类视图和表单来省事。借书的表单用ModelForm直接从BorrowRecord生成# forms.py from django import forms from .models import BorrowRecord class BorrowForm(forms.ModelForm): class Meta: model BorrowRecord fields [book, reader]视图用CreateView# views.py from django.views.generic.edit import CreateView from django.urls import reverse_lazy from .forms import BorrowForm class BorrowCreateView(CreateView): form_class BorrowForm template_name books/borrow_form.html success_url reverse_lazy(book_list)类视图的好处是复用性高坏处是当你需要在保存前检查库存并扣减时还得重写form_valid方法并不比函数视图少多少代码。我的建议是复杂的业务逻辑用函数视图 事务简单的增删改用类视图。这个项目里借书、还书都属于“业务逻辑”用函数视图更直观模板复用则通过include标签解决把搜索框、分页条提取成单独模板文件在多个页面里{% include %}进来就行。5. 排查与避坑Django MySQL 最常见的 5 个翻车现场5.1 中文乱码与 utf8mb4 的坑现象在 Django Admin 里添加书名“三体”保存后页面上显示“三?体”或者直接报Data too long for column title。原因数据库、表或连接三层的字符集不一致。MySQL 的utf8其实是 utf8mb3只能存 BMP 字符某些生僻字和 Emoji 会变成问号或截断而settings.py里没指定连接charset时Django 会用数据库默认值很可能和建库时的字符集不一致。解决建库时明确指定字符集CREATE DATABASE library_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;然后在settings.py的OPTIONS里加charset: utf8mb4。如果是从老库迁移用ALTER TABLE books_book CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;转换表但要注意这可能会锁表最好在低峰期操作。5.2 时区问题导致的时间错乱现象借书记录里borrow_date显示的时间比本地时间早了 8 小时或者auto_now_addTrue的字段写入 UTC 时间。原因Django 的USE_TZTrue默认开启时区设为TIME_ZONEUTCMySQL 连接时也会按会话时区处理结果存进 DATETIME 字段的是 UTC 时间。如果你在页面直接渲染Django 模板会转成当前时区但如果你用原生 SQL 查询或者导出数据看到的就是 UTC。解决如果项目只在本地跑且不考虑全球用户最简单是USE_TZ False # 关闭 Django 时区转换 TIME_ZONE Asia/Shanghai # 使用本地时间存取如果必须开USE_TZTrue那就保持TIME_ZONEUTC并在输出时用模板过滤器{{ time|localtime }}转换。注意 MySQL 连接也可以设置时区在连接 URL 或 OPTIONS 里加init_command: SET time_zone 08:00否则 Django 写入的带时区信息在 MySQL 里可能被丢掉。5.3 断开连接与 MySQL server has gone away现象页面长时间不访问第一次刷新报OperationalError: (2006, MySQL server has gone away)刷新第二次又好了。原因MySQL 的wait_timeout默认 8 小时但如果 Django 进程比如开发服务器、Celery worker持有连接超过这个时间服务端就主动断开了。连接还在池里下次请求直接用就出错。第二次之所以好是因为 Django 检查连接失效后重建了连接。解决在settings.py里设置连接最大年龄让后端定期重连DATABASES { default: { CONN_MAX_AGE: 3600, # 连接最长存活 1 小时 } }CONN_MAX_AGE的单位是秒设成 3600 表示一小时后自动重新连接。注意如果用的pymysql旧版本对ping()支持不好建议升级到 1.0.2。终极方案是在每次请求前检查连接但 Django 本身有close_old_connections机制一般是够用的。5.4 外键级联删除误伤数据现象删除一个读者结果他的借阅记录全没了库存数量也没恢复数据一团糟。原因BorrowRecord里的reader ForeignKey(Reader, on_deletemodels.CASCADE)CASCADE 会连带删除所有关联的借阅记录。这在“读者注销后删掉历史记录”的语义下可能符合预期但大多数图书管理系统需要保留借阅历史不能真删。解决把外键的on_delete改成PROTECT或SET_NULL。PROTECT会让删除有借阅记录的读者直接报错阻止删除SET_NULL需要在字段上加nullTrue删除读者后借阅记录保留但reader变空。我的建议是图书和读者的历史记录要保留所以BorrowRecord上的外键用PROTECT同时手动删除读者前先检查是否还有未归还的借阅记录。如果确实要删除先清掉或转移借阅记录再删读者。5.5 sql_mode 严格模式引发的保存失败现象保存一个 ISBN 超过 20 字符的图书记录Django 没报错但数据库里存的是截断后的字符串或者插入时直接报Data truncated for column isbn at row 1。原因MySQL 5.7 默认启用STRICT_TRANS_TABLES严格模式在严格模式下对非空字段插入超长值会报错而不是截断。而 Django 生成模型字段长度max_length20与 MySQL 的varchar(20)是匹配的但如果你从旧数据库导入的 dump 里isbn是varchar(30)Django 模型还是 20就会先截断再入库。解决统一模型字段长度与数据表结构。在settings.py的OPTIONS里显式设置init_command: SET sql_modeSTRICT_TRANS_TABLES让 Django 一开始就知道严格模式这样保存超长数据时会在 Python 侧报DataError而不是静默丢数据。这个报错是好事你就能发现问题而不是等数据丢了才察觉。6. 让系统更好用从「能跑」到「敢上线」的四个验证与优化技巧借书还书、检索分页都能跑通之后距离“敢上线”还差几步。第一个技巧是启用 Django Admin它帮你免费拿到一套后端数据维护界面。在admin.py里注册模型from django.contrib import admin from .models import Book, Reader, BorrowRecord admin.register(Book) class BookAdmin(admin.ModelAdmin): list_display (title, author, available_count, total_count) search_fields (title, author, isbn)注册后去/admin登录就能直接改图书库存、补录读者、查看借阅记录。对于内部系统这一套后端可能比前端更实用。第二个技巧是验证查询次数。在开发环境下给视图加一个断言查出连续操作触发了多少 SQL防止 N1 from django.test.utils import CaptureQueriesContext from django.db import connection with CaptureQueriesContext(connection) as ctx: response client.get(/books/) print(SQL 数量, len(ctx.captured_queries))如果发现数量不对回到模型查询里补select_related或prefetch_related。第三个技巧是给借书、还书接口写一个并发测试用threading模拟 20 个人同时借同一本书断言库存不会变成负数。第四个技巧是把DEBUG关了跑一遍生产模式用python manage.py collectstatic收集静态文件同时检查ALLOWED_HOSTS——这一步能提前暴露很多“开发环境没问题一部署就 500”的隐患。我自己的教训是每次拿到这种“完整源码数据库”的项目不要急着去读业务代码先花十分钟跑migrate --check和导入数据库确认环境和数据没问题再动代码。不然你会花一下午在一个本该三分钟跑通的环境问题上。希望这次梳理的套路能帮到你现在就动手跑起来。本文还有配套的精品资源点击获取