1. 接手商城项目后的第一件事:别急着启动,先把环境盘清楚
先说个我自己的经历。去年接了一个二手商城项目的维护和二次开发,代码是从前一个团队手里转过来的,技术栈是 Spring Boot + MySQL + Redis + Nginx,前端用的是 Vue。拿到代码那天,我以为环境部署这种体力活两小时就能搞定——JDK 装上,MySQL 起来,npm install 一下,数据库导入,完事。结果当天从下午两点折腾到晚上十一点,中间大部分时间都耗在了环境上,真正看业务代码的时间不到一小时。
后来我把整个过程的坑逐一记录下来,再结合后来在多个项目里的部署经验,整理成了一套比较稳妥的流程。这篇文章就是围绕“商城项目的环境部署和数据查询”这条主线来写的,适合刚接触商城类项目、打算自己从零搭一套环境跑通前后端的新手,也适合那些跟我一样经常被环境问题折磨、想找一份“避坑手册”的开发者。
先说一个最核心的结论:商城项目的环境部署,难点不在“安装软件”,而在“版本匹配”和“组件之间的协作关系”。JDK 版本不一致会让项目启动直接报错;MySQL 字符集没配好,商品详情页的中文标题会变成问号;Redis 没设置密码,生产环境随时可能被写入垃圾数据;docker-compose 里依赖顺序不对,服务启动后数据库还没就绪,后端应用启动三次必挂一次。这些问题都不是什么高深的技术,但它们就是会拦住你。
我在这篇文章里会把环境部署拆成两部分来讲:第一部分是传统方式(本机直接装 JDK、MySQL、Redis),适合本地开发和调试;第二部分是基于 docker-compose 的容器化部署,适合测试环境以及正式上线的场景。然后会重点讲数据查询,也就是数据库表结构设计、索引优化、慢查询排查这套东西——因为商城项目一旦跑起来,环境部署的问题会被掩盖,数据查询的性能问题才是真正影响用户体验的拦路虎。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 传统方式部署商城项目:JDK、MySQL、Redis 的版本匹配陷阱
2.1 JDK 环境:并非“装个 Java 就能跑”
商城后端最常见的技术栈是 Java。而 Java 环境部署不像想象中的“装完就结束”,它有几个特别容易踩的坑。
第一个坑是 JDK 版本。很多商城项目用的是 Spring Boot 2.x,这个版本对 Java 8 的支持最稳定,也能跑在 Java 11 上,但部分依赖在 Java 11 下面会有兼容问题,尤其是那些早期写的自定义类加载器或反射代码。而我接手那个项目,代码里有个旧的加密工具类,在高版本 JDK 下反射访问私有字段直接抛 InaccessibleObjectException,找人查了半天才发现是 JDK 版本的问题。所以我现在的习惯是:拿到代码第一时间看 pom.xml 里 Spring Boot 的版本,再决定上哪个 JDK。Spring Boot 2.2.x 之前,老老实实用 JDK 8;2.3.x 之后可以用 JDK 11;如果你拿到的是 Spring Boot 3.x 的项目,那 JDK 17 是跑不掉的。
第二个坑是环境变量。不要以为安装完 JDK 就算环境配好了。JAVA_HOME、PATH、CLASSPATH 三个变量都得检查。我在另一台服务器上部署一个老商城项目时,明明已经用 java -version 确认了版本是 1.8,但启动脚本就是报 UnsupportedClassVersionError。查到最后发现,脚本里写的 JAVA_HOME 指向的是另一个 JDK 目录,版本是 17,而 PATH 里的 java 命令从 alternatives 链接到了 1.8,两边不一致导致混乱。
正确的检查方式是同时执行三条命令:
bash复制java -version
echo $JAVA_HOME
which java
如果 JAVA_HOME 为空或者跟 which java 指向的目录不一致,配置就会出问题。在 /etc/profile 或 ~/.bashrc 里加上:
bash复制export JAVA_HOME=/usr/local/jdk1.8.0_202
export PATH=$JAVA_HOME/bin:$PATH
export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
第三个坑是 Maven。商城项目通常有几十个依赖,本地仓库如果之前没拉过相关包,第一次构建会很慢。这时候不要干等,先检查 Maven 的 settings.xml 里有没有配置阿里云镜像:
xml复制<mirror>
<id>aliyunmaven</id>
<mirrorOf>central</mirrorOf>
<name>阿里云公共仓库</name>
<url>https://maven.aliyun.com/repository/public</url>
</mirror>
我当时就是没配镜像,一个商城项目光下载依赖就花了四十分钟,中途还因为网络波动断了两次。配置完镜像后同样的项目两分钟就拉完了。
2.2 MySQL 初始化:字符集和数据导入
商城项目的数据库部署,一个容易被忽略的是字符集。MySQL 8.0 默认字符集是 utf8mb4,但如果你用的是 5.7 版本,默认字符集可能是 latin1,导入有中文注释的 sql 文件会出现乱码,更麻烦的是商品表里的中文数据会变成问号存进去。
所以建库之前务必显式指定字符集:
sql复制CREATE DATABASE mall CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
这里解释一下为什么要用 utf8mb4 而不只是 utf8。MySQL 里的 utf8 是“阉割版”,最多只能存三个字节的字符,像 emoji 表情这种四个字节的字符直接存不进去。商城项目里的用户昵称、商品评价这种字段,用户真的会往里面放 emoji,你拦都拦不住。用 utf8mb4 就没有这个限制。
还有一个细节是时区。如果你在连接串里不加 serverTimezone 参数,Spring Boot 连接 MySQL 8.0 时经常报 The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。解决办法是在连接串后面加上:
properties复制jdbc:mysql://localhost:3306/mall?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
allowPublicKeyRetrieval=true 这个是 MySQL 8.0 的新参数,不加的话 JDBC 客户端在某些安全策略下会报 caching_sha2_password 相关的错误。还有,驱动版本要跟数据库版本对应,MySQL 8.0 数据库至少要用 mysql-connector-java 8.0.x 驱动。
导入 sql 文件的时候我建议用命令行方式而不是图形化工具:
bash复制mysql -uroot -p --default-character-set=utf8mb4 mall < /data/sql/mall_init.sql
导入完成后,可以顺手验证一下表数据:
sql复制SELECT id, name FROM mall_product LIMIT 5;
如果中文显示正常,说明字符集没问题;如果显示问号,先检查 sql 文件本身的编码格式,用 file 命令查看,确保是 UTF-8 编码。
2.3 Redis:本地能跑,线上不一定
商城项目的 Redis 一般用来做购物车缓存、验证码存储和商品详情缓存。本地开发时直接 redis-server 启动就行,但部署到测试或生产环境有两个必须改的地方。
第一个是密码。默认 Redis 没有密码,谁连上谁就能读写,这对商城项目来说风险很大。改配置文件 redis.conf 或直接启动时指定:
bash复制redis-server --requirepass yourStrongPassword123 --daemonize yes
同时在 Spring Boot 的 application.yml 里同步配置:
yaml复制spring:
redis:
host: 127.0.0.1
port: 6379
password: yourStrongPassword123
database: 0
第二个是持久化策略。商城购物车数据不能全放内存里,Redis 一重启全没了用户会很崩溃。生产环境建议开启 RDB + AOF 双重持久化。在 redis.conf 里:
conf复制appendonly yes
appendfsync everysec
save 900 1
save 300 10
save 60 10000
注意 appendfsync everysec 是性能和安全的折中方案,每次写入操作先记录到 AOF 文件但每秒才同步一次。这个模式下 Redis 挂了最多丢一秒的数据,对商城项目来说是可接受的。
3. 用 docker-compose 部署商城项目:从“可以跑”到“稳定跑”
3.1 容器化的价值不在“新”,而在“可复现”
如果你只是想本地跑起来看一眼效果,传统部署方式足够。但如果你要部署到测试环境、预发布环境,或者跟多个同事共享一套环境,我强烈建议用 docker-compose。核心原因只有一个:可复现性。同一个 compose 文件在张三的电脑、李四的服务器、五爷的云主机上跑出来的环境是一模一样的。不会再出现“我本机能跑,到你那就不行”的甩锅现场。
另外还有两个很现实的好处:
- 环境隔离。MySQL、Redis、后端应用各自在独立容器里运行,互相不污染系统环境。你不用担心卸载 MySQL 影响别的项目,删容器就够了。
- 资源清理方便。
docker-compose down一条命令就可以把整套环境停掉并清理容器。对测试环境来说,这是个非常有用的能力。
3.2 一套完整的 docker-compose.yml 应该长什么样
下面我给出一份商城项目常见的 docker-compose.yml,包含 MySQL、Redis、后端三个核心服务。这个文件是我在一个真实商城项目里用过的简化版,去掉了一些业务相关细节,保留了核心结构。
yaml复制version: '3.8'
services:
mysql:
image: mysql:8.0.32
container_name: mall-mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: mall
TZ: Asia/Shanghai
ports:
- "3306:3306"
volumes:
- ./mysql/data:/var/lib/mysql
- ./mysql/init:/docker-entrypoint-initdb.d
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_general_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot123456"]
interval: 10s
timeout: 5s
retries: 10
networks:
- mall-network
redis:
image: redis:7.0.10
container_name: mall-redis
restart: always
ports:
- "6379:6379"
environment:
TZ: Asia/Shanghai
volumes:
- ./redis/data:/data
- ./redis/redis.conf:/etc/redis/redis.conf
command: redis-server /etc/redis/redis.conf
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
networks:
- mall-network
backend:
build:
context: ./app
dockerfile: Dockerfile
container_name: mall-backend
restart: always
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: prod
TZ: Asia/Shanghai
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- mall-network
networks:
mall-network:
driver: bridge
3.3 这个文件里的几个关键设计
先看 mysql 服务的 volumes 这一段。./mysql/init 目录映射到容器的 /docker-entrypoint-initdb.d,这是 MySQL 官方镜像的一个特性:容器首次启动时,会自动执行该目录下的 .sql 文件。也就是说,你只要把建库语句、建表语句、初始数据放进 ./mysql/init 目录,首次启动就会自动完成数据库初始化。这个特性非常实用,避免了手动导入 sql 的麻烦。
Redis 部分用的是 command 指定配置文件的方式。注意 redis.conf 里需要把 daemonize 改为 no,因为容器环境要求前台运行,daemonize yes 会让容器启动后立即退出。这个坑我踩过,当时容器日志显示 Redis 启动成功,但几秒后容器就退出了,查看日志没有任何报错,其实是 Redis 进程转到后台,容器主进程就退出了。
backend 服务的 depends_on 用了新版的 condition: service_healthy 语法。这个写法解决了典型的“服务启动顺序”问题——不要只是启动顺序对,还要等依赖服务“就绪”。很多人在 docker-compose 里只用 depends_on 的默认行为,结果 MySQL 容器启动了,但 MySQL 服务还没完全就绪,后端应用连接数据库失败,直接退出。重启策略 restart: always 会让它不断重启,直到 MySQL 就绪,但这会刷出一堆报错日志,而且启动时间不可控。用 healthcheck + condition 的方式,后端容器会严格等 MySQL 健康检查通过之后才启动,稳定得多。
3.4 后端 Dockerfile 的写法
商城后端应用的 Dockerfile 其实不难写,关键在于利用多阶段构建减小镜像体积:
dockerfile复制FROM maven:3.8.7-eclipse-temurin-8 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
FROM eclipse-temurin:8-jre
WORKDIR /app
COPY --from=builder /build/target/mall-server.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
多阶段构建的好处是最终镜像只包含 JRE 和打好的 jar 包,不含 Maven 和编译产物,镜像体积可以从七八百 MB 压到两百 MB 左右。另外,把 mvn dependency:go-offline 单独放一步是为了利用 Docker 的层缓存机制,这样你修改业务代码重新构建镜像时,不需要重新下载全部依赖。
有件事值得提醒:部署的时候不要把 Spring Boot 的配置明文写在 Dockerfile 里,而是用 SPRING_PROFILES_ACTIVE 环境变量指定使用哪个配置文件。不同的环境对应不同的 application-xxx.yml,互相隔离,避免测试环境的配置被误带到生产环境。
3.5 容器化部署时常见的三个隐藏坑
坑一:镜像版本漂移。 如果我写 image: mysql:8.0.32,跟写 image: mysql:8.0 是不一样的。后者在你某次重新 pull 镜像时,可能会把镜像更新到 8.0.x 的最新补丁版本,虽然大版本一样,但某些行为可能发生变化。生产环境部署建议锁定具体小版本,这样镜像的哈希值就是确定的,不会莫名其妙踩到版本漂移的坑。
坑二:日志文件无限增长。 docker-compose 默认情况下容器的日志文件不会自动清理,跑几个月可能占用几十 GB 磁盘空间。建议在 compose 文件里加上日志限制:
yaml复制logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
坑三:时区问题。 容器默认时区是 UTC,如果你在业务代码里用 new Date() 输出时间,会跟北京时间差 8 个小时。所以我在每个服务里都加了 TZ: Asia/Shanghai 环境变量。不要小看这个细节,商城订单的时间戳差了 8 小时,用户在凌晨查看订单记录时经常会看到“订单时间在未来”这种诡异问题。
4. 商城数据库的表结构设计与基础查询优化
4.1 商城项目的核心表有哪些
环境部署好之后,就要面对数据查询的问题了。商城主流程绕不开这几个核心实体:用户、商品、库存、订单、订单项。我先说一个最通用的简化建表思路,然后重点讲查询和索引。
用户表一般是这样的:
sql复制CREATE TABLE `mall_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(64) NOT NULL COMMENT '用户名',
`password` varchar(128) NOT NULL COMMENT '密码密文',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`email` varchar(128) DEFAULT NULL COMMENT '邮箱',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态 1正常 0禁用',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
KEY `idx_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商城用户表';
商品表:
sql复制CREATE TABLE `mall_product` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`category_id` bigint(20) NOT NULL COMMENT '分类ID',
`name` varchar(255) NOT NULL COMMENT '商品名称',
`sub_title` varchar(255) DEFAULT NULL COMMENT '副标题',
`main_image` varchar(512) DEFAULT NULL COMMENT '主图URL',
`detail_html` text COMMENT '商品详情富文本',
`price` decimal(10,2) NOT NULL COMMENT '价格',
`stock` int(11) NOT NULL DEFAULT '0' COMMENT '库存',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '上架状态 1上架 0下架',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_category_status` (`category_id`, `status`),
KEY `idx_price` (`price`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
订单表:
sql复制CREATE TABLE `mall_order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL COMMENT '订单编号',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '订单状态 0待付款 1待发货 2待收货 3已完成 4已取消',
`address_snapshot` varchar(512) DEFAULT NULL COMMENT '收货地址快照',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_create_time` (`user_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
这些表结构看起来简单,但索引设计上藏着不少值得琢磨的东西。我挑几个典型的查询场景展开说说。
4.2 商品列表查询:联合索引怎么建才合理
商城首页和分类页最常见的查询是“按分类查商品,并按价格排序”:
sql复制SELECT id, name, price, main_image
FROM mall_product
WHERE category_id = 10 AND status = 1
ORDER BY price ASC
LIMIT 20;
这条 SQL 的关键点在于 category_id 和 status 同时作为筛选条件。我建了 (category_id, status) 联合索引,这样可以在索引层面一次性过滤掉两个条件。如果只建 category_id 单独索引,MySQL 需要先查出该分类下所有商品,再过滤 status,多一步回表和过滤操作。
这里顺便说一下联合索引的最左前缀原则:(category_id, status) 联合索引,只有当查询条件同时用到 category_id 和 status 时才会充分利用;如果只查 status 不查 category_id,这个索引是用不上的。所以经常有人问“联合索引里字段顺序怎么排”,答案是区分度高的、经常作为等值条件的字段放前面。
但是注意,上面这条 SQL 还带 ORDER BY price。当查询命中联合索引 (category_id, status) 后,MySQL 拿到的记录在 price 字段上是无序的,所以需要一次 filesort。数据量小没关系,但如果商品表有几百万行,这个排序可能成为瓶颈。
更优的方案是把 price 也放进索引:(category_id, status, price),这样索引本身就是按这个顺序排好的,查询结果可以直接按顺序返回,省掉 filesort。但索引不是越多越好,额外索引会影响插入和更新的性能。取舍原则是:优先覆盖你线上最热的查询路径,不是把可能的查询都建一遍索引。
4.3 订单查询:用户订单列表的经典问题
商城订单查询最常见的 SQL 是:
sql复制SELECT * FROM mall_order
WHERE user_id = 123456
ORDER BY create_time DESC
LIMIT 0, 20;
这条 SQL 的优化点也在联合索引。(user_id, create_time) 联合索引可以同时完成过滤和排序,因为索引中 user_id 相同的数据,create_time 是顺序排列的。如果只建 user_id 单独索引,查询结果需要在临时表中对 create_time 排序。数据量少的时候感觉不到差距,但一个用户累计几百个订单后,差异就出来了。
还有个小细节是 limit 分页。商城后台常见的分页方式是页数跳转,对应 SQL 是:
sql复制SELECT * FROM mall_order
WHERE user_id = 123456
ORDER BY create_time DESC
LIMIT 100000, 20;
这种深度分页有个性能陷阱:MySQL 要把前 100000 条数据全部扫描出来,再丢弃掉,最后返回 20 条。数据量越大越慢。对商城后台这种需要跳页的场景,可以考虑用游标分页:
sql复制SELECT * FROM mall_order
WHERE user_id = 123456 AND create_time < '2024-05-01 10:00:00'
ORDER BY create_time DESC
LIMIT 20;
前端通过把上一次查询最后一条记录的 create_time 传回来作为筛条件,MySQL 可以直接从索引定位到这个位置往后取 20 条,跳过了前面大量的无关数据。这种分页方式对“无限滚动”这种交互非常友好。
4.4 模糊查询和 like 的性能问题
商城后台的商品搜索框一般会支持模糊查询:
sql复制SELECT * FROM mall_product WHERE name LIKE '%连衣裙%' AND status = 1;
这种前后都带百分号的 %xxx% 写法,索引是绝对用不上的。原因很简单:B+ 树索引是按照前缀顺序排列的,要查找包含某个子串的记录,无法利用前缀定位。MySQL 只能走全表扫描,逐行判断 name 字段是否包含该子串。商品表 100 万行的时候,这条查询可能需要几百毫秒甚至一秒以上。
针对这种场景,有几个思路:
- 用前缀模糊
LIKE '连衣裙%',这种可以利用索引,但业务上往往不允许。 - 在单独的搜索字段上建立全文索引,MySQL 8.0 支持中文全文索引,但分词效果一般。
- 引入 Elasticsearch 或者轻量级的 MeiliSearch 做商品搜索,把名称、描述、标签同步到检索引擎里。这是商城项目的常规操作,但这篇文章不展开讲搜索引擎,只提醒一句:不要试图用 MySQL 的 like 硬扛商品搜索,数据量上来之后一定扛不住。
- 如果数据量不算太大(几万条以内),并且也不是核心路径,可以直接加前缀索引或者用索引覆盖来缓解。你可以先
EXPLAIN看看执行计划,如果 type 列的值为 ALL,就说明是全表扫描,需要重点关注。
我自己在实际项目当中,最优先的做法是减少模糊查询的使用频率。商城前台的搜索框,用户在输入关键词、点击搜索这个动作之间是有实际延迟的,前端可以做一个 300ms 的防抖,同时优先使用分类筛选和价格排序来减少对 like 的依赖。
5. 慢 SQL 排查实战:从发现问题到定位根因
5.1 开启慢查询日志,用数据说话
环境部署好了,查询也写完了,但有时候线上反馈慢。这时候最忌讳的做法是“猜”——猜是哪条 SQL 慢,然后盲目加索引。正确做法是先开启慢查询日志,让数据告诉你哪条 SQL 有问题。
MySQL 里可以通过以下命令临时开启:
sql复制SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = ON;
这里 long_query_time = 1 表示执行时间超过 1 秒的 SQL 会被记录。log_queries_not_using_indexes 表示没有使用索引的 SQL 也会被记录。这两个参数配合起来,很快就能找到疑似慢 SQL。注意这只是临时配置,服务器重启后会失效,要永久生效需要在 my.cnf 里加上对应配置。
查看慢查询日志内容:
sql复制SHOW VARIABLES LIKE 'slow_query_log_file';
拿到慢 SQL 之后,下一步就是使用 EXPLAIN 查看执行计划:
sql复制EXPLAIN SELECT * FROM mall_order WHERE user_id = 123456 ORDER BY create_time DESC LIMIT 20;
关键看这几个字段:
- type:const > eq_ref > ref > range > index > ALL,ALL 代表全表扫描,要警惕。
- key:实际使用的索引名,如果为 NULL 说明没走索引。
- rows:预估扫描的行数,这个值越大说明性能越差。
- Extra:
Using filesort表示需要额外排序,Using temporary表示使用临时表,这两个都是优化信号。
5.2 从一次真实案例看排查链路
有一次我在一个商城项目里接到反馈,说订单管理后台打开很慢,要等好几秒才出来。我拉出慢查询日志,发现有一条 SQL 反复出现:
sql复制SELECT * FROM mall_order
WHERE status = 0 AND create_time > '2024-04-01 00:00:00'
ORDER BY create_time ASC
LIMIT 100;
EXPLAIN 结果显示 type 为 ALL,rows 扫描了 30 万行,Extra 显示 Using filesort。原因是订单表的索引只有 (user_id, create_time),但没有单独针对 status 的索引,而这条后台查询是按状态筛选的——admin 要看所有待付款订单,不可能限制某个用户。
我当时想法是建一个 (status, create_time) 联合索引,因为这条查询既有等值条件 status,又有范围条件 create_time,还按 create_time 排序。建索引之后,type 变成了 range,rows 降为几千行,Extra 不再显示 Using filesort,查询时间从 3.2 秒降到了 80 毫秒。
但这里有个反直觉的点要提醒:建了联合索引不代表每条相关 SQL 都会用它。MySQL 的优化器会根据索引的区分度、表的数据分布决定是否走索引。执行计划不是一成不变的,表数据量增长或数据分布变化后,优化器可能会做出不同的选择。所以生产环境不仅要建索引,还要定期关注执行计划的变化。
5.3 索引失效的经典场景
除了五五开的 like,索引失效的情况还有好几种,这几种我在商城项目里都遇到过:
第一是隐式类型转换。比如订单号 order_no 在表结构中定义的是 varchar,但查询时传入的参数是数值类型:
sql复制SELECT * FROM mall_order WHERE order_no = 1234567890;
MySQL 会把字符串字段转换成数值再比较,导致索引失效,type 变成 ALL。解决办法是确保参数类型与字段类型一致,这真的算得上商城后台最常见的一种“隐形慢查询”了——接口接收的参数类型没做校验,数字被直接拼进 SQL。
第二是函数操作。对索引字段使用函数会导致索引失效:
sql复制SELECT * FROM mall_order WHERE DATE(create_time) = '2024-05-01';
这里对 create_time 用了 DATE() 函数,索引无法被利用。正确写法是范围查询:
sql复制SELECT * FROM mall_order
WHERE create_time >= '2024-05-01 00:00:00'
AND create_time < '2024-05-02 00:00:00';
第三是 OR 条件连接。如果 OR 连接的多个条件不是所有字段都建了索引,优化器可能选择全表扫描。比如:
sql复制SELECT * FROM mall_product WHERE name = '连衣裙' OR category_id = 10;
建议用 UNION 改写,让每个子查询分别走索引。
5.4 缓存一致性:商城查询链路上的一层缓冲
商城项目里数据查询还有个绕不开的话题是缓存。我之前一直强调 MySQL 查询要快、索引要建对,但无论怎么优化,MySQL 的性能上限就在那里。对热点数据(像商品详情、首页分类推荐),正确的做法是加一层 Redis 缓存。
但缓存不是随便加的,核心问题是缓存一致性。商城改价之后,用户看到的价格还是旧值,这种问题比“页面加载慢”更严重。
我用的是 Cache Aside Pattern,即旁路缓存模式。查询的时候先读缓存,没有则查数据库,再回写缓存;更新的流程是:先更新数据库,再删除缓存。这里之所以不选择先更新缓存,是因为并发写场景下,读请求可能在写请求完成之前读到旧数据。而删除缓存看似“浪费”,实际上却是一种简单可靠的设计:下次读请求反正会重新加载。
在商城项目里特别注意一点:删除缓存要尽量用延时双删,也就是在删除缓存之后,等待几百毫秒再删一次,目的是处理并发情况下短时间内可能出现的缓存重新写入旧值问题。这个方案不是万无一失,但在大多数商城场景里已经够用。
6. 部署完成后的检查清单和三个保命命令
环境部署好、数据查询也优化过了,最后一步是验证整套环境是否正常。我给自己整理了一套检查清单,每次部署完都按这个顺序走一遍。
先冒烟测试:依次检查后端应用是否能启动、MySQL 是否能连、Redis 是否能写入读取、前端页面是否能通过 Nginx 访问。每个环节都有一个最简单的验证命令:
bash复制# 检查后端服务是否监听在 8080 端口
curl -v http://localhost:8080/api/health
# 检查 MySQL 是否能正常连接
mysqladmin -uroot -p ping
# 检查 Redis 是否能正常读写
redis-cli ping
redis-cli set test_key test_value
redis-cli get test_key
然后是业务数据校验。商城项目最简单的校验方式是注册一个测试用户,走一遍“浏览商品->加入购物车->提交订单->支付”的完整链路。这个过程能一次性验证用户表、商品表、订单表和 Redis 缓存是否正常协作。注意测试完要把测试订单清掉,别污染正式数据。
最后是三个保命命令:
bash复制# 查看后端容器的实时日志
docker logs -f mall-backend
# 查看所有容器状态和资源占用
docker ps
# 清理无用容器、网络、镜像缓存
docker system prune -f
docker system prune -f 这个命令注意慎用,它会删除所有未使用的容器、网络和悬空镜像。如果你有想保留的容器,先确认它还在运行中。生产环境不建议执行这个命令,测试环境可以定期清理。
最后再分享一个我在连续踩了多次坑之后养成的习惯:每次部署完,把当前环境的版本信息、配置改动、确认过的命令按时间顺序记录下来。可以是简单的 Markdown 文件,甚至写在手机的备忘录里都行。原因是商城项目往往不是部署一次就完了,后续每次迭代升级都会涉及环境变更。有了一份完整的部署记录,下次升级时对照着操作,可以减少大量“重新踩坑”的时间。我那个二手商城项目后来做了三次版本迭代,每一次环境部署都能在上一次的记录基础上快速完成,前后花费不到半小时。
环境部署和数据查询看起来是两件事,但它们其实是商城项目稳定运行的一体两面。环境部署做不好,业务代码写得再好也起不来;数据查询不优化,环境再稳用户也会因为页面卡顿而流失。希望这篇基于实际项目经验写出来的文章,能帮你少踩一些坑。
