得,直接开始正题。聊到互联网后端,我估计前面两篇已经把HTTP协议、Web框架、接口设计这些基础盘得差不多了,这篇咱们必须得换个频道,聊聊在“学习记录”这个系列里最劝退、也最躲不开的硬骨头——数据库与缓存。你去看各大招聘网站的岗位要求,后端工程师的JD里写的是“精通MySQL与Redis”,但面试时候问的全是库表结构和缓存穿透。说白了,后端这行当,前半场比的是谁接口写得快,后半场拼的是谁数据倒腾得稳。今天这篇,我就把自己在这阶段踩过的坑、翻过的车、连夜修复的线上故障,沉淀成一份能直接抄作业的学习实录,重点说说关系型数据库到底怎么学才算入门,以及缓存这尊大佛该怎么供。
这篇笔记主要面向的是有Servlet或Spring基础、正在尝试独立做项目、或者已经在公司实习天天跟CRUD打交道的朋友。如果你还在纠结“数据库不就是写SQL吗”,那这篇内容可能正好能帮你掰扯清楚设计层面的弯弯绕绕。
1. 从“会写SQL”到“懂表结构”的认知跨越
1.1 为什么学了增删改查还是做不好系统
我见过太多同学,包括当时的我自己,以为数据库这关过了——CRUD写得飞起,连表查询不在话下,觉得自己已经是后端工程师了。结果一到自己做项目、或者接手公司的老系统,看到几百行的存储过程、一堆冗余字段的订单表、乱七八糟的加索引习惯,直接傻眼。
其实这里有个核心认知误区:SQL语法是语言能力,表结构设计才是架构能力。你会说法语,跟你能用法语写出一部小说是两码事。后端开发里,前者决定你能否混过试用期,后者决定你能不能接得住大流量的冲击。大多数新手写的系统之所以一上线就卡死,就是因为表设计得一塌糊涂——该拆的表没拆,该冗余的字段没冗余,该建索引的地方没建,不该建索引的地方糊了一堆。
我当初第一个模拟项目X是个简单的博客系统,用户表、文章表、评论表,当时觉得设计得挺美,三张表搞定一切。直到有一天地址栏被人拼上了诡异的条件,几条慢查询直接把数据库干趴了,我才意识到,写SQL谁都会,但你要能预判这个表未来三年会怎么变、会有哪些查询场景、哪些字段会被高频更新,这才是功力。
1.2 后端视角下数据库的三种角色
等到后来跟着某导师做某跨平台系统,我才慢慢理顺了一个理念,数据库在系统里其实承担着三种完全不同的角色,你必须分开去理解它们,而不是笼统地觉得“数据库就是存数据的”。
第一种是存储引擎。这是数据库最底层的物理机制,InnoDB还是MyISAM,决定了你的数据怎么落盘、怎么加锁、怎么崩溃恢复。对于绝大多数互联网应用,闭眼选InnoDB就行,什么外键约束、事务支持、行级锁全是它给的。你非要用MyISAM去省那点空间,并发一上来数据错乱就够你喝一壶的。
第二种是查询引擎。这是你跟数据库对话的界面,SQL写得好不好、索引命中没命中,全看这个层面。高阶一点的,还有执行计划分析、优化器怎么选索引的问题,你得能看懂EXPLAIN的输出,才知道自己的SQL到底烂在哪里。
第三种是分布式基础设施。等数据量过亿、读写比例失衡之后,单机数据库再能打也扛不住,这时候主从复制、读写分离、分库分表、中间件全得安排上。这个阶段其实已经超出绝大多数后端开发初期的学习范畴了,但你心里得有这根弦,知道数据库不是神,它也有天花板。
分清这三种角色之后,你再去看那些数据库调优的文章,会发现思路完全不一样了——你不是在琢磨一条SQL怎么写,你是在权衡一个系统里各个组件的职能边界。
2. 关系型数据库的事务与锁,必须得掰碎了讲
2.1 事务隔离级别,别只背口诀
面试的时候最容易被问的就是事务隔离级别,很多同学张口就来“读未提交、读已提交、可重复读、串行化”,但你再追一句“MySQL默认是哪个,为什么选它”,就哑火了。
MySQL InnoDB 默认的是可重复读(REPEATABLE READ),这事儿得从历史包袱讲起。MySQL的二进制日志格式在早期版本里,如果使用读已提交(READ COMMITTED),会导致主从复制时数据不一致。所以官方干脆把默认级别定成可重复读,配合间隙锁(Gap Lock)来解决一部分幻读问题。你听起来觉得这很随意,但实际做后端开发,你就是要面对这种“默认值未必是最合理、但却是最兼容”的现实。
实操的时候,我给你的建议是:不要死记隔离级别,要去想你的业务场景里会不会出现脏读、不可重复读、幻读。比如一个转账接口,你肯定要保证两个账户余额的一致性,那可能得上SERIALIZABLE?不对,其实你用可重复读,配合行锁就够了。比如一个统计报表的查询,它只读一次快照,用读已提交也完全没问题。你得把每个隔离级别想象成一个安全等级不同的玻璃房,你能看到外面的数据变化程度不一样,同时你对外界数据的变更封锁程度也不一样。
2.2 锁的粒度与死锁复盘
锁这块,我一个过来人给你提个醒:学锁不是在学数据库理论,是在学并发编程。MySQL的行锁、表锁、间隙锁、意向锁,本质上是操作系统里PV操作在数据库领域的映射。
说个我自己真实踩过的大坑。某跨平台系统的订单模块,当时为了给用户增加积分,先查用户表再更新积分表,结果同一时间两个请求互相持有对方的行锁位置,直接死锁,业务回调报错,用户积分没到账还投诉。排查了半天才发现,是代码里更新账户信息的顺序不一致——一个事务先更新A表再更新B表,另一个事务先更新B表再更新A表。
后来跟某导师复盘,他一句话点醒我:所有事务里涉及多行更新的,必须按照固定的顺序来访问资源。这就跟你过狭窄的桥一样,大家都靠右走,谁也堵不住谁。所以后来我在代码规范里写了条死规矩——多表更新时,按照表名的字典序来上锁。这招土是土了点,但在绝大多数业务系统里,它确实能帮你规避掉80%以上的死锁风险。
2.3 索引的底层逻辑与失效陷阱
B+树索引的原理我不打算展开太多计算机组成原理的细节,你可以把它理解成一个“有序的、带层次的多级目录”。MySQL每次查数据,都是先翻目录(走索引)再翻正文(回表查数据)。你要想让查询快,本质上是让这个“翻目录”的动作尽量少。
实际操作中,新人最容易犯的错就是:建了一堆索引,结果查询的时候压根用不上。比如你在where条件里对索引列做了函数运算、隐式类型转换、前导模糊查询,好一点的引擎优化器还能帮你转换一下,烂一点的就直接全表扫了。我整理一个索引失效自查清单,这东西我觉得比任何理论都值钱:
- 违反最左前缀法则。假设你建了一个(a, b, c)联合索引,你上来就查b字段,索引直接废掉。
- 在索引列上进行计算、函数、类型转换。
- 使用不等于(!= 或 <>)导致全表扫描。
- like以通配符开头,也就是
'%关键词'这种写法。 - 字符串列与数字比较时,隐式转换让索引失效。
- or连接的两个条件中,如果只有一个条件走索引,整个查询可能退化为全表扫描。
每次写完一条复杂SQL,EXPLAIN一下,看type字段是不是到了const、ref、range级别,如果还是ALL,那就是在裸奔扫全表,正常应该立刻回头重写SQL或者调整索引设计。
3. 缓存引入的收益与三大经典难题
3.1 为什么数据库很努力,你还是要上缓存
你可以这么理解:数据库就像一个很认真但办事很慢的老会计,每笔账都要核对半天,准确率百分百但速度起不来。而缓存就像老会计桌上的便签纸,记住最近谁查过什么、余额多少,下次有人来问,直接照本宣科念出来,快得飞起。后端业务一旦上了量,你会发现绝大部分请求都是读多写少,比如商品详情、用户信息、配置数据,算上本地缓存和分布式的Redis,你可能能挡住95%的重复查询压力。
我刚接触Redis的时候,觉得这玩意就是set和get两个命令,后来才意识到,缓存设计的好坏直接决定了你系统的稳定性和资金的损耗。你缓存得当,数据库能清清爽爽地只写核心数据;缓存失当,数据库就是缓存雪崩时替你挡枪的炮灰,惨不忍睹。
3.2 缓存穿透、击穿与雪崩,每个都有话聊
缓存穿透是指查一个根本不存在的数据,缓存里没有、数据库里也没有,每次请求都直接打穿缓存层,直捣数据库。解决方案很成熟:一是用布隆过滤器在缓存前置就拦截掉大部分非法key;二是把空结果也缓存起来,设置较短的过期时间,比如60秒。我——个人更喜欢第二种,因为布隆过滤器有误判率而且维护成本略高,业务简单的话,没必要为了造火箭去学烧锅炉。
缓存击穿是指某个热点key在过期的瞬间,有大量请求同时访问这个key。因为缓存里没有,数据库一瞬间就被高并发流量锤爆了。业界最经典的做法是互斥锁,也就是当缓存失效时,只让一个线程去重建缓存,其他线程要么等待、要么走降级逻辑。另外一种玩法是逻辑过期,也就是缓存里没有设置实际的过期时间,而是在value里塞一个过期标记,后台异步去刷新。这样能保证任何时刻缓存里都有值,但实现复杂度会高一些,容易把你绕晕。
缓存雪崩就更好理解了:大量key集中在同一时间失效,或者Redis节点直接宕机,导致所有流量瞬间涌入数据库。处理思路无非就是过期时间加随机值、多级缓存(本地Caffeine加Redis)、以及Redis的高可用方案(主从加哨兵、集群)。别觉得这些方案是架构师才该考虑的,你自己单机写个demo,也要有意识地做过期时间抖动,不然压测的时候第一个死的就是你。
3.3 数据一致性问题,不存在完美的银弹
这块我必须实实在在给你泼盆冷水:Cache Aside Pattern(旁路缓存)依然是现阶段最主流、最稳妥的方案,别听人吹什么分布式事务+缓存强一致,那都是特定场景下的核弹,不是常规武器。
旁路缓存的逻辑很简单:读的时候先读缓存,缓存没有则读数据库,然后回填缓存;写的时候先更新数据库,再删除缓存。为什么是删除缓存而不是更新缓存?因为更新缓存是写操作,很容易产生并发竞争,两个线程同时写数据库,可能最后写进缓存的是旧值;删掉缓存则强迫下一次读取时重建缓存,保证最终一致性。
这一步有一个典型的坑:更新数据库和删除缓存不是原子操作。如果删除缓存失败,缓存里就是脏数据。常规做法是引入重试机制,或者订阅数据库的变更日志,异步删缓存。我用得比较顺手的套路是把“删除缓存”这个动作扔进MQ里,失败了还可以重新消费。说实话,这世界上没有一套方案能让缓存和数据库在任意时刻都强一致,你要做的是结合业务场景评估容忍度,然后选择成本和一致性之间的平衡点。
4. 实操篇:从零搭建一套“数据库+缓存”的高可用骨架
4.1 初始环境与基础组件选型
我不会推荐你去手写一个数据库中间件,那是另一个修炼维度的内容。但把环境搭起来是必须的。我用的是某云服务器(2核4G)作为主力测试机,操作系统是CentOS,数据库用MySQL 8.0,缓存用Redis 6.x。
安装过程不啰嗦,但有几个版本上的注意事项得提一下。MySQL 8.0默认字符集是utf8mb4,你不用像以前5.7那样建库时反复确认编码了。Redis 6.x之后默认开启了ACL,用户的权限控制比以前严谨,写脚本时需要注意认证方式,别拿老密码命令往上怼。
基础依赖就三样:JDK、Maven、Git。JDK我用的是1.8版本,很多公司老项目还在上面跑,不过如果你是新起项目,直接上17也行,注意Spring Boot的版本匹配就好。技术框架就选择Spring Boot + MyBatis-Plus + RedisTemplate,这套组合在中小型后端项目里就是万金油,资料多、上手快、坑也基本都被前人踩平了。
4.2 表结构设计与索引调整实践
为了演示事务和缓存,我设计了一个简化的“秒杀系统”模拟项目Y。核心表是库存表,在秒杀场景下存在典型的读多写少、热点集中。
建表的核心逻辑给大家看一眼,你先注意字段类型和索引选择:
CREATE TABLE `stock` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `goods_id` BIGINT NOT NULL COMMENT '商品ID', `total_count` INT NOT NULL DEFAULT 0 COMMENT '总库存', `sold_count` INT NOT NULL DEFAULT 0 COMMENT '已售数量', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_goods_id` (`goods_id`), KEY `idx_sold_count` (`sold_count`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '库存表';这里有几个细节值得嚼一嚼。goods_id我加了唯一索引,是因为一个商品只有一条库存记录,从业务的唯一性上就该约束住,这也是防止脏数据的兜底手段。sold_count加普通索引,是为了应对后台的销量排序统计。注意,我没有给create_time加索引,如果销售的筛选场景更偏向时间范围,那你就得再权衡索引的成本和收益,别贪多。
秒杀场景的更新语句不能用普通的update直接扣库存吗?可以,但需要加上version字段做乐观锁控制,防止超卖。经典的原子更新长这样:
UPDATE stock SET sold_count = sold_count + 1, version = version + 1 WHERE goods_id = #{goodsId} AND version = #{oldVersion}这样一次原子更新,配合数据库行锁,基本能保证不会超卖。但更极端的秒杀场景里,这种扣减库存的更新可能还是会在高并发下出现性能瓶颈,具体解决就得靠后面的缓存冷热分离来缓解数据库压力了。
4.3 缓存设计与热点Key的刷新机制
我在这套模拟项目Y里,商品详情是热点数据,肯定要先塞到Redis里。缓存结构我选用了Hash类型,比简单的String可读性更强,适合存对象属性:
HSET goods:detail:1001 title "某品牌手机" stock 10000 price 4999普通商品直接设置缓存过期时间,比如30分钟,加一个随机抖动值(5分钟内),防止大量商品同时失效。
热点key的处理就不一样了。比如活动大促的时候,运营预估某几个爆款商品会被疯狂点击,我就在后台配置了一份热点key白名单,对这些key使用逻辑过期策略:
- 不设置Redis的TTL,也就是
EXPIRE命令不上 - 在写入Hash结构时,额外存一个字段
expire_time为当前时间戳+30分钟 - 查询时判断
expire_time是否小于当前时间 - 如果过期了,先返回旧数据,同时异步触发一个线程去数据库加载最新数据并回填缓存
这种设计的好处是,缓存永远有值,不会出现空窗期,极端情况下最多就是返回的数据滞后几秒,但对商品详情这种场景来说完全能接受。我后来把这种玩法扩展到了用户会话、配置中心,实测下来稳得很。
4.4 结合Spring Cache的框架封装
直接用RedisTemplate当然灵活,但每个方法都手写缓存逻辑,重复代码能写到你想吐。我建议项目里直接集成Spring Cache,用注解来声明缓存规则,把精力留在业务上。
依赖只需要一个Spring Boot的starter,然后配置一下Redis连接信息。用起来是这样的:
@Cacheable(cacheNames = "goods:detail", key = "#goodsId") public GoodsVO getGoodsDetail(Long goodsId) { // 难得写一次数据库查询 return goodsMapper.selectById(goodsId); }这个注解的效果是:调用方法前先查缓存,如果没有就执行方法体,再把结果塞进缓存。下次同一个参数进来,直接走缓存了。要注意的是,cacheNames和key决定了缓存空间的划分,如果你是分布式系统,多个服务共享同一个Redis,key一定要带业务前缀,避免互相覆盖。
更新的时候用@CachePut,删除的时候用@CacheEvict。但我要提醒一句,注解缓存的粒度不要太细。如果你把每一行查询接口都加上@Cacheable,那你后面清理缓存绝对会疯——因为你根本不知道哪些key被业务改动了。我习惯的做法是:只在读多写少、一致性要求不高的查询接口上加,其他一律手写RedisTemplate做精细控制。
5. 压测调优实录与数据指标的二次解读
5.1 压测工具选型与脚本避坑
后端性能到底行不行,不能靠“感觉”,得靠数据。业界常用的有Apache Bench、wrk、JMeter。个人做接口压测的话,wrk上手快、性能强,适合命令行选手;如果你想看图形界面和聚合报告,JMeter更合适。我这篇使用的实际压测是wrk,因为简单直接。
压测前有一步必做——先把日志级别调成WARN,不然压测时应用疯狂打印INFO日志,磁盘IO会被打满,测出来的数据全是假象。
我的压测命令长这样:
wrk -t8 -c100 -d30s --latency http://localhost:8080/api/goods/detail/1001解释一下参数:8个线程,100个并发连接,持续压30秒。这个配置对一个2核4G的测试机来说,已经属于中等强度压力了。
5.2 从压测报告看系统瓶颈
我第一次压这个秒杀详情接口的时候,QPS大概在3200左右,响应时间P99是210ms,看着好像还行,但打开数据库慢查询日志,发现一堆耗时超过2秒的SQL。这说明了什么?说明接口的总耗时全耗在数据库上了,Redis缓存压根没把流量挡住。
后来排查发现,Redis里的key根本没存进去,因为我压测的是商品ID为1001的数据,但缓存预热脚本只做了1000以前的,1001直接打得数据库。就这么一个低级失误,暴露出“缓存Key管理和预热脚本不统一”的问题,这在线上是能引发性能事故的。
修复方案特别简单:把预热脚本改成动态从配置中心拉取封面商品的ID列表,然后手动执行一遍HSET仅此而已。再次压测,QPS直接飙到11500,响应时间P99降到25ms。这组数据对比足够说明,缓存的命中率就是后端性能的第一生命线。
5.3 内存分析:Redis内存碎片化
压测之后,我顺手看了一下Redis的内存使用情况,发现INFO memory里used_memory只有300MB,但used_memory_rss已经快600MB了。这说明Redis的内部存在大量内存碎片,尤其是我频繁进行HSET和DEL操作时,jemalloc分配器会留下碎片空间。
Redis 4.0以后支持自动内存碎片清理,但默认是不开的。我调整了一下配置:
activedefrag yes active-defrag-ignore-bytes 100mb active-defrag-threshold-lower 10 active-defrag-threshold-upper 100意思就是当碎片超过100MB且碎片率超过10%时,开始自动清理,超过100%时加大清理力度。调整后观察两天,内存碎片率从1.8降到了1.1左右,效果非常明显。这波操作让我明白:数据库调优不只是SQL层的事,中间件的运行参数同样在你的一亩三分地范围里。
6. 常见故障与排查技巧速查表
长期跟“数据库+缓存”这套组合拳打交道,故障总是不期而至。我把这个学习阶段最常碰到的几个问题整理成了一张速查表,方便你以后直接对着排查。这里面的每一项,都是我或者身边同学真实踩过的坑,不信你看完就知道很多事故其实是低级问题叠加出来的。
| 故障现象 | 可能原因 | 快速排查命令/思路 | 推荐解决动作 |
|---|---|---|---|
| 缓存命中率低,数据库压力大 | Key命名不统一、过期时间设置太短 | INFO stats看keyspace_hits/hits+misses | 统一Key前缀,拉长TTL并加抖动 |
| 接口偶尔超时,平均值不高 | 存在慢查询或大Key热点 | 打开MySQL慢查询日志,Redis-cli --bigkeys | 索引优化、大Key拆分、热点数据前置 |
| 缓存与数据库数据不一致 | 更新缓存失败、删缓存失败 | 对比业务日志与实际缓存 | 引入消息队列异步重试删除 |
| Redis连接突然异常 | 连接池耗尽或Redis宕机 | redis-cli -p 6379 ping | 扩大连接池、配置健康检查与自动重连 |
| 数据库死锁 | 多资源更新顺序不一致 | SHOW ENGINE INNODB STATUS | 规范事务访问顺序,锁定粒度尽量小 |
| 主从延迟导致刚写入查不到 | 复制线程延迟 | SHOW SLAVE STATUS看Seconds_Behind_Master | 强制走主库查询、或者引入缓存标记 |
这张表看着简单,但每一行后面都藏着一整晚的排查经历。我希望你别走我老路,遇到故障别急着重启服务或者清缓存,先看指标、看日志、看监控,大部分问题的线索其实一早就暴露出来了,只是你没在意。
7. 写在最后的个人体会
学到这个阶段,我对后端的理解已经脱离了“写接口”的视角,开始真正摸到“数据”的脾气。数据库的表结构、索引、锁、事务,以及缓存的一致性、高可用,它们之间不是孤立的知识点,而是一张互相牵连的大网。你在一个点上做的决策,可能会在另一个点上百倍地还回来。该加缓存的地方加了,系统飞起来;不该加缓存的地方死磕,反而引入了数据不一致的风险。
如果有朋友正处于这个学习阶段,我建议你多去折腾,别只看书只背题。自己用Spring Boot写一套带商品列表、详情、秒杀的系统,然后拿wrk压它一晚上,把故障报错一条条搜出来解决掉。这个过程比任何付费课程都管用。
数据库和缓存这块的水实在太深,我能分享的也只是这一段路踩出来的脚印。下一篇学习记录,我大概率会接着写分布式会话管理或者消息队列的实战选型,到时候咱们再接着聊。最后再分享个小技巧:每次上线大版本前,把Redis的RDB快照打开,把MySQL的binlog日志保留天数检查一遍,这些看似不起眼的动作,关键时刻能救你一条命。