Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

LifeMate - 本地生活服务平台

Spring Boot RocketMQ Redis Caffeine License

LifeMate 是一个类“大众点评”的本地生活服务平台。此仓库不罗列功能,只记录开发过程中遇到的设计取舍与问题复盘。

在模拟“秒杀优惠券”的高并发场景时,我通过 JMeter 压测,发现了传统单体架构的诸多瓶颈:数据库死锁、超卖、页面响应慢等。为了解决这些问题,我开始引入中间件并重构代码,将系统从“能用”优化到了“抗压”。

注:本项目业务逻辑参考了经典的点评系统,拉取了代码进行学习;在逐步学习的过程中,对一些存在技术问题的架构进行了自己的思考与重铸。

目录

缓存击穿与缓存穿透

1. 热点 Key 失效(缓存击穿)

对于平台中商铺的信息,这类数据变动较少、读多写少,比较适合写入 Redis 缓存,以降低数据库压力。

当一个热点 Key(比如秒杀活动详情)TTL 过期的瞬间,几千个请求同时涌入。如果直接查数据库重建缓存,数据库瞬间就会崩掉,这就是缓存击穿。我调研了两种主流方案:

  • 互斥锁方案:当缓存失效时,让所有请求去抢锁,抢到锁的那一个线程去查数据库重建缓存,其他线程休眠等待。
  • 逻辑过期方案:不给 Redis Key 设置物理 TTL,而是把过期时间存在 Value 里。线程取出数据后判断是否过期;如果过期了,获取互斥锁,开启一个独立线程去后台更新缓存,自己直接返回旧数据。

方案对比(CAP 定理的抉择):

  • 互斥锁:保证了一致性,牺牲了可用性。
  • 逻辑过期:保证了可用性,而牺牲了一致性。

针对活动信息、热搜榜单这类社交媒体类型的数据,一致性的要求并不是强一致性——用户看到的热搜榜单晚 2 秒更新无伤大雅(AP 模型,重可用性)。

最终结论:为了保证服务的可用性,我选择了逻辑过期方案解决缓存击穿问题。

验证环节:手动修改数据库数据制造不一致,等到 Redis 逻辑过期后,用 JMeter 开启 1000 个线程并发(QPS 200)。查看日志发现,确实只有一次数据库查询,其他请求全部迅速返回了旧数据,证明方案有效。

2. 查询不存在的数据(缓存穿透)

如果有人恶意用脚本疯狂请求不存在的商铺 ID,请求就会直接穿透 Redis 打到数据库,这就是缓存穿透。我了解到两种解决方案:缓存空值、布隆过滤器。

方案对比:

  • 缓存空值:实现简单,虽然存在额外内存占用,但可以通过设置较短时间的 TTL 解决。
  • 布隆过滤器:存在误判可能性,且实现复杂,需要引入新的组件并持续维护。

针对商铺信息查询场景,由于当前平台的商铺数量并不是很大,我选择了缓存空值方案解决缓存穿透问题。

Caffeine 与 Redis 二级缓存架构

对于一些存储在 Redis 中的过热 Key,例如秒杀优惠券的详情页,本身是更新频率极低、访问频率很高的数据。而 Redis 单实例性能有上限,单个 Redis 压力过大时可能成为系统瓶颈。

为此我引入了 Caffeine 作为进程本地缓存:请求进来先查本地内存,没有再查 Redis,最后才查数据库。

这里有一个数据一致性的坑:如果是集群部署,数据库改了,怎么通知所有机器清理本地缓存?引入 Redis 广播又太复杂了。

我想通了一个点:既然是秒杀详情页,短暂的不一致是可以容忍的。所以我给本地缓存设置了极短的 TTL(比如 5 秒),只靠 TTL 来自动刷新——代码简单,效果也足够好。

搭建二级缓存架构后,用户的请求流程为:

  1. 先从本地缓存中获取数据,如果本地缓存有数据则返回数据。
  2. 否则从 Redis 缓存中获取数据;如果 Redis 缓存中有数据,则更新本地缓存,然后将数据返回客户端。
  3. 如果 Redis 缓存没有数据,则去数据库查询数据,然后更新 Redis 缓存,接着再更新本地缓存,最后将数据返回给客户端。

关于一致性的思考:

使用本地缓存时,后端服务集群部署的情况下,如果数据库数据发生更新,本地缓存与数据库会存在不一致。若要通过更新/删除本地缓存来保证一致,就需要把所有节点的本地缓存都更新/删除——例如发送广播消息、所有实例监听后在本地缓存更新/删除,实现较为复杂。

更简单的方法是给本地缓存设置较短时间的 TTL:不去主动管理本地缓存的数据更新,仅依靠 TTL 不断刷新本地缓存的数据。

当因为热 Key 导致 Redis 实例压力过高时,为了减少 Redis 的访问压力——并且优惠券详情页数据极少更新、几乎不变——我使用本地缓存进行优化。

1. Redis 单实例压力过大,为什么不搭建 Redis 集群?

第一是成本问题。第二,即使搭建了 Redis 集群,集群主要解决的是海量数据存储和整体吞吐量的问题,解决不了单热点 Key 的问题:热 Key 存在于某个 Redis 实例上,依然会使得单台实例压力过大。除非对该热 Key 进行分片,分散到不同的 Redis 实例上,但这样实现复杂度又会极大增加。

相比之下,本地缓存方案更简单、更直接、更高效。

2. 本地缓存和数据库的数据一致性是怎么保证的?

本地缓存是进程隔离的,数据库更新后,其他节点的本地缓存确实会存在脏数据。针对这个问题(用到本地缓存的数据一般都是极少改变的数据)有两种方案:

  • MQ 广播清理:数据更新发 MQ,所有节点监听并删除本地缓存。但这会让架构变得非常重,增加维护成本。
  • TTL 短过期(我选择的方案):考虑到业务场景是“秒杀详情页”,用户看到 2 秒前的“旧描述”或“旧库存显示”对业务影响非常小。我选择“重可用性、轻一致性”的策略:给 Caffeine 设置 5 秒的短过期时间。这样不需要复杂的广播机制,仅依靠 TTL 过期自动刷新,就能保证数据在 5 秒内最终一致。

3. Caffeine 的实现原理

选择 Caffeine 是因为它是目前 Java 生态中性能最强的本地缓存,主要做了数据结构和读写操作的极致优化:

  • 数据结构:底层参考了 ConcurrentHashMap,但为了提高命中率,没有使用简单的 LRU,而是使用 W-TinyLFU 算法。存储数据的 hashmap 分为三个部分:窗口区(Window)、试用区(Probation)和保护区(Protected),每个区域是一个 LRU 双端队列,大小随该区域的命中率变化自动调节,类似 JVM 的新生代老年代设计——窗口区生命周期最短,保护区最长。
  • 读写优化:为了减少多线程竞争锁的开销,读写操作类似 MySQL 的 WAL 机制,分别向 readbuffer 和 writebuffer 添加读写任务,两个 buffer 均采用 mpcs(多生产者单消费者)的多线程设计模式。写缓存时,实际先把数据写到缓冲区,等合适的时机再把缓冲区的任务刷新到内存。

滑动窗口限流

考虑到以下几种特殊情况,系统需要一个限流组件来保证可用性、安全性、稳定性:

  • 优惠券秒杀时流量过大,需要限流保证系统可用性。
  • 领取无限制的优惠券时,为了避免恶意刷券,可以针对用户限流。
  • 针对爬虫爬取商家信息,可以针对 IP 限流。

我调研了固定窗口、滑动窗口、漏桶、令牌桶四种限流算法:

  1. 固定窗口:在临界点会有允许两倍流量的临界问题,一般不会使用。
  2. 漏桶算法:核心是请求以固定的速率被处理,不管请求的突发性。缺点是无法处理突发流量——在秒杀时我们希望系统在有能力的情况下尽可能处理更多请求,漏桶会导致资源无法充分利用。
  3. 令牌桶算法:允许一定程度的突发流量。但如果桶中积累了令牌,并不符合优惠券秒杀中的公平性,可能会有用户攒满令牌抢券。
  4. 滑动窗口算法:请求必须在时间轴上均匀分布,是防刷场景下最有效的手段。

令牌桶和滑动窗口如何选型?

这两种算法的选择,本质上是“防御目标”截然不同:

  1. 令牌桶算法关注“系统可用性”与“用户体验”。限流的主要目的是防止流量洪峰打挂服务器,以及控制第三方 API 的平均调用成本。需要控制的是全局平均速率,防止费用超标,而不是卡死每一毫秒的请求数——所以在视频上传场景,需要的是弹性,令牌桶是最佳选择。
    • 如果使用固定窗口或滑动窗口,把限制死死定在“1 秒 1 次”,用户传第 2 个视频时就会报错,体验非常糟糕,是在误伤正常用户。视频上传或 API 调用允许合理的突发流量:用户想批量上传 3 个视频,令牌桶允许在有库存的情况下瞬间消费 3 个令牌,立即完成操作。
    • 令牌桶基于 Redis Hash + Lua 实现,时间复杂度 O(1),性能强悍,不会成为系统瓶颈。
  2. 对于 LifeMate 秒杀优惠券的场景,关注“业务公平性”与“严格风控”。主要针对 IP 维度限流,防止黄牛刷单和爬虫抓取。它强制请求在时间轴上均匀分布,是对抗爬虫和刷单最有效的手段——在这种风控场景,要的是死板和精准,滑动窗口是最佳选择。
    • 在防刷场景下不能容忍突发流量。假设限制 1 分钟 60 次:黄牛可以休眠 59 秒,攒满 60 个令牌,然后在第 60 秒的一瞬间打出 60 个并发请求。如果是令牌桶,这 60 个请求会全部通过,对普通用户极不公平。
    • Redis ZSet 实现的滑动窗口记录了每一个请求的真实时间戳。不管怎么“攒”,只要在任意时间窗口内的请求数超标,就能精准拦截。
    • ZSet 的性能(O(logN))略低于令牌桶,但由于是针对单个用户或 IP 限流,数据量很小(也就几十个 Key),性能损耗完全在可接受范围内。

实现组成:

  1. 自定义限流注解:包含限流 Key 前缀、时间窗口大小、时间窗口内允许的请求数、限流提示信息、限流维度(支持全局、用户、IP)。
  2. 限流切面处理:获取注解参数,执行限流 Lua 脚本。
  3. 限流 Lua 脚本:利用 Redis 的 ZSet 数据类型实现:
    • 删除超出时间窗口的数据(ZREMRANGEBYSCORE)。
    • 获取当前窗口内的请求数量(ZCARD)。
    • 若数量小于限制,则添加当前请求;若大于等于,则限制本次请求。

为什么要用 Lua 脚本?

假如不使用 Lua 脚本,会有并发问题。例如两个请求先后执行 Redis 命令 ZCARD,返回当前时间窗口内的请求数均为 99,然后这两个请求都认为不应该限流而被放行——但实际上限制数量是 100,这两个请求应该有一个被拦截。所以限流逻辑内的“查看数量”“判断大小”“添加请求”几个操作必须连贯、有原子性,因此使用 Lua 脚本。另外,使用 Lua 脚本同时也能减少多次网络往返,提升性能。

Redis + Lua 高并发库存扣减与一人一单

这是整个项目最核心的难点,经历了三个阶段的优化:

  1. 数据库悲观锁:直接 select ... for update。结果很明显——请求串行化,性能极差,数据库连接池瞬间被打满。
  2. 数据库乐观锁(CAS)update ... where stock > 0。虽然解决了超卖,但数据库依然扛不住高并发读写,且“一人一单”逻辑在集群下失效(JVM 锁锁不住)。
  3. Redis + Lua + MQ 异步架构(最终方案)
    • 利用 Lua 脚本的原子性,在 Redis 内存中完成“库存判断”和“一人一单校验”。
    • 校验通过后,发送消息到 RocketMQ,立即给前端返回“排队中”。
    • 后端消费者慢慢消费消息,写入 MySQL。
    • 收益:将同步的 DB 操作转为毫秒级的 Redis 操作。

第一,不想让 MySQL 承载下单资格判断的高并发查询和更新,所以选择在 Redis 中完成用户下单资格的判断。

第二,既然使用了 Redis,那么联想到 Lua 正好是原子性的,可以解决一人一单的并发安全问题,也就不再需要使用分布式锁,方便了很多。

  • 优惠券库存信息:使用 String 即可,Key 为 业务前缀+优惠券的ID,Value 为库存数量。
  • 订单信息:使用 Set 数据类型,Key 为 业务前缀+优惠券的ID,Value 为所有购买过该优惠券的用户 ID。

这样,一人一单和库存不超卖的下单资格判断完全不需要涉及 MySQL,都在 Redis 中完成。由于 Redis 基于内存,秒杀业务的性能大大提升。

RocketMQ 异步下单流程

在秒杀的高并发场景下,扣减库存、生成订单是两次 MySQL 数据库操作,使得整个秒杀业务的性能受到很大影响。

这原本是一个同步流程:判断资格 -> 扣减库存 -> 生成订单。但其实在判断完用户有资格下单(库存不超卖、一人一单)后,就意味着用户下单成功、秒杀成功;至于 MySQL 的库存扣减和订单生成,完全可以异步来做。

可以在判断用户有下单资格后,发送一条 RocketMQ 消息,另启动一个消费者监听消息,在消费者逻辑内扣减库存、生成订单。通过同步变异步,提高秒杀场景的并发性能。

1. 如果重复消费消息,会不会对业务有所影响?

RocketMQ 保证的是 At Least Once,在网络波动时重复投递不可避免,所以在业务层(Consumer 端)通过实现幂等性来解决重复消费的问题。

在确定用户符合下单资格后就会生成一个唯一的订单 ID,RocketMQ 消息中携带这个唯一订单 ID。消费者接收消息后创建订单,也就是写入一条数据库记录;如果是重复消费,第二次及后续的消费都会因为订单 ID 主键唯一而写入失败——订单 ID 重复导致创建订单失败,证明是重复消费,后续流程也就不再执行。因此即使重复消费消息也不会对业务有影响,保证了幂等性。

2. Redis 库存扣减了,但数据库的库存没有扣减、订单没有生成怎么办?

这场秒杀的核心要求(一人一单、库存不超卖)由 Redis 存储库存和订单信息、使用 Lua 完成下单资格判断来保证,所以核心要求不受影响。如果消息丢失,只是 MySQL 中的库存信息不正确、订单记录没有及时生成,属于数据一致性问题。

这是一个最终一致性保障的问题,可以设计一个兜底策略:设置一个秒杀结束后执行的定时任务,检查 Redis 库存信息和 MySQL 库存信息是否一致、订单是否都正常生成,发现异常即可修复。

这其实是一个对账思想——上面也可以不用 Set 存储购买过优惠券的用户 ID,而是改用 Hash,这样定时任务可以查 Redis 中的订单数据和 MySQL 中的订单数据做比对验证:一致则无问题,不一致则可能是丢消息或系统执行异常,需要人工介入排查。

3. 如果用户支付后订单记录还没生成怎么办?

用户支付本身不依赖于数据库的订单记录。后台生成唯一订单 ID 后,调用第三方接口生成支付凭证给前端。当用户支付成功回调回来时,如果发现数据库中没有此订单(说明 MQ 还在排队),可以利用回调信息反向生成对应的“已支付”订单。

未支付订单到期自动关闭

在优惠券下单后,用户如果超过一定时间仍未支付,需要关闭订单并释放库存。我调研了常见的几种方案:

  • 方案一:Spring Task 定时任务(每分钟扫描 MySQL 取出未支付且超时的订单)。
    • 优点:实现简单、稳健。
    • 缺点:数据量大时对数据库有压力(但加好联合索引后效率也很快)。
  • 方案二:RocketMQ 延迟消息
    • 优点:精准触发,无需频繁扫描数据库;配合乐观锁可安全释放库存。
  • 方案三:Redis 过期监听
    • 缺点:Redis 的过期策略是惰性删除,不保证实时性,过期通知可能晚几分钟,不可靠。

综合考虑:项目中使用方案二(RocketMQ 延迟消息):下单后发送一条延迟消息,延迟到期后消费者检查订单是否仍未支付,若未支付则通过乐观锁关单并释放库存。相比每分钟扫描数据库,这种方式更精准,也不会对数据库产生额外压力。

1. 会不会存在重复关闭订单、重复增加库存?

不会。执行 SQL 时带上条件 update orders set status = '取消' where id = ? and status = '未支付',只有更新成功(影响行数 > 0)才会释放库存。

2. 如果订单状态设置为关闭,但库存增加失败怎么办?

引入消息队列异步增加库存;若消费者内增加库存失败,由消息队列机制重试补偿,多次重试依然失败则触发告警,人工介入排查。

乐观锁解决支付与关单并发

如果一笔订单在“超时取消”的那一刻,“用户刚好付款”了怎么办?

此时后台的“支付成功处理”与“超时关单处理”应当是一个成功、一个失败:

  • 超时关单update orders set status = '取消' where id = ? and status = '未支付'
  • 支付成功update orders set status = '已支付' where id = ? and status = '未支付'

这两个 SQL 的 where 条件都加了 status = '未支付',利用数据库行锁的特性,保证只有一个执行成功,另外一个一定失败。

如果“超时关单”处理成功、“支付成功回调”处理失败怎么办?

这种情况意味着用户已经付了钱,但订单被系统超时关闭。此时如果发现订单被关闭,系统需要自动触发原路退回流程,把钱退回给用户。不能强制改成“已支付”,因为关单通常伴随着释放库存,强行改状态会导致超卖。

其他 Redis 特性应用

  • 点赞排行榜(Redis ZSet)
    • 需求:按时间排序的点赞列表。
    • 实现:ZADD key score(时间戳) value(userId)
  • 共同关注(Redis Set)
    • 需求:A 关注的人和 B 关注的人取交集。
    • 实现:SINTER keyA keyB
  • 附近商户(Redis GEO)
    • 需求:搜索方圆 5km 内的店,按距离排序。
    • 实现:底层是 GeoHash 算法,命令 GEORADIUSGEOSEARCH
  • 用户签到(Redis BitMap)
    • 需求:记录用户一年的签到,极其节省空间。
    • 实现:1 个 bit 代表一天,SETBIT key offset 1
  • UV 统计(HyperLogLog)
    • 需求:统计网站访问量,百万级访问量下允许极小误差。
    • 实现:概率算法,占用内存极小(12KB),命令 PFADDPFCOUNT

License

本项目基于 MIT License 开源。

About

面向本地生活场景的综合服务平台,提供商家信息、优惠活动及相关生活服务。围绕高并发业务场景进行设计,为用户提供便捷、稳定的本地生活服务体验。

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages