
简介这是一份基于 PHP 和 MySQL 的学生信息管理系统源码面向初学 PHP 与数据库的开发者也适用于课程设计、期末项目或毕业设计参考。系统围绕学生信息的增删改查展开包含用户登录、表单处理、数据展示和会话管理等常见功能整体结构清晰便于按模块理解与二次开发。压缩包共 29 个文件核心是 17 个 PHP 脚本分别承担登录、配置、数据库访问、公共函数及多个信息管理模块另有 1 份 SQL 建库脚本、1 份 CSS 样式、1 份说明文本与 9 张 PNG/GIF 图片素材整体仅 66KB轻量且易于部署。源码可对照掌握数据库设计、PHP 连接 MySQL、SQL 增删改查、HTTP 请求处理、预处理语句防注入等知识点并为错误处理、用户认证与响应式布局提供直观示例。目前已有 552 人学习浏览适合用它入门 Web 项目开发并在此基础上继续完善成绩、课程等更多功能模块。1. 写一套 phpmysql 学生信息管理系统比“会 CRUD”多学了什么“学生信息管理系统”大概是计算机程序设计实训里出现频率最高的题目之一课程设计任务书和毕业设计选题里总能见到它。它的标准形态就是标题里那套组合PHP 渲染页面MySQL 存数据加上会话管理和权限判断完成学号、姓名、班级、成绩这类基础信息的增删改查。这套组合看起来年代感很足但很多刚接触 Web 的人第一次把“前端表单 → 后端脚本 → 数据库表 → 回显列表”完整串起来靠的就是这个题目。它暴露的问题也很实际SQL 注入、分页深了变慢、部署到 nginx 上以后 500 报错、中文乱码全部能在几百行代码里依次遇到。下面按“环境与建表 → PDO 分层实现 → 安全基线 → 索引与分页优化 → 上线排错”的顺序把一套能直接抄的原生 PHP 实现讲透新手能照着敲熟手也能看到预处理、深翻页和外键这些词在这个小系统里到底卡在哪里。2. 从 mysql 安装教程到建库环境、字符集与学生系统三张核心表2.1 用 XAMPP/phpStudy 搭出 PHP MySQL 运行环境Windows 下最快的落地方式是 XAMPP 或 phpStudy 这类集成环境它们把 Apache/Nginx、PHP、MySQL 一次装齐适合先跑通再谈部署。如果你不想用集成环境走 mysql 下载地址手动装 zip 版也行只是环境变量、data 目录初始化和 Windows 服务注册都要自己来入门阶段容易在半路卡住。集成环境装完第一件事不是写代码而是确认 PHP 扩展真的加载了。php -m | grep -i mysql正常应该同时看到mysqli和pdo_mysql两行。只有其中一个说明php.ini里extensionpdo_mysql或extensionmysqli还被注释着。phpStudy 这类面板改完配置要重启 PHP 服务修改才生效。php -m 列的是当前 CLI 环境加载的模块和 php-fpm/Apache 模块是两套配置面板上改完还要看“PHP 版本管理”或“配置文件”里改的是不是同一个 php.ini。再补一句更直接的php -r print_r(PDO::getAvailableDrivers());输出里有mysql才说明 PDO 能连 MySQL。常见情况是页面里报could not find driver但命令行一切正常这多半是 Web 端 PHP 和 CLI 用的不是同一个 php.ini。集成环境默认 MySQL 端口常见问题XAMPP3306本机已装 MySQL 导致端口冲突服务起不来phpStudy3306MySQL 5.7 和 8.0 切换后 root 密码被重置提示3306 被占用时优先停掉本机已装的 MySQL 服务而不是改端口。改端口会让后面所有连接代码都要带非默认端口排查时容易混淆。2.2 学生信息系统的三张核心表字段、类型与索引设计只建一张 student 表也能跑但带上班主任、专业、入学年份后会有两个问题同样的班级名在每行里重复存储改一次专业名称要 UPDATE 几十行没有班级维度以后想按专业统计人数只能靠字符串 LIKE 匹配。所以按三张主表设计再加一张操作日志表日志表放在最后一章讲。CREATE DATABASE IF NOT EXISTS student_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE student_db; CREATE TABLE class ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, class_name VARCHAR(50) NOT NULL, major VARCHAR(50) NOT NULL DEFAULT , grade SMALLINT UNSIGNED NOT NULL, -- 入学年份如 2024 counselor VARCHAR(20) NOT NULL DEFAULT , -- 辅导员姓名 UNIQUE KEY uk_class_name (class_name) ) ENGINEInnoDB; CREATE TABLE student ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, sno CHAR(12) NOT NULL, -- 学号固定 12 位CHAR 比 VARCHAR 省空间 name VARCHAR(30) NOT NULL, gender TINYINT NOT NULL DEFAULT 0, -- 0 未知 1 男 2 女 birth_date DATE DEFAULT NULL, phone VARCHAR(20) NOT NULL DEFAULT , class_id INT UNSIGNED NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_sno (sno), KEY idx_class_id (class_id), KEY idx_name (name), CONSTRAINT fk_student_class FOREIGN KEY (class_id) REFERENCES class(id) ) ENGINEInnoDB; CREATE TABLE sys_user ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(30) NOT NULL, password_hash VARCHAR(255) NOT NULL, -- 只存哈希不存明文 role TINYINT NOT NULL DEFAULT 1, -- 1 管理员 2 普通教师 UNIQUE KEY uk_username (username) ) ENGINEInnoDB;字段设计上有几个值得说的点。学号用CHAR(12)而不是VARCHAR(12)长度固定时 CHAR 的检索和存储都比 VARCHAR 规整VARCHAR 还要额外存 1 到 2 字节长度前缀。gender用 TINYINT 而不是枚举因为业务上随时可能加“保密”或“未知”改枚举要跑 ALTER TABLE加一个数字含义则只需改后端映射。密码字段给VARCHAR(255)是为password_hash()输出留余地bcrypt 现在是 60 位PASSWORD_DEFAULT以后换算法可能变长。索引这边uk_sno唯一约束承担了学号重复的业务校验比“先 SELECT 再 INSERT”更可靠idx_class_id供 student 与 class 关联时的外键检查使用idx_name是给第 5 章按姓名搜索准备的。外键约束只有 InnoDB 生效如果考核环境里用了 MyISAM外键会静默失效不报错这是 mysql 面试题里很经典的坑。2.3 用 Navicat 或 MySQL Workbench 导入初始化数据建表之后要初始化班级和一个管理员账号。管理员密码不能手写明文 INSERT用命令行先算出哈希php -r echo password_hash(admin123, PASSWORD_DEFAULT), PHP_EOL;把输出的一长串哈希复制进 SQL注意那串字符里有$符号在 bash 里直接粘贴到 SQL 文件没问题但如果用命令行 echo 拼接就要小心变量展开。INSERT INTO class (class_name, major, grade, counselor) VALUES (软件241, 软件技术, 2024, 李老师), (网络242, 计算机网络, 2024, 王老师); INSERT INTO sys_user (username, password_hash) VALUES (admin, $2y$10$这里替换成你生成的哈希值);在 Navicat 或 MySQL Workbench 里整段执行时先看连接属性里的字符集。工具默认可能连的是 utf8表是 utf8mb4执行中文 INSERT 后表里显示正常页面读出来却是乱码这种问题最隐蔽。导入后跑一条关联查询验证数据SELECT s.sno, s.name, c.class_name FROM student s LEFT JOIN class c ON s.class_id c.id;LEFT JOIN保证即使学生记录因外键约束失效被分到已删除班级查询也不丢行。初始化数据这一步做完数据库侧就绪接下来进入 PHP 代码实现。3. 用 PHP 的 PDO 拆出连接层与模型层学生增删改查怎么写才不算玩具3.1 为什么选 PDO 而不是 mysqli 作为数据库抽象层PHP 5.5 废弃mysql_*系列函数PHP 7.0 彻底移除现在任何新代码里出现mysql_connect都意味着项目至少十年没维护了。剩下mysqli和PDO二选一两者都支持预处理语句也都有面向对象写法。PDO 的核心优势是数据库抽象同一套代码在 MySQL 上开发上生产换成 MariaDB只需要改 DSN业务代码一行不动mysqli 则绑死在 MySQL 细节上。另一个差异在预处理模式的控制粒度上PDO 可以显式关闭模拟预处理让参数真正走 MySQL 端原生协议这个开关是防注入的关键见 3.2。对于“学生信息管理系统”这种要被反复改交、换环境演示的项目PDO 是下限更高的选择。3.2 PDO 单例连接与 Student 模型的最小实现连接层用静态变量实现单例避免一个请求里多次 new PDO 造成多次握手。关键配置写在构造函数第四个参数里。?php // config/db.php declare(strict_types1); const DB_HOST 127.0.0.1; const DB_PORT 3306; const DB_NAME student_db; const DB_USER root; const DB_PASS root; const DB_CHARSET utf8mb4; function pdo(): PDO { static $pdo null; if ($pdo instanceof PDO) { return $pdo; } $dsn sprintf( mysql:host%s;port%s;dbname%s;charset%s, DB_HOST, DB_PORT, DB_NAME, DB_CHARSET ); $pdo new PDO($dsn, DB_USER, DB_PASS, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES false, ]); return $pdo; }三个配置各有作用。ERRMODE_EXCEPTION让 SQL 执行出错时抛异常而不是返回 false配合全局异常处理可以直接拿到错误信息和调用栈FETCH_ASSOC省去每次fetch(PDO::FETCH_ASSOC)的重复代码ATTR_EMULATE_PREPARES false关闭模拟预处理参数与 SQL 语句分两条通道发给 MySQL注入语句在 MySQL 端只被当作普通字符串。模型层只做数据访问不混入页面逻辑。下面是 Student 的最小实现。?php // model/Student.php require_once __DIR__ . /../config/db.php; class Student { public static function findById(int $id): ?array { $stmt pdo()-prepare(SELECT * FROM student WHERE id ? LIMIT 1); $stmt-execute([$id]); $row $stmt-fetch(); return $row false ? null : $row; } public static function page(int $page, int $perPage, string $keyword ): array { $where ; $params []; if ($keyword ! ) { // 转义 LIKE 通配符防止 % _ 扩大匹配范围 $where WHERE s.name LIKE :kw OR s.sno LIKE :kw; $params[kw] % . addcslashes($keyword, %_) . %; } $sql SELECT s.id, s.sno, s.name, s.gender, s.phone, c.class_name FROM student s LEFT JOIN class c ON s.class_id c.id $where ORDER BY s.id DESC LIMIT :offset, :perPage; $stmt pdo()-prepare($sql); foreach ($params as $k $v) { $stmt-bindValue($k, $v); } // LIMIT 参数必须显式绑成 int否则会被当字符串 $stmt-bindValue(:offset, ($page - 1) * $perPage, PDO::PARAM_INT); $stmt-bindValue(:perPage, $perPage, PDO::PARAM_INT); $stmt-execute(); return $stmt-fetchAll(); } public static function create(array $d): void { $sql INSERT INTO student (sno, name, gender, birth_date, phone, class_id) VALUES (:sno, :name, :gender, :birth_date, :phone, :class_id); $stmt pdo()-prepare($sql); $stmt-execute([ :sno $d[sno], :name $d[name], :gender (int)$d[gender], :birth_date $d[birth_date] ?: null, :phone $d[phone], :class_id (int)$d[class_id], ]); } public static function delete(int $id): void { $stmt pdo()-prepare(DELETE FROM student WHERE id ?); $stmt-execute([$id]); } }这个模型类里暴露了三个容易出错的位置。findById的返回类型写成?array查询不到返回 null调用方必须判空否则$row[name]会触发 Warning。分页查询里 LIMIT 的两个参数不能放进execute()的数组里让它自动绑定因为 PDO 默认会把它们当字符串处理MySQL 对LIMIT 10, 20的隐式转换在不同版本上行为不一致显式PDO::PARAM_INT最稳。addcslashes($keyword, %_)是很多人忽略的细节用户搜100%时不进 LIKE 是因为预处理挡住了注入但%仍被当成通配符把所有包含任意前缀的学号都捞出来这不是注入是查询语义错误。3.3 页面层列表、分页与搜索共用一个入口文件单入口的好处是初始化逻辑只写一次权限判断不会漏。这里用index.php做列表页act参数留给后续扩展。?php // index.php require_once model/Student.php; require_once model/ClassRoom.php; // 类方法略返回 class 表全部行 $page max(1, (int)($_GET[page] ?? 1)); $perPage 10; $keyword trim($_GET[kw] ?? ); $rows Student::page($page, $perPage, $keyword); $totalStmt pdo()-prepare(SELECT COUNT(*) FROM student WHERE name LIKE :kw OR sno LIKE :kw); $totalStmt-execute([:kw % . addcslashes($keyword, %_) . %]); $total (int)$totalStmt-fetchColumn(); $pages (int)ceil($total / $perPage); ? table theadtrth学号/thth姓名/thth性别/thth班级/thth操作/th/tr/thead tbody ?php foreach ($rows as $r): ? tr td? htmlspecialchars((string)($r[sno] ?? ), ENT_QUOTES) ?/td td? htmlspecialchars((string)($r[name] ?? ), ENT_QUOTES) ?/td td? $r[gender] 1 ? 男 : ($r[gender] 2 ? 女 : -) ?/td td? htmlspecialchars((string)($r[class_name] ?? ), ENT_QUOTES) ?/td td a hrefedit.php?id? (int)$r[id] ?编辑/a a hrefdelete.php?id? (int)$r[id] ? onclickreturn confirm(确定删除?)删除/a /td /tr ?php endforeach; ? /tbody /table p共 ? $total ? 条第 ? $page ? / ? $pages ? 页/p ?php for ($i 1; $i $pages; $i): ? a href?page? $i ?kw? urlencode($keyword) ?? $i ?/a ?php endfor; ?列表页的输出全部经过htmlspecialchars(..., ENT_QUOTES)这是防 XSS 的标准动作。姓名字段是用户输入不转义的话有人把名字提交成scriptalert(1)/script所有访问列表页的管理员浏览器都会执行这段脚本。ENT_QUOTES同时转义单引号和双引号比默认只转双引号更严格。分页链接带着urlencode($keyword)保证关键词里含、%时翻页不会破坏 URL 结构。这里删除用的是 GET 链接代码短、演示方便但它带着两个隐患搜索引擎爬虫可能顺着链接抓到 delete.php 触发删除恶意站点可以构造一个img srcdelete.php?id5标签管理员只要打开那个页面浏览器带 Cookie 请求学生记录就被删了。这个问题的完整解法在第四章。3.4 表单提交、校验与回显的正确顺序编辑页面最常见的问题是“表单校验失败后用户刚输入的内容被数据库旧值覆盖”。正确顺序是GET 请求时用数据库值做默认回显POST 请求时先用$_POST整体覆盖默认值再做校验校验失败也回填用户刚提交的内容。// edit.php 核心逻辑 $errors []; $old Student::findById((int)($_GET[id] ?? 0)); if ($old null) { exit(学生不存在); } if ($_SERVER[REQUEST_METHOD] POST) { $old array_merge($old, $_POST); // 用提交值覆盖旧值 $errors validate($old); if (!$errors) { Student::update($old); // update 方法实现同 create加 WHERE id header(Location: index.php?updated1); exit; } } // 下面渲染表单$old 里的值就同时服务首次回显和错误回填validate()最少要校验四件事学号为 12 位数字姓名为空或超过 30 字要报错班级 ID 必须存在于 class 表出生日期格式符合 YYYY-MM-DD。关于学号唯一性常见做法是提交前先SELECT COUNT(*) WHERE sno ? AND id ?查一遍但并发请求下两次检查之间仍可能插入重复数据最可靠的兜底是捕获 insert 时的 1062 唯一键冲突异常把PDOException转成“学号已存在”的提示。先校验后入库是业务习惯捕获唯一键冲突是数据层兜底两层都写才算完整。4. php 后台系统的安全基线注入、XSS、CSRF 与密码哈希都在这几百行里4.1 一个被黑透的 delete.php 长什么样网上流传的很多“PHP 源码学生管理系统”里删除功能还是这个写法$id $_GET[id]; mysql_query(DELETE FROM student WHERE id$id);攻击者在 URL 上拼delete.php?id1 OR 11SQL 变成DELETE FROM student WHERE id1 OR 11整张表清空。更隐蔽的是订单号、状态值这类看起来像数字的字段intval()能挡住一部分但所有非数字字段的注入例如登录用户名只能靠参数化查询解决。这类源码还停留在mysql_*时代PHP 7 以上直接跑不起来。把这个系统当安全改造练手样本第一原则是所有 SQL 一律预处理语句用户输入永远不进 SQL 字符串。4.2 PDO 预处理的边界伪预处理与 query 直拼PDO 的预处理有两种模式。ATTR_EMULATE_PREPARES为 true 时PDO 在客户端把参数拼接进 SQL 再发给 MySQL本质是模拟预处理不是真正的服务端预处理false 时才走 MySQL 原生的 COM_STMT_PREPARE。第 3 章连接层已经关掉模拟这一层防线就有了。另一个坑是query()方法它不接受参数绑定任何把外部变量拼进 SQL 再传给 query 的写法和当年mysql_query没有本质区别。常见写法是否安全问题点mysql_query(DELETE ... WHERE id$id)否拼接内容被当作 SQL 执行mysqli_query($conn,SELECT ... WHERE name$name)否引号转义不全宽字节可绕过$pdo-query(SELECT ... id . $_GET[id])否query 不走预处理通道prepare execute([$id])是参数与语句分离传输prepare bindValue(:id, $id, PDO::PARAM_INT)是额外显式类型约束表格最后一行是推荐姿势。bindValue 显式声明PARAM_INT比 execute 数组自动绑定多一层类型保护例如用户传1; DROP TABLE student类型约束直接触发类型不匹配而拒绝执行虽然预处理本身已经挡住了注入但类型约束让攻击面更早关闭。动态拼接的表名、列名没法预处理因为这些属于 SQL 结构不是数据学生管理系统里只有排序字段可能来自外部这个口子在第五章处理。4.3 删除改 POST 表单并给每个表单加 CSRF 令牌第三章列表页的 GET 删除链接要改成 POST 表单同时加 CSRF 校验。CSRF 的攻击路径是管理员已登录系统Cookie 有效此时访问了攻击者构造的恶意页面页面里的表单自动向目标站点提交删除请求浏览器会带上管理员的 Cookie服务器无法分辨这是用户主动操作还是恶意请求。防御办法是给表单塞一个只有服务器 session 里才有的随机令牌。?php session_start(); function csrf_token(): string { if (empty($_SESSION[csrf_token])) { $_SESSION[csrf_token] bin2hex(random_bytes(32)); } return $_SESSION[csrf_token]; } function csrf_verify(): void { if (($_POST[csrf_token] ?? ) ! ($_SESSION[csrf_token] ?? )) { http_response_code(403); exit(CSRF token mismatch); } } ?delete.php 的用户侧表单这样写form methodpost actiondelete.php onsubmitreturn confirm(确定删除?) input typehidden namecsrf_token value? csrf_token() ? input typehidden nameid value? (int)$r[id] ? button typesubmit删除/button /formdelete.php 接收端require_once config/db.php; require_once model/Student.php; session_start(); if (($_SESSION[user] ?? null) null) { exit(请先登录); } csrf_verify(); Student::delete((int)($_POST[id] ?? 0)); header(Location: index.php);csrf_token()从 session 取令牌表单和校验读取的是同一个 session攻击者的页面拿不到管理员的 session 数据伪造的请求会在csrf_verify()处直接 403。random_bytes(32)生成 64 个十六进制字符比常见的uniqid()或md5(mt_rand())安全得多。这个实现是整会话复用令牌低频后台够用每次刷新令牌则更严格但要在页面间传递并处理多标签页冲突。4.4 登录态与密码哈希password_hash 和 password_verify 的标准组合密码存储只谈一条规则MySQL 里永远只存password_hash()的输出登录时用password_verify()比对绝不存明文也不用 md5。md5 加盐虽然比裸 md5 好但 bcrypt 自带 salt 和 cost 参数验证时从哈希里读取盐对硬件加速攻击的抵抗性远强于 md5 族。$stmt pdo()-prepare(SELECT id, username, password_hash, role FROM sys_user WHERE username ? LIMIT 1); $stmt-execute([$username]); $user $stmt-fetch(); if ($user password_verify($password, $user[password_hash])) { session_regenerate_id(true); // 登录成功后更换 session id防会话固定 $_SESSION[user] [ id (int)$user[id], role (int)$user[role], ]; header(Location: index.php); exit; }session_regenerate_id(true)是很多教学代码省略的一步登录前攻击者如果能预置一个 session id 给受害者受害者登录后这个会话就变成已认证会话攻击者可共用。登录成功后强制换新 id旧会话作废这条攻击链就断了。密码哈希字段的VARCHAR(255)在第 2 章的建表语句里就是为这个函数预留的。登录之后的管理操作统一在入口处检查$_SESSION[user]是否存在普通教师角色再校验role避免学生登录后直接访问管理页面这类越权问题在实训演示时最容易丢分。5. 从能跑到扛得住mysql 创建索引、排序与分页深翻页的取舍5.1 LIKE 查询与索引失效用 EXPLAIN 验证而不是猜列表页加了搜索后最自然的写法是WHERE s.name LIKE :kw。但 LIKE 能不能走索引取决于通配符位置。用 EXPLAIN 看执行计划最直接EXPLAIN SELECT * FROM student WHERE name LIKE 张%; EXPLAIN SELECT * FROM student WHERE name LIKE %张%;第一条的type是range说明 idx_name 索引被用上范围扫描 1000 行第二条type是ALL全表扫。原因在于 B 树索引按前缀排列张%可以定位到第一个“张”字的叶子节点再顺序扫描%张%不知道匹配从哪个字符开始只能遍历所有叶子。这条规则对学号搜索同样成立sno LIKE 2024%走索引sno LIKE %001%不走。学生系统里数据量几万条时全表扫也谈不上灾难但把这个习惯带到订单表、流水表就会出事。业务上强行需要中间匹配时MySQL 8 可以选全文索引或者把搜索词拆词后走前缀匹配。作为实训项目把“前导通配符导致索引失效”这件事写进注释比功能本身更能体现对 mysql 创建索引的理解。5.2 深翻页为什么越来越慢LIMIT 100000, 20 的代价与游标分页LIMIT :offset, :perPage写起来最直观但数据库执行时要把前 100000 行都扫描出来再丢掉前 100000 行只返回 20 行。offset 越大无效扫描越多。第 3 章的 page 方法在演示环境没问题数据量到几十万时会明显变慢。两种常见改法第一种游标式分页也叫 keyset pagination适用于排序字段唯一-- 第一页 SELECT id, sno, name FROM student ORDER BY id DESC LIMIT 20; -- 下一页把上一页最后一条记录的 id 传进来 SELECT id, sno, name FROM student WHERE id :last_id ORDER BY id DESC LIMIT 20;直接跳过扫描MySQL 从 id 小于上次位置的地方开始取只扫 20 行。代价是失去了“跳转到任意页”的能力只能上一页下一页但换取的是翻页深度增加时性能恒定。第二种改法是子查询先取主键再回表SELECT * FROM student WHERE id IN ( SELECT id FROM ( SELECT id FROM student ORDER BY id DESC LIMIT 100000, 20 ) tmp );内层子查询只查 id走覆盖索引不回表读整行数据省掉大量随机 IO。这个写法不像游标那样一劳永逸offset 超过百万时内层扫描依然存在但作为不改接口的优化能缓解不少。游标式分页要求排序字段值唯一如果按birth_date排序同一天出生的学生会漏数据必须改成ORDER BY birth_date, id让(birth_date, id)复合排序保证稳定。5.3 触发器、存储过程与事务在这个系统里的适用边界教学版学生管理系统里存储过程和触发器出现得很频繁理由是“把统计逻辑放数据库更高效”。实际维护时会发现存储过程无法进 Git 做版本对比换库时语法不一定兼容出问题只能用数据库日志排查调试成本远高于 PHP 代码。对这个体量的 CRUD统计交给SELECT COUNT(*)加索引就够了触发器更是会把隐式操作藏起来业务代码里看不到线上出问题很难追溯。真正该用数据库特性的是事务。学生转班要同时更新student.class_id和写入一条班级变更日志两步必须原子完成要么都成功要么都回滚$pdo pdo(); $pdo-beginTransaction(); try { Student::changeClass($studentId, $newClassId); Log::write($adminId, change_class, student{$studentId} to class{$newClassId}); $pdo-commit(); } catch (Throwable $e) { $pdo-rollBack(); error_log($e-getMessage()); }beginTransaction(),commit(),rollBack()操作的是当前 PDO 连接所以必须始终使用同一个pdo()实例不能在中间重新连接否则第二个连接会带着未提交的事务悬挂。MyISAM 引擎不支持事务这就是建表语句里坚持ENGINEInnoDB的原因。这块内容在 mysql 面试题里经常被问到答出“存储过程难维护、事务必须 InnoDB、排序字段唯一性影响分页方案”三点比背概念更有说服力。6. 部署到 nginx php-fpm配置清单、三个高频报错与审计日志收尾6.1 nginx 站点配置与 PHP-FPM 的转发关系本地 phpStudy 跑通后部署到 Linux 服务器一般是 nginx php-fpm 组合。nginx 本身不处理 PHP它把.php请求转给 fastcgi 进程响应再返回给浏览器。站点配置最少要包含这几行server { listen 80; server_name student.example.com; root /var/www/student; # index.php 所在目录 index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }try_files的作用是请求路径对应的文件不存在时把请求内部重定向到index.php原生 PHP 项目靠它免去写 rewrite 规则。fastcgi_pass支持 unix socket 和 TCP 两种写法本机部署用 unix socket 更快跨机部署写127.0.0.1:9000。这个 sock 路径必须和你机器上实际的 PHP 版本一致写出php8.0-fpm.sock而装的是 8.1nginx 会立刻报 502。6.2 三个高频报错怎么定位第一个是连接失败。SQLSTATE[HY000] [2002] Connection refused说明 PHP 进程连不上 MySQL先确认 MySQL 服务是否在运行、端口是否 3306、连接账号是否允许从当前主机访问。本地 phpStudy 里最常见的原因是 MySQL 8.0 和 5.7 切换后 root 密码变了而config/db.php里还写着旧密码。第二个是中文乱码。文件编码、数据库字符集、连接字符集三处要一致PHP 文件存成 UTF-8 无 BOM表字符集 utf8mb4PDO 的 DSN 里带charsetutf8mb4。mysqli 时代漏写SET NAMES utf8mb4就乱码PDO 在 DSN 里声明后不用再单独执行。第三个是白屏 500。先在命令行跑php -l index.php查语法再tail -f /var/log/nginx/error.log看 nginx 转到哪里最后看 php-fpm 错误日志。PHP 7.4 之后语法错误和未捕获异常都会导致 500开发阶段把error_reporting(E_ALL)打开能省一半排查时间。6.3 加一张操作日志表让系统可审计、可回查部署完成后值得为系统补最后一块操作日志。学生信息被误改后没有日志就只能查 binlog有了日志直接一条 SQL 定位操作人和时间。表结构在第二章预留的思路这里落地CREATE TABLE op_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, action VARCHAR(30) NOT NULL, -- 如 create / update / delete target_id INT UNSIGNED NOT NULL DEFAULT 0, detail VARCHAR(500) NOT NULL DEFAULT , created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, created_at) ) ENGINEInnoDB;在每个写操作调用处补一行Log::write()记录谁在什么时间改了哪条记录。日志写失败不应影响主流程用 try-catch 包住catch 里只error_log()不进业务异常。这个体量的系统不需要引入消息队列或异步任务直接同步插入也才毫秒级把“队列”留给真正高并发的场景。日志表建好、每一处增删改都补上这行调用系统的每次数据变化都有来源可查。本文还有配套的精品资源点击获取