
资料在文章末尾
上一篇的注册系统把用户数据写进了 users.json 文件。这种方式在练习阶段够用,但真上生产环境问题就暴露了:想查某个用户得把整个文件读进来遍历;两个人同时注册可能互相覆盖写入;数据量上千后性能断崖式下跌;想按注册时间排序、统计用户数量,都得自己写一堆代码。
数据库就是来解决这些问题的。它把数据按表结构分类存放,提供标准化的 SQL 语言做增删改查,内置锁机制保证多人同时操作不冲突,还有索引让查询速度不随数据量增长而暴跌。MySQL 是最主流的开源关系型数据库,和 PHP 组合就是经典的 LAMP 技术栈,本篇所有操作基于它。
MySQL 是关系型数据库管理系统(RDBMS),数据按表组织,表和表之间通过键关联。先把这几个术语记住,后面写代码会反复用到:
• 数据库(Database):一个项目对应一个数据库,里面可以有多张表• 表(Table):具体存数据的地方,由行和列组成,类似 Excel 工作表• 行(Row):一条完整记录,比如一个用户的所有信息• 列(Column):记录的某个属性,比如用户名、邮箱、注册时间• 主键(Primary Key):唯一标识一行的字段,通常用自增整数 id• 外键(Foreign Key):关联另一张表的字段,比如文章表里的 user_id 指向用户表
之前装的 XAMPP 已经自带了 MySQL(实际上是 MariaDB,MySQL 的分支,完全兼容)。打开 XAMPP 控制面板,点 MySQL 那行的 Start,端口 3306 变绿就说明启动了:

也可以点 XAMPP 的 Shell 按钮打开命令行,输入 mysql -u root 回车(默认无密码),进入 MySQL 命令行后输入 SELECT VERSION(); 能看到版本号就说明一切正常:

SQL 就是操作数据库的标准语言,不管是 MySQL、PostgreSQL 还是 SQLite,SQL 语法基本通用。先从建库建表开始。
假设要做博客系统,先建数据库 blog,再建用户表 users:
-- 创建数据库(如果不存在)CREATE DATABASE IF NOT EXISTS blog DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;-- 使用该数据库USE blog;-- 创建用户表CREATE TABLE IF NOT EXISTS users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, email VARCHAR(100) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逐行说明:
• id:自增主键,每插一行自动 +1,不用手动指定• username:最长 50 字符,NOT NULL 不能为空,UNIQUE 值不能重复• password:最长 255,存哈希后的密码串• created_at:时间戳,DEFAULT CURRENT_TIMESTAMP 自动填插入时间• ENGINE=InnoDB:支持事务和外键的存储引擎• CHARSET=utf8mb4:支持 emoji 等 4 字节字符,别用 utf8(只支持 3 字节)
踩坑提醒:MySQL 里的 utf8 是阉割版,只能存 3 字节字符,遇到 emoji 会报错。建库建表一律用 utf8mb4,这是很多人的血泪教训。
INSERT INTO users (username, password, email) VALUES ('zhangsan', 'hashed_password_here', 'zhangsan@example.com');批量插入也支持,一条语句插多行:
INSERT INTO users (username, password, email) VALUES('lisi', 'pass2', 'lisi@test.com'),('wangwu', 'pass3', 'wangwu@test.com');查所有列所有行:
SELECT * FROM users;只查指定列(生产环境建议明确写列名,别用 *):
SELECT username, email FROM users;带条件查询:
SELECT * FROM users WHERE id = 1;SELECT * FROM users WHERE username LIKE '%zhang%'; -- 模糊搜索排序和限制结果数量:
SELECT * FROM users ORDER BY created_at DESC LIMIT 10; -- 按注册时间倒序,取最新10条UPDATE users SET email = 'newemail@test.com' WHERE id = 2;务必加 WHERE!忘加 WHERE 的 UPDATE 会把整张表每一行都改掉,这是个经典的删库级操作。删数据同理,下面会说到。
DELETE FROM users WHERE id = 3;同样,忘加 WHERE 会清空整张表。如果只是想清空表但保留结构,用 TRUNCATE TABLE users 更快,但这也是不可逆的。
• COUNT():计数,如 SELECT COUNT(*) FROM users;• SUM() / AVG() / MAX() / MIN():求和、平均、最大、最小• GROUP BY:分组统计,如按城市统计用户数• JOIN:多表联查,后面文章 CRUD 会用到• ALTER TABLE:修改表结构,比如加列、改列类型
命令行写 SQL 虽然专业,但刚开始学的时候可视化工具更直观。XAMPP 内置了 phpMyAdmin,浏览器访问 http://localhost/phpmyadmin 就能打开:

左侧点"新建",输入数据库名 blog,排序规则选 utf8mb4_unicode_ci,点创建:

在新建的 blog 数据库里创建表,名称填 users,字段数填 5,点执行:

然后按上面的 SQL 定义填写各字段的名称、类型、长度、索引等,点保存就行。顶部的 SQL 标签页可以直接输入 SQL 语句执行,"插入"和"浏览"功能也很好用。
个人建议:图形界面方便归方便,但 SQL 一定要自己会写。因为代码里操作数据库靠的是 PDO 执行 SQL 语句,不是靠点 phpMyAdmin 的按钮。图形工具用来调试和看数据结构就好。
• mysql 扩展:PHP 5.5 废弃,PHP 7.0 彻底移除,现在写就是报错,别用• mysqli 扩展:改进版,支持面向对象和过程化,但只支持 MySQL 一种数据库• PDO(PHP Data Objects):数据库抽象层,一套接口支持 12 种数据库,原生支持预处理语句,安全性最好
本篇只讲 PDO,它是目前 PHP 连接数据库的推荐方式。
<?php$host = '127.0.0.1';$dbname = 'blog';$username = 'root';$password = '';$charset = 'utf8mb4';try { $dsn = "mysql:host=$host;dbname=$dbname;charset=$charset"; $pdo = new PDO($dsn, $username, $password, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 抛出异常便于调试 PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // 默认返回关联数组 PDO::ATTR_EMULATE_PREPARES => false, // 使用真正的预处理 ]); echo "数据库连接成功!";} catch (PDOException $e) { die("数据库连接失败:" . $e->getMessage());}?>三个配置项很关键,解释一下:
• ERRMODE_EXCEPTION:出错时抛异常,比手动检查 errorInfo 方便,try-catch 就能接住• FETCH_ASSOC:fetch 时默认返回以列名为键的关联数组,不用每次都写 FETCH_ASSOC• EMULATE_PREPARES => false:关闭模拟预处理,让 MySQL 原生处理预处理语句,安全性更高

<?php// 数据库连接配置$host = '127.0.0.1';$dbname = 'blog';$dbUser = 'root';$dbPass = '';$charset = 'utf8mb4';$pdo = null;try { $dsn = "mysql:host=$host;dbname=$dbname;charset=$charset"; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; $pdo = new PDO($dsn, $dbUser, $dbPass, $options); echo "<p style='color:green'>数据库连接成功!</p>";} catch (PDOException $e) { die("<p style='color:red'>数据库连接失败:" . htmlspecialchars($e->getMessage()) . "</p>");}// 查询用户表数据try { $stmt = $pdo->query("SELECT * FROM users"); $users = $stmt->fetchAll(); if (empty($users)) { echo "<p>暂无用户数据</p>"; } else { foreach ($users as $user) { $username = htmlspecialchars($user['username'] ?? ''); $email = htmlspecialchars($user['email'] ?? ''); echo "用户名:{$username},邮箱:{$email}<br>"; } }} catch (PDOException $e) { echo "<p style='color:red'>查询失败:" . htmlspecialchars($e->getMessage()) . "</p>";}?>query() 适合没有外部变量的固定查询。一旦涉及用户输入,就必须用预处理语句,否则就是给 SQL 注入侵略者开门。

先看反面教材。如果你直接把用户输入拼进 SQL:
$username = $_POST['username'];$sql = "SELECT * FROM users WHERE username = '$username'";黑客在输入框里填 ' OR '1'='1,SQL 就变成了 SELECT * FROM users WHERE username = '' OR '1'='1',条件恒真,返回所有用户数据。这就是 SQL 注入攻击。
预处理语句的原理是:先把 SQL 模板发给数据库(结构已固定),再把参数单独发送(只当数据处理,不会被执行为 SQL 语法)。这样不管参数里填什么,都不可能改变 SQL 的结构,从根本上杜绝注入。
<?php// 准备 SQL 模板,用命名占位符 :username$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username");// 绑定参数$username = $_POST['username'];$stmt->bindParam(':username', $username);// 执行$stmt->execute();// 获取结果$user = $stmt->fetch(); // 获取一条记录?>$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");$stmt->execute([$id]); // 按顺序绑定$user = $stmt->fetch();命名占位符和问号占位符功能一样,选哪个看个人习惯。命名占位符可读性好一些,参数多了不容易搞混顺序。
<?php$username = $_POST['username'];$password = password_hash($_POST['password'], PASSWORD_DEFAULT);$email = $_POST['email'];$sql = "INSERT INTO users (username, password, email) VALUES (:username, :password, :email)";$stmt = $pdo->prepare($sql);$stmt->execute([ ':username' => $username, ':password' => $password, ':email' => $email,]);echo "新用户 ID:" . $pdo->lastInsertId();?>execute() 接收一个关联数组,键名对应命名占位符。lastInsertId() 返回本次插入的自增主键值,注册后自动登录经常会用到。
// 更新$sql = "UPDATE users SET email = :email WHERE id = :id";$stmt = $pdo->prepare($sql);$stmt->execute([':email' => $newEmail, ':id' => $userId]);// 删除$sql = "DELETE FROM users WHERE id = :id";$stmt = $pdo->prepare($sql);$stmt->execute([':id' => $userId]);$stmt->rowCount() 返回受影响的行数,可以用来判断更新或删除是否真的执行了。
把上一篇基于 JSON 文件的注册系统升级为数据库版。先建一张更完整的用户表,加上头像字段:
CREATE DATABASE IF NOT EXISTS blog DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;USE blog;CREATE TABLE IF NOT EXISTS users ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户主键ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', password VARCHAR(255) NOT NULL COMMENT '加密密码', email VARCHAR(100) NOT NULL UNIQUE COMMENT '邮箱', avatar VARCHAR(255) DEFAULT NULL COMMENT '头像文件相对路径', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;用单例模式封装 PDO 连接,全局复用同一个连接对象,避免重复创建:
<?php/** * 数据库公共连接文件 * 单例模式获取PDO连接,全局复用,避免重复创建连接 * @return PDO */function getPdo(): PDO{ // 静态变量仅首次调用实例化 static $pdo = null; if ($pdo === null) { $host = '127.0.0.1'; $dbname = 'blog'; $dbUser = 'root'; $dbPass = ''; $charset = 'utf8mb4'; $dsn = "mysql:host=$host;dbname=$dbname;charset=$charset"; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; $pdo = new PDO($dsn, $dbUser, $dbPass, $options); } return $pdo;}后续所有页面 require 'db.php'; 后调用 getPdo() 就能拿到连接。
注册逻辑:CSRF 校验 → 表单验证 → 头像上传 → 查重 → 密码哈希 → 写入数据库 → 自动登录跳转。代码比较长,建议照着敲一遍理解流程:
<?phprequire 'db.php';session_start();header("X-XSS-Protection: 1; mode=block");header("X-Frame-Options: DENY");// 生成CSRF令牌if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32));}$errors = [];$inputFill = ['username' => '', 'email' => ''];$uploadDir = __DIR__ . '/uploads/avatar/';if (!file_exists($uploadDir)) { mkdir($uploadDir, 0755, true);}$avatarPath = '';if ($_SERVER['REQUEST_METHOD'] === 'POST') { // CSRF校验 $postToken = $_POST['csrf_token'] ?? ''; if (!hash_equals($_SESSION['csrf_token'], $postToken)) { $errors['global'] = '非法请求,禁止跨站重复提交'; } $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; $password2 = $_POST['password2'] ?? ''; $email = trim($_POST['email'] ?? ''); $inputFill['username'] = $username; $inputFill['email'] = $email; // 表单校验 if (empty($username)) { $errors['username'] = '用户名不能为空'; } elseif (mb_strlen($username) < 3) { $errors['username'] = '用户名至少3个字符'; } if (empty($password)) { $errors['password'] = '密码不能为空'; } elseif (strlen($password) < 6) { $errors['password'] = '密码长度最少6位'; } if ($password !== $password2) { $errors['password2'] = '两次输入密码不一致'; } if (empty($email)) { $errors['email'] = '邮箱不能为空'; } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors['email'] = '邮箱格式不合法'; } // 头像上传处理 if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] !== UPLOAD_ERR_NO_FILE) { $file = $_FILES['avatar']; if ($file['error'] !== UPLOAD_ERR_OK) { $errors['avatar'] = '文件上传失败,请重新选择'; } else { $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($file['tmp_name']); $allowMime = ['image/jpeg', 'image/png']; if (!in_array($mime, $allowMime)) { $errors['avatar'] = '仅支持 JPG / PNG 图片头像'; } elseif ($file['size'] > 1048576) { $errors['avatar'] = '头像文件不能超过 1MB'; } else { $ext = $mime === 'image/png' ? 'png' : 'jpg'; $fileName = md5(uniqid(microtime(true), true) . $username) . '.' . $ext; $destFile = $uploadDir . $fileName; if (move_uploaded_file($file['tmp_name'], $destFile)) { $avatarPath = 'uploads/avatar/' . $fileName; } else { $errors['avatar'] = '头像保存失败,检查目录权限'; } } } } // 无错误再操作数据库 if (empty($errors)) { try { $pdo = getPdo(); // 查重 $checkSql = "SELECT id FROM users WHERE username = :username"; $checkStmt = $pdo->prepare($checkSql); $checkStmt->execute([':username' => $username]); if ($checkStmt->fetch()) { $errors['username'] = '该用户名已被占用,请更换'; } else { $hashPwd = password_hash($password, PASSWORD_DEFAULT); $insertSql = "INSERT INTO users (username, password, email, avatar) VALUES (:u, :p, :e, :avatar)"; $insertStmt = $pdo->prepare($insertSql); $insertStmt->execute([ ':u' => $username, ':p' => $hashPwd, ':e' => $email, ':avatar' => $avatarPath ]); $uid = $pdo->lastInsertId(); $_SESSION['logged_in'] = true; $_SESSION['user_id'] = $uid; $_SESSION['username'] = $username; $_SESSION['avatar'] = $avatarPath; header('Location: welcome_db.php', true, 302); exit; } } catch (PDOException $e) { $errors['global'] = '注册失败,服务器繁忙,请稍后重试'; } }}?><!-- HTML部分:表单含CSRF隐藏域、用户名、密码、确认密码、邮箱、头像上传 --><form method="post" enctype="multipart/form-data"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES) ?>"> <input type="text" name="username" placeholder="至少3个字符"> <input type="password" name="password" placeholder="最少6位字符"> <input type="password" name="password2" placeholder="再次输入密码"> <input type="email" name="email" placeholder="example@xxx.com"> <input type="file" name="avatar" accept="image/jpeg,image/png"> <button type="submit">完成注册</button></form>实战细节:数据库报错信息不要直接展示给用户,catch 里用通用提示"服务器繁忙"即可,真实错误写日志。因为 PDO 异常信息可能暴露表结构或 SQL 语句,给攻击者可乘之机。
登录逻辑:查用户名 → password_verify 校验密码 → 写 Session → 跳转欢迎页:
<?phprequire 'db.php';session_start();header("X-XSS-Protection: 1; mode=block");header("X-Frame-Options: DENY");$error = '';$fillUser = '';if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; $fillUser = $username; if (empty($username) || empty($password)) { $error = '用户名和密码均不能为空'; } else { try { $pdo = getPdo(); $sql = "SELECT id, username, password, avatar FROM users WHERE username = :u"; $stmt = $pdo->prepare($sql); $stmt->execute([':u' => $username]); $user = $stmt->fetch(); if ($user && password_verify($password, $user['password'])) { $_SESSION['logged_in'] = true; $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; $_SESSION['avatar'] = $user['avatar']; header('Location: welcome_db.php', true, 302); exit; } else { $error = '用户名或密码不正确'; } } catch (PDOException $e) { $error = '登录异常,请稍后重试'; } }}?><!-- HTML部分:用户名输入框 + 密码输入框 + 登录按钮 --><form method="post"> <input type="text" name="username" placeholder="请输入注册用户名"> <input type="password" name="password" placeholder="输入登录密码"> <button type="submit">登录</button></form>注意:登录失败提示统一写"用户名或密码不正确",不要区分"用户名不存在"和"密码错误"。否则攻击者可以通过提示差异枚举出哪些用户名是存在的,这叫用户枚举攻击。
welcome_db.php 是受登录保护的页面,未登录自动跳回登录页,显示用户名和头像:
<?phpsession_start();if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) { header('Location: login_db.php', true, 302); exit;}$userName = htmlspecialchars($_SESSION['username'], ENT_QUOTES);$avatar = $_SESSION['avatar'] ?? '';?><!DOCTYPE html><html lang="zh-CN"><head> <meta charset="UTF-8"> <title>个人中心</title></head><body> <h2>欢迎你,<?= $userName ?>!</h2> <div> <?php if (!empty($avatar) && file_exists($avatar)): ?> <img src="<?= htmlspecialchars($avatar, ENT_QUOTES) ?>" style="width:140px;height:140px;border-radius:50%;object-fit:cover;border:3px solid #2563eb;"> <?php else: ?> <div style="width:140px;height:140px;border-radius:50%;background:#e2e8f0;display:inline-flex;align-items:center;justify-content:center;color:#666;">暂无头像</div> <?php endif; ?> </div> <a href="logout_db.php" style="display:inline-block;margin-top:24px;padding:10px 24px;background:#6c757d;color:#fff;text-decoration:none;border-radius:6px;">安全退出登录</a></body></html>logout_db.php 安全退出三步走——清 Session 数据、清 Cookie、销毁会话文件:
<?phpsession_start();// 1. 清空会话数据$_SESSION = [];// 2. 销毁浏览器Session Cookieif (ini_get("session.use_cookies")) { $cookieParam = session_get_cookie_params(); setcookie( session_name(), '', time() - 86400, $cookieParam["path"], $cookieParam["domain"], $cookieParam["secure"], $cookieParam["httponly"] );}// 3. 销毁服务器端会话文件session_destroy();// 4. 跳转登录页header('Location: login_db.php', true, 302);exit;?>


继续完善博客系统,实现文章的增、查、改、删完整流程。先建文章表,用外键关联用户表:
USE blog;CREATE TABLE posts ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, title VARCHAR(200) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;ON DELETE CASCADE 表示用户被删除时,其名下文章自动一起删除。外键约束保证文章必须有对应的作者。
登录后才能发布,CSRF 校验 + 表单验证 + 预处理插入:
<?phprequire 'db.php';session_start();header("X-XSS-Protection: 1; mode=block");header("X-Frame-Options: DENY");// 未登录拦截if (empty($_SESSION['logged_in'])) { header('Location: login_db.php', true, 302); exit;}// CSRF令牌if (empty($_SESSION['csrf_post'])) { $_SESSION['csrf_post'] = bin2hex(random_bytes(32));}$errors = [];$titleFill = '';$contentFill = '';if ($_SERVER['REQUEST_METHOD'] === 'POST') { $token = $_POST['csrf_token'] ?? ''; if (!hash_equals($_SESSION['csrf_post'], $token)) { $errors[] = '非法提交请求'; } $titleFill = trim($_POST['title'] ?? ''); $contentFill = trim($_POST['content'] ?? ''); if (empty($titleFill)) $errors[] = '文章标题不能为空'; if (mb_strlen($titleFill) > 200) $errors[] = '标题不能超过200个字符'; if (empty($contentFill)) $errors[] = '文章内容不能为空'; if (empty($errors)) { try { $pdo = getPdo(); $stmt = $pdo->prepare("INSERT INTO posts (user_id, title, content) VALUES (:uid, :title, :content)"); $stmt->execute([ ':uid' => $_SESSION['user_id'], ':title' => $titleFill, ':content' => $contentFill ]); header('Location: list_posts.php', true, 302); exit; } catch (PDOException $e) { $errors[] = '文章发布失败,请稍后重试'; } }}?><!-- HTML部分:标题输入框 + 内容textarea + CSRF隐藏域 + 发布按钮 -->用 JOIN 联查文章表和用户表,一次查询拿到文章标题、作者名、发布时间:
<?phprequire 'db.php';session_start();header("X-XSS-Protection: 1; mode=block");header("X-Frame-Options: DENY");$pdo = getPdo();// 联表查询文章+作者$sql = "SELECT p.id, p.title, p.created_at, u.username FROM posts p JOIN users u ON p.user_id = u.id ORDER BY p.created_at DESC";$stmt = $pdo->query($sql);$postList = $stmt->fetchAll();?><!-- HTML部分:遍历$postList输出文章卡片,每篇带标题链接和作者信息 --><?phprequire 'db.php';session_start();header("X-XSS-Protection: 1; mode=block");header("X-Frame-Options: DENY");$id = trim($_GET['id'] ?? '');$pdo = getPdo();$sql = "SELECT p.*, u.username, u.id as author_uid FROM posts p JOIN users u ON p.user_id = u.id WHERE p.id = :pid";$stmt = $pdo->prepare($sql);$stmt->execute([':pid' => $id]);$post = $stmt->fetch();// 文章不存在则跳转if (!$post) { header('Location: list_posts.php', true, 302); exit;}// 判断当前用户是否是作者(控制编辑/删除按钮显示)$isAuthor = (!empty($_SESSION['logged_in']) && $_SESSION['user_id'] == $post['author_uid']);?><!-- HTML部分:文章标题+作者+时间+正文,作者可见编辑/删除按钮 --><div class="content"> <?= nl2br(htmlspecialchars($post['content'])) ?></div>安全提醒:输出数据库内容到 HTML 时,仍然要用 htmlspecialchars 转义。因为用户可能在文章内容里写 <script> 标签,存进数据库再输出时就变成 XSS 攻击了。数据库存原文,输出时转义,这是标准做法。
编辑前先校验当前用户是不是文章作者,防止越权修改别人文章:
<?phprequire 'db.php';session_start();header("X-XSS-Protection: 1; mode=block");header("X-Frame-Options: DENY");if (empty($_SESSION['logged_in'])) { header('Location: login_db.php', true, 302); exit;}$id = trim($_GET['id'] ?? '');$pdo = getPdo();// 查询文章并校验归属$stmt = $pdo->prepare("SELECT * FROM posts WHERE id = :pid");$stmt->execute([':pid' => $id]);$post = $stmt->fetch();// 不是作者直接跳走if (!$post || $post['user_id'] != $_SESSION['user_id']) { header('Location: list_posts.php', true, 302); exit;}// CSRFif (empty($_SESSION['csrf_edit'])) { $_SESSION['csrf_edit'] = bin2hex(random_bytes(32));}$errors = [];$titleFill = $post['title'];$contentFill = $post['content'];if ($_SERVER['REQUEST_METHOD'] === 'POST') { $token = $_POST['csrf_token'] ?? ''; if (!hash_equals($_SESSION['csrf_edit'], $token)) { $errors[] = '非法请求'; } $titleFill = trim($_POST['title'] ?? ''); $contentFill = trim($_POST['content'] ?? ''); if (empty($titleFill)) $errors[] = '标题不能为空'; if (mb_strlen($titleFill) > 200) $errors[] = '标题最多200字符'; if (empty($contentFill)) $errors[] = '内容不能为空'; if (empty($errors)) { try { $upd = $pdo->prepare("UPDATE posts SET title=:t, content=:c WHERE id=:id"); $upd->execute([':t' => $titleFill, ':c' => $contentFill, ':id' => $id]); header("Location: view_post.php?id=$id", true, 302); exit; } catch (PDOException $e) { $errors[] = '保存修改失败'; } }}?><!-- HTML部分:预填标题和内容的编辑表单 -->删除走二次确认,GET 请求展示确认页,POST 请求才真正执行删除:
<?phprequire 'db.php';session_start();header("X-XSS-Protection: 1; mode=block");header("X-Frame-Options: DENY");if (empty($_SESSION['logged_in'])) { header('Location: login_db.php', true, 302); exit;}$id = trim($_GET['id'] ?? '');$pdo = getPdo();$stmt = $pdo->prepare("SELECT id, title, user_id FROM posts WHERE id = :pid");$stmt->execute([':pid' => $id]);$post = $stmt->fetch();// 拦截无权限/不存在if (!$post || $post['user_id'] != $_SESSION['user_id']) { header('Location: list_posts.php', true, 302); exit;}// CSRFif (empty($_SESSION['csrf_del'])) { $_SESSION['csrf_del'] = bin2hex(random_bytes(32));}$err = '';if ($_SERVER['REQUEST_METHOD'] === 'POST') { $token = $_POST['csrf_token'] ?? ''; if (!hash_equals($_SESSION['csrf_del'], $token)) { $err = '非法操作'; } else { try { $del = $pdo->prepare("DELETE FROM posts WHERE id=:id"); $del->execute([':id' => $id]); header('Location: list_posts.php', true, 302); exit; } catch (PDOException $e) { $err = '删除失败,请重试'; } }}?><!-- HTML部分:确认删除提示 + 确认/取消按钮 -->访问 http://localhost/list_posts.php 看效果:







当一组操作必须全部成功或全部失败时(比如转账:A 扣钱、B 加钱,中间断了就完蛋),就要用事务:
<?php$pdo->beginTransaction();try { $stmt1 = $pdo->prepare("UPDATE accounts SET balance = balance - 100 WHERE id = 1"); $stmt1->execute(); $stmt2 = $pdo->prepare("UPDATE accounts SET balance = balance + 100 WHERE id = 2"); $stmt2->execute(); $pdo->commit(); // 两步都成功,提交} catch (Exception $e) { $pdo->rollBack(); // 任何一步失败,回滚到事务开始前 throw $e;}?>数据量大时一页显示不完,用 LIMIT 和 OFFSET 分页。注意 LIMIT/OFFSET 的占位符要用 bindValue 指定整数类型:
<?php$page = max(1, (int)($_GET['page'] ?? 1));$perPage = 10;$offset = ($page - 1) * $perPage;$stmt = $pdo->prepare("SELECT * FROM posts ORDER BY id DESC LIMIT :limit OFFSET :offset");$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);$stmt->execute();$posts = $stmt->fetchAll();// 获取总数算总页数$totalStmt = $pdo->query("SELECT COUNT(*) FROM posts");$total = $totalStmt->fetchColumn();$totalPages = ceil($total / $perPage);?>• ORDER BY 和 LIMIT 后面不能用占位符,必须用白名单验证列名• IN (...) 查询需要动态生成占位符数量并逐个绑定• 数据库取出的内容输出到 HTML 前仍需 htmlspecialchars,防 XSS• 生产环境关闭 PDO 异常的详细信息输出,只记日志
• MySQL 基础:数据库/表/行/列/主键/外键的概念,SQL 的 CREATE/INSERT/SELECT/UPDATE/DELETE• phpMyAdmin:可视化工具,建库建表看数据方便,但代码里操作数据库还是得写 SQL• PDO 连接:三个关键配置(异常模式、关联数组、关闭模拟预处理),单例模式复用连接• 预处理语句:SQL 模板 + 参数分离,从根本上防 SQL 注入,命名占位符和问号占位符二选一• 综合实战:注册登录系统数据库版(查重/哈希/自动登录),博客文章 CRUD(联查/权限校验/二次确认删除)• 进阶:事务保证操作原子性,分页用 LIMIT/OFFSET + bindValue 指定整数类型
本文基于作者实际学习与项目经验整理,代码示例均已本地测试可运行。文字整理过程中借助了 AI 工具辅助排版。

本文配套资料包
资料下载

-END-
