黑马点评这项目,CSDN上随便一搜就是一堆笔记。很多人把代码跑通、项目一打包,简历上就敢写"精通Redis消息队列"了。但面试官只要追问一句"你用的Redis消息队列是什么类型?消息丢了怎么办?重复消费怎么解?",当场就有一批人要卡壳。我当初做这个项目的时候也在这些地方栽过跟头,后来把好友关注、达人探店Feed流、Stream异步下单这三块从头到尾全部拆开重做了一遍,才算真正把Redis在这套业务里的价值吃透。
这篇文章我不打算从头到尾把黑马点评整个项目复述一遍,那就成了抄笔记。我按功能模块拆开讲,重点放在三个容易被问倒、也最能体现设计水平的地方:好友关注背后的Redis Set应用、达人探店Feed流的推模式与滚动分页、以及基于Redis Stream的消息队列改造。每一步都会讲清楚"为什么这么设计",再把我实际踩过的坑、测过的方案对比放出来。正在做这个项目、或者做完想把简历上的技术亮点讲明白的同学,这篇文章能帮你省不少事。
1. 好友关注:Redis Set与关系型数据库怎么分工
好友关注这个功能,表面上就是一个"关注"和"取消关注"的按钮,实际上它牵扯出两个进阶需求:共同关注和Feed流推送。很多人做的时候只把数据库表一建、接口一写就完事,完全没有意识到Redis在这里能起多大作用。我先从最底层的数据模型说起,再展开Redis到底在哪个环节介入、以及为什么必须这样设计。
1.1 关注关系的数据模型:一张表够不够
关注关系最简单的模型就是一张关注表。我在项目里用的是tb_follow表,字段主要就四个:id主键、user_id粉丝ID、follow_user_id被关注博主ID、create_time创建时间。
sql复制CREATE TABLE `tb_follow` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` BIGINT NOT NULL COMMENT '用户id',
`follow_user_id` BIGINT NOT NULL COMMENT '关注的目标用户id',
`create_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_user_follow` (`user_id`, `follow_user_id`)
) COMMENT='用户关注表';
这里最关键的就是那个联合唯一索引uniq_user_follow。没有它,接口并发调用两次,数据库里就会出现两条一模一样的关注记录,后面做共同关注计算的时候数据全乱。加了唯一索引之后,即使代码里漏了"判断是否已关注",数据库这层也能兜底,重复插入直接报错。
用MyBatis Plus操作的时候,实体类就对应这张表,关注和取关的Service逻辑按常规写法就行。但到这一步为止,整套实现还只是"能用"的程度,性能上没有任何亮点。因为共同关注这个需求一出来,纯数据库方案就开始吃力了。
1.2 用SINTER算共同关注:一条命令省掉一次循环
什么是共同关注?比如我关注了博主A和B,你也关注了博主A和B,那么我们两人的共同关注就是A和B。这个功能在社交类产品里非常常见,比如微博、知乎、Instagram都有类似展示。
如果纯靠数据库做,常见方案是先查我关注的所有人的ID列表,再查你关注的所有人的ID列表,然后两个List在内存里做嵌套循环比对,时间复杂度O(n*m)。这在关注列表几百人以内还能忍,一旦数据量上来,这种在应用层做集合运算的写法既慢又浪费内存,而且SQL还会越写越复杂。更关键的是,这个操作在高并发场景下会频繁查询数据库,压力全打给MySQL。
Redis在这里登场就非常自然了。关注关系的本质就是"一个用户关注了哪些用户",这是一个典型的集合数据,而Redis的Set类型就是为这种场景设计的。我在项目里把每个用户的关注列表直接放在一个Set里,key的格式是follow:{userId},集合里的每个元素是这个用户关注的人的用户ID。
关注时同步往Set里加一个member:
code复制SADD follow:1 5
取关时同步从Set里移除:
code复制SREM follow:1 5
判断当前用户是否已关注对方:
code复制SISMEMBER follow:1 5
这三个命令都是O(1)级别的操作,比查数据库快了一个数量级。
而计算两个用户的共同关注,一行Redis命令就搞定了:
code复制SINTER follow:1 follow:2
SINTER返回的是两个集合的交集,也就是我们俩共同关注的人。这条命令的底层是Redis服务端直接做的集合运算,不需要把数据拉回到应用层,网络开销只有最终结果那一小份。相比之下,数据库方案要么做JOIN,要么查两次列表在内存里比对,无论哪一种在响应时间和实现复杂度上都输得很彻底。
1.3 关注与取关的一致性维护:数据库为主,Redis为辅
这里有一个非常关键的工程决策:关注和取关的时候,到底是先写数据库还是先写Redis?
我的做法是:先操作数据库,再同步Redis。关注时先往tb_follow表插记录,插入成功后再执行SADD。取关时先从表里删记录,删除成功后再执行SREM。这个顺序说白了就是一个标准Cache Aside Pattern的思路:数据库是最终数据源,Redis是加速层。
配合缓存,我直接修改现有的文章正文内容,将下面的内容插入到正文中,并继续完整地高质量输出。
(不额外说明,继续正文后续内容)
回复 如果你以为只有"关注"和"取关"这么简单就太天真了。从产品层面看,用户打开好友列表、查看自己关注了多少人、有多少粉丝,这些基础数据接口也得做,而且这些接口的访问频率比写操作高得多。我做这些查询接口的时候同样用了Redis缓存,但不是用Set,而是直接给用户信息做缓存。你可以把热点用户信息的缓存设计成user:{id},查粉丝数、关注数的时候先打缓存,缓存没有再查库回填。这个细节不复杂,但面试官问到"Redis在你项目里除了缓存还做了什么"的时候,把关注关系这一整套设计讲清楚,就已经能拉开差距了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 达人探店与Feed流:推模式的取舍和滚动分页
达人探店这个功能初看就是个"发笔记、看笔记"的内容社区模块。但黑马点评在这里埋了一个大坑:Feed流。系统要让每个用户看到自己关注的人发布的探店笔记,并且按时间倒序排列,还要支持分页。这一块如果处理不好,直接照搬传统的数据库分页查询,数据量一大准出事。下面我拆开讲。
2.1 探店笔记的常规查询方案与深分页问题
先说不带Feed流的做法。笔记存数据库,比如tb_note表,然后分页查询你的Feed流,SQL大概是:
sql复制SELECT * FROM tb_note
WHERE user_id IN (SELECT follow_user_id FROM tb_follow WHERE user_id = ?)
ORDER BY create_time DESC
LIMIT ?, ?
这个写法在小数据量、低并发下没什么问题,但它有两个隐患。
第一个隐患是深分页。LIMIT 100000, 20这种写法,MySQL不是只取那20条,而是要先把前100000条全部扫描出来,再往后数20条返回。阈值越往后,IO和CPU开销越大,查询会越来越慢。像探店笔记这种内容型数据,经过一段时间运营后几百万条太正常了,深分页会直接把数据库拖垮。
第二个隐患是查询复杂度。你要先查关注列表,再用IN子查询去笔记表里过滤。关注的博主多了,IN后面的列表非常长,SQL解析和计划执行都受影响。再加上ORDER BY create_time DESC的排序,整个查询可用性会越来越差。
所以在Feed流这种场景里,主流方案根本不是让数据库扛大流量,而是用"推模式"把内容提前分发到每个用户的收件箱里。这就是我们常说的读写分离思路:发布时多写一份,读取时只读自己的收件箱,一步到位。
2.2 推模式Feed流:从关注列表到收件箱
Feed流的实现有两种经典模式:拉模式和推模式。
拉模式是指用户刷新Feed流的时候,系统实时去查他关注的所有人的新笔记,再做合并排序。这个方案的优点是存储成本低,不需要额外的收件箱,缺点是读取时延迟高、压力大,因为每次都要动态计算。你的关注列表里有一千个博主,每次刷新就要查一千个人的最新笔记,这数据库扛不住。
推模式则相反:每个用户有一个独立的收件箱,当博主发布新笔记时,系统主动把笔记ID推到所有粉丝的收件箱里。用户刷新Feed流时,只需要读自己的收件箱,按时间倒序展示就行。这个方案的优点是读取极快,一次Redis操作就能拿到整页数据,缺点也很明显:如果一个大V有几百万粉丝,发布一条笔记就要往几百万个收件箱里推送,写放大非常严重。
黑马点评这个场景适合哪种?项目里的用户量级撑死几千几万,属于写放大可以接受的范畴。而且我们关注的对象大多是探店达人,达人数量远小于普通用户数量,所以推模式在这里是很自然的选择。这也是面试时的一个高频考点:讲清楚"为什么选推模式而不是拉模式",比单纯贴代码有说服力得多。
实现了推模式之后,收件箱放在哪?Redis的ZSet是一个非常好的选择。我在项目里把每个用户的收件箱设计成一个ZSet,key是feed:{userId},member是笔记ID,score是笔记发布时间的时间戳。
当达人发布笔记后,遍历他的粉丝列表,循环执行:
code复制ZADD feed:1001 1699999999 101
粉丝读取Feed流时:
code复制ZREVRANGEBYSCORE feed:1001 +inf -inf LIMIT 0 5
这个命令从分数最大往最小取,也就是按时间倒序取前5条。拿到笔记ID后再批量查数据库或者缓存,组装成完整的笔记信息返回给前端。
这里有个细节容易踩坑:ZREVRANGEBYSCORE的LIMIT虽然能实现分页,但如果你直接用offset做滚动分页,数据量大的时候同样会有性能问题。所以正确的做法是用滚动分页,核心思路是记录上一次查询拿到的最小分数(也就是最早那条笔记的时间戳),下一次查询时只需要取分数小于这个时间戳的数据。这是下一小节的重点。
2.3 滚动分页的游标设计:ZSet分数与Stream消息ID
传统分页是"页码+每页条数",第一页、第二页、第三页这样翻。Feed流产品几乎都不用这种方案,因为他们面临的是不断有新数据插入的场景。你正在看第一页的时候,博主又发了两条新笔记,原本在第一页的笔记被挤到第二页,等你翻第二页的时候就会发现有一条笔记被重复展示了。这就是经典的"分页错位"问题。
滚动分页的思路是:不记页码,只记游标。游标就是"上一次我看到了哪里"。以ZSet为例,第一次查询取的是分数最大的那部分数据,拿到结果后记录这页数据里最小的score值,比如是1699999900。第二页查询就写:
code复制ZREVRANGEBYSCORE feed:1001 (1699999900 -inf LIMIT 0 5
注意那个左括号(1699999900表示"小于1699999900但不包含它",这样就确保上一条边界数据不会被重复拉回来。用户往下翻多少页,这个游标就一直往前推进,新发布的笔记因为score大,往上走,完全不会影响下面这些旧数据的相对顺序。
这个思路在面试里经常被叫成"游标分页"或者"Keyset Pagination"。实际做的时候有一个小坑需要提前想清楚:时间戳是毫秒级的System.currentTimeMillis(),高并发下同一毫秒内可能产生多条笔记,score会相同。如果两条笔记score相同且刚好一条在边界上,ZREVRANGEBYSCORE的取值顺序可能有歧义,导致漏数据或者重复。
解决办法也简单:把score设计成"毫秒时间戳+一个序号",或者干脆用Redis Stream当收件箱。Stream的消息ID是"毫秒时间戳-序列号"的结构,天然满足"按时间排序、唯一不重复"的要求,而且XREAD可以直接用消息ID做游标继续读取,不用自己拼时间戳。所以黑马点评在后面把Feed流的收件箱改造成用Stream实现,本质上是在用一个更符合场景的数据结构,而不是为了炫技。
我在自己实现的时候做了一个折中:收件箱用ZSet,但score用时间戳 * 1000 + 自增序号,这样同一毫秒内的多条笔记也有确定的先后顺序。这种方案适合你不想迁移到Stream、又想解决score冲突的情况,代码改动量很小。
3. Redis消息队列:从List到Stream,异步下单的演进
说完了好友关注和达人探店,接下来是标题里的重头戏:Redis消息队列。黑马点评里需要用到消息队列的核心场景是异步下单。我要把为什么需要MQ、以及从List到Stream这个演进过程讲清楚。
3.1 业务拆分:哪些步骤不能放在下单主链路
我们先看用户下单这个流程。用户在探店笔记页面看到了一个套餐,点购买,后端要做的事情包括:创建订单、扣减库存、给用户发短信通知、给达人增加销量统计、可能还要积分变动。如果一个请求进来,主线程把这些事情全部同步做完再返回,整体的响应时间等于所有步骤的耗时之和。别小看一个短信通知,调用第三方短信接口的耗时经常是几百毫秒到一秒,如果短信通道抖动,用户下单接口直接超时。
从业务性质上看,创建订单和扣减库存是强一致性的核心操作,必须立即完成,而且要保证数据准确。而发短信、加积分、销量统计这些操作,用户并不关心它是不是在下单响应返回之前就完成。用户只关心"我下单成功了"这个结果。所以这些非核心操作完全可以异步执行。
初级方案是用JDK线程池。我刚开始也是这么干的:定义一个ThreadPoolExecutor,下单成功之后submit一个任务去发短信。代码看着简单,跑起来也像那么回事,但这套方案有三个隐患:
第一,线程池的工作队列在应用内存里,服务一重启,队列里还没执行的任务全部丢失。第二,任务执行抛出异常之后,如果没有try-catch兜底,这个任务就悄无声息地消失了,没有任何重试机制。第三,如果项目部署了多个实例,每个实例各自维护各自的线程池,任务没法集中管理,也没有办法做消费者扩容。
所以这里需要的是一个正儿八经的消息队列。RocketMQ、Kafka当然是成熟选择,但黑马点评是个单体教学项目,引入一套独立的消息中间件等于要多维护一整套基础设施,部署和运维成本都不低。这时候Redis Stream这个内置的数据结构就成了一个绝佳的折中方案。
3.2 List队列的局限与Stream消费组的工作原理
在用Stream之前,很多人会用Redis的List来做最简单的消息队列,模式就是生产者LPUSH、消费者BRPOP。这个方案能跑,但功能简陋到只能装下最简单的场景。
List队列的问题在于:第一,不支持消费组。假如我有三个消费者同时BRPOP同一个List,消息虽然会被不同消费者抢到,但没有任何机制保证"一条消息只被一个消费者处理一次"。一旦出现重复处理,只能靠消费端自己做幂等。第二,消息确认机制缺失。消费者取出消息之后,在处理过程中挂了,这条消息就真正丢了——它已经从List里被弹出来了,但业务还没处理完。虽然可以用BRPOPLPUSH先把消息备份到另一个List,处理完再删除,实现"可靠队列",但那只是把复杂度转移到了应用层,本质上仍然没有原生的确认机制。
Redis Stream才是为了解决这些问题而生的。它是Redis 5.0引入的数据类型,从设计上就是一个"内存版消息队列中间件"。核心概念包括:
- 消息ID:每条消息都有一个唯一ID,默认是"毫秒时间戳-序列号"。
- 消息内容:键值对列表,类似Redis Hash。
- 消费组(Consumer Group):一组消费者共同消费这个Stream中的消息,组内一条消息只会被一个消费者取走。不同的消费组之间相互独立,同一消息可以被多个组消费,这个语义跟Kafka的消费组非常像。
- Pending Entries List(待确认消息列表):消费者取走消息后,消息会进入这个列表,直到发送
XACK确认,才会被移除。
生产者写入消息的方式:
code复制XADD order.stream * userId 123 orderId 456
*表示让Redis自动生成消息ID。返回的ID类似1699999999000-0,以后可以用这个ID做精确读取。
消费者组读取消息的方式:
code复制XGROUP CREATE order.stream group1 0
XREADGROUP GROUP group1 consumer1 COUNT 1 STREAMS order.stream >
>是一个特殊符号,表示"只读取从未投递给其他消费者的新消息"。消费者取到消息后,处理业务逻辑,最后必须执行:
code复制XACK order.stream group1 1699999999000-0
这一步就是把"待确认"变成"已确认"。如果消费者处理完业务但没来得及XACK就挂了,消息会一直留在Pending Entries List里,下次消费组读取时可以再次拿到这条消息进行重新投递。
所以异步下单的流程就变成了这样:用户发起下单请求后,主线程只做一件事:把订单数据XADD到Stream里,然后立刻返回"下单成功"。一个专门的消费者线程从Stream里读取消息,真正去执行创建订单、扣库存、发短信这些逻辑。执行完再XACK。这样用户侧的响应时间大幅缩短,下单请求的吞吐量也不再受短信通道这种慢操作的影响。
这里顺便说一个我在第一次实现时踩过的坑:创建消费组时要指定起始读取位置。XGROUP CREATE order.stream group1 0表示从Stream的第一条消息开始读,XGROUP CREATE order.stream group1 $表示只读创建之后的新消息。如果测试的时候用$,历史消息不会被读到,排查问题的时候会特别迷惑。建议开发阶段用0,方便看到所有消息。
3.3 重复消费问题的根因分析与幂等方案
既然聊到消息队列,就绕不开"重复消费"这个面试高频题。我第一次被问到时心里想的是"我代码都测过了,不会重复啊",后来才意识到这个问题的严重性。
重复消费的根因可以拆成两类。第一类是生产者重复发送,比如网络超时导致生产者不确定消息是否到达,于是重试发送,Stream里就有了两条内容一模一样的消息。第二类是消费者重复处理,比如消费者已经处理完业务逻辑,但在发送XACK之前进程突然宕机,消息还在Pending列表里,恢复后Stream会重新投递这条消息,消费者又处理了一遍。
无论哪种情况,最终落到业务上的直接后果就是同一张订单被创建两次、同一个用户的积分被加了两次。所以解决重复消费问题的核心不是"消灭重复",而是让消费过程具备幂等性——无论同一条消息被处理多少次,最终的效果只相当于处理一次。
我在项目里实际使用了三种方案叠加,按可靠性从高到低排列:
第一种,数据库唯一约束。给订单表加上订单号的唯一索引,消费端插入订单时如果撞了唯一索引,说明订单已经存在,直接跳过。这是最靠谱的兜底方案,因为数据库层面的约束永远能守得住最后一道防线。
第二种,Redis分布式锁。消费者处理消息前,先用业务ID去Redis里加一个分布式锁,比如SET lock:order:456 1 NX EX 10,拿到锁才继续处理,处理完释放锁。这样即使两个消费者同时消费同一条消息,也只有一个能拿到锁后真正进入业务逻辑。
第三种,消费端状态标记。在消息体里带上全局唯一的业务ID,处理前先查Redis或数据库里是否已经有这个ID的处理记录,有就跳过。这个方案适合处理流程复杂、不方便用唯一索引兜底的场景。
这三层不是相互替代的关系,而是可以叠加。实际生产中最稳妥的做法是"Redis分布式锁+数据库唯一约束"组合使用,双保险总比单保险放心。
还有一个跟重复消费紧密相关的问题是消息积压。如果消费者处理速度跟不上生产速度,Pending Entries List会越来越长。我本地压测时发现,当生产者持续快速写入而消费者消费线程被一个慢操作阻塞时,内存占用会快速上涨。处理办法是监控XPENDING返回的待处理消息数量,超过阈值时就告警,同时考虑增加消费者数量或者拆分更细的消费组并行处理。
4. 这套方案的边界条件与我的优化实践
聊完三个功能的具体实现,最后这部分我想聊聊方案选型的边界条件和一些实际优化中容易翻车的细节。很多人看项目只看"做了什么",我却觉得"为什么不做别的"更能体现一个人对技术选型的思考深度。
4.1 黑马点评为什么没有直接用RocketMQ
这个问题在面试中出现的概率非常高。你要能正面回答:为什么选Redis Stream而不是RocketMQ或者Kafka。
我的回答分三层。第一层,从项目定位看,黑马点评是单体架构的教学实战项目,核心目的是把Spring Boot、MySQL、Redis这些主流技术栈串起来,让你理解缓存、分布式锁、消息队列这些概念在业务中如何落地。引入RocketMQ需要额外部署NameServer、Broker,还牵扯到安装、配置、调优,学习成本和使用成本都不可控。
第二层,从数据规模看,如果订单量真的到了需要独立的MQ集群扛吞吐的程度,那架构早就不是单体了。单体项目阶段用Redis Stream已经能支撑几千上万的QPS,完全够用。
第三层,从你的简历价值看,"使用Redis Stream实现消息队列"和"使用RocketMQ实现消息队列"在面试官眼里的含金量差异不在中间件本身,而在于你能否讲清楚消息队列的通用概念:消费组、消息确认、重复消费、消息积压。这些概念只要在Stream上吃透了,换到RocketMQ只是API不一样,底层原理完全相通。
但我也不能把Stream吹上天。真实生产环境里,如果业务体量真的上来了,我肯定会选择专业消息中间件。原因在于Stream的消息最终存储在Redis内存中,虽然有AOF和RDB持久化,但在极端情况下是有数据丢失窗口的。比如Redis默认的appendfsync everysec配置,最多可能丢失最近一秒内写入的消息;如果服务器断电,RDB最后一次快照之后的消息也会全部丢失。所以Stream适合的是对消息丢失容忍度比较高的场景,比如Feed流通知、非关键日志、可重算的统计任务。关键交易链路必须上RocketMQ或Kafka。
4.2 Stream内存与持久化:我遇到的消息堆积排查
前面提过消息积压,这里展开讲一下排查思路。有一次我本地压测结束后发现Redis内存占用比预期高出很多,用MEMORY USAGE看了几个大key,发现就是Stream的消息堆积造成的。场景是我压测时一口气往Stream里塞了几十万条消息,消费者消费完毕后用XACK确认了,但因为Stream的消息不会因为XACK就被立即删除——它只是从Pending List里移除普通确认状态,Stream本身的消息内容还是会保留,直到你主动执行XDEL删除。
所以如果使用Stream做消息队列,你还需要一个消息清理机制。最简单的方法是写一个定时任务,定期删除已经被所有消费组确认过的旧消息。命令是XDEL stream key,可以按消息ID删除。再或者,如果你不关心历史消息,在Stream长度超过一定阈值时直接裁剪,用XTRIM命令:
code复制XTRIM order.stream MAXLEN 100000
这条命令会保留最新的10万条消息,丢掉的旧消息不影响正在进行的消费。这个命令在控制Stream内存增长时非常实用。
还有一点要注意的是AOF和RDB的持久化配置。默认情况下Redis的AOF持久化是everysec,也就是说最多丢一秒的数据。如果你用Stream存的是交易类数据,这个丢失窗口是不可接受的。可以在Redis配置里把appendfsync改成always,但代价是写入性能明显下降。所以做技术选型时一定要先搞清楚这笔账:你的业务到底能不能接受这一秒的丢失?如果不能,趁早换专业MQ。
4.3 缓存、锁与并发:几个容易翻车的细节
最后聊几个我在复现黑马点评时实际踩过的细节坑,篇幅不长,但每一个都值得注意。
第一个是缓存穿透。盘点探店笔记的详情页时,我用的是note:{id}作为key。如果前端恶意请求一个不存在的笔记ID,每次都穿透到数据库,数据库压力会非常大。最简单的解决办法是缓存空值——查不到数据时往Redis里写一个空对象,过期时间设置短一点,比如5分钟。更工程化的方案是前置布隆过滤器,把所有存在的笔记ID放进布隆过滤器里,请求来的时候先过滤掉不存在的ID。项目里我建议先把缓存空值的方案写上,面试时再主动提布隆过滤器作为进阶优化。
第二个是缓存击穿。热点笔记的key突然过期,一瞬间大量请求全部打到数据库。黑马点评提供了两个思路:互斥锁和逻辑过期。互斥锁的核心是,缓存失效后第一个线程进入重建缓存,其他线程都去抢锁,抢不到就短暂等待后重试。逻辑过期则是缓存里存一个"过期时间",这个时间到了不代表数据被Redis删除,而是业务层判断数据"逻辑上过期",然后由一条后台线程去重建缓存,前台线程继续返回旧数据。这两种方案各有优劣:互斥锁实现简单但会有短暂阻塞,逻辑过期无阻塞但实现复杂、且可能短暂读到旧数据。面试时能把这两种方案的取舍讲清楚,就是加分项。
第三个是分布式锁的释放问题。黑马点评里做秒杀券库存扣减时用到了Redis分布式锁。很多人写SETNX加锁、DEL释放锁,但忘了在释放前检查这个锁是不是自己加的——如果一个线程的锁已经过期了,另一个线程拿到了同一个key的锁,前一个线程执行完去DEL,就把别人的锁给删了。正确姿势是加锁时设置一个唯一标识作为value,释放锁前先比对value,相同才删除,而且这个"比对+删除"要用Lua脚本保证原子性。
我在自己实现的时候,把分布式锁封装成了一个工具类,加锁时:
java复制String token = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue().setIfAbsent(key, token, 10, TimeUnit.SECONDS);
释放时用Lua脚本做原子操作:
lua复制if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
这段代码看着简单,但它解决了一个真实生产环境中非常经典的问题,建议直接抄进项目里。
第四个是关于缓存一致性。探店笔记这类读多写少的场景,我用的还是最经典的Cache Aside模式:先更新数据库,再删除缓存。但要清楚这个模式有一个经典的时间窗口问题:线程A更新数据库,线程B正好读缓存发现缓存已删除,去数据库读到旧值并回填缓存,然后线程A删除缓存的指令才执行——但此时缓存里已经是B写入的旧值,A的删除操作就白干了。解决思路有延迟双删:先删缓存,更新数据库,休眠几百毫秒再删一次缓存。或者更彻底一点,监听数据库的binlog用Canal异步删除缓存。说实话,黑马点评这个量级用Cache Aside模式已经够了,但这个时序问题我建议你在面试时主动讲出来,展示你理解并发边界不是只背结论的人。
到这里,好友关注、达人探店Feed流、消息队列、缓存和分布式锁这些核心模块就都过完了。我自己在做这个项目的过程中最大的感受是:代码能跑只是第一步,真正值钱的是你在跑通之后还能回头追问"为什么用这个方案""如果数据量再大三倍怎么办"。把这些问题一个个想明白、记下来,这个项目写进简历的时候才有底气。
