MCPcopy Index your code
hub / github.com/Xiaoc7r/CityHub

github.com/Xiaoc7r/CityHub @main

Chat with this repo
repository ↗ · DeepWiki ↗ · + Follow
203 symbols 544 edges 76 files 84 documented · 41%
What it actually does AI analysis from the code graph — generated when you open this
loading…
README

CityHub - 本地生活服务平台

<img src="https://img.shields.io/badge/Spring%20Boot-3.0-green" alt="Spring Boot">
<img src="https://img.shields.io/badge/RocketMQ-5.0-blue" alt="RocketMQ">
<img src="https://img.shields.io/badge/Redis-7.0-red" alt="Redis">
<img src="https://img.shields.io/badge/Caffeine-3.1-orange" alt="Caffeine">
<img src="https://img.shields.io/badge/License-MIT-yellow" alt="License">

CityHub 是一个类“大众点评”的本地生活服务平台,此仓库README主要用于存放学习期间关于此项目的一些思考。

在模拟 “秒杀优惠券” 的高并发场景时,我通过JMeter 压测,发现了传统单体架构的诸多瓶颈:数据库死锁、超卖、页面响应慢等。

为了解决这些问题,我开始引入中间件并重构代码。以下是我在解决 “热点缓存一致性”、“秒杀超卖” 及 “分布式锁选取” 等问题时的复盘记录。将系统从“能用”优化到了“抗压”。

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

此仓库不罗列功能,只记录我在开发过程中遇到的设计取舍和问题。

缓存击穿问题,缓存穿透问题

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

对于平台中商铺的信息其实是一个变动比较少的、读多写少的数据,因此比较适合写入 Redis 缓存中,降低数据库的压力。

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

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

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

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

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

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

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

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

对于平台中商铺的信息其实是一个变动比较少的、读多写少的数据,因此比较适合写入 Redis 缓存中,降低数据库的压力,如果有人恶意用脚本疯狂请求不存在的商铺 ID,请求就会直接穿透 Redis 打到数据库。

因此需要解决缓存穿透的问题,我了解到两种解决方案:一是使用缓存空值、二是使用布隆过滤器。

方案对比

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

针对这个商铺信息查询,因为目前的平台的商铺数量并不是很大。为了解决缓存穿透问题,我选择了缓存空值的方案来解决缓存穿透问题。


使用 Caffeine 本地缓存和 Redis 缓存搭建二级缓存架构

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

对于“秒杀优惠券详情”这种更新频率极低、访问频率极高的数据,我引入了 Caffeine 作为进程本地缓存。请求进来先查本地内存,没有再查 Redis,最后才查数据库。

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

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

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

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

关于一致性的思考:

当然使用本地缓存,有一个问题是,当后端服务集群部署时,如果数据库的数据有更新的情况,本地缓存的数据和数据库的数据存在不一致的情况,如果要更新/删除本地缓存的数据,因为是集群部署,就要把所有节点的本地缓存的数据都进行更新/删除,此时这个实现稍微有些复杂,例如发送广播消息,所有实例节点监听广播消息,然后在本地缓存更新/删除。

但是其实可以使用一种更简单的方法,就是我们可以给本地缓存设置较短时间的 TTL,这样我们可以不用去管本地缓存的数据更新,而是仅依靠 TTL,去不断刷新本地缓存的数据。

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

1. Redis 单实例压力过大,为什么不搭建 Redis 集群? 第一是各方面的成本问题。

第二即使搭建了Redis集群,集群 主要是为了解决海量数据存储和整体吞吐量的问题,但它解决不了单热点 Key的问题。 热Key存在于某个Redis实例上,依然会使得单台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. 令牌桶算法:允许一定程度的突发流量。但如果桶中积累了令牌,这并不符合优惠券秒杀中的公平性,可能会有用户攒满令牌抢

令牌桶和滑动窗口选型?

这两种算法的选择,本质上是 ‘防御目标’ 完全截然不同。 1. 对于令牌桶算法,关注的是‘系统可用性’与‘用户体验’。 在这个场景下,限流的主要目的是防止流量洪峰打挂服务器,以及控制第三方 API 的平均调用成本,我们需要控制的是全局平均速率,防止费用超标,而不是非要卡死每一毫秒的请求数。所以在视频场景,我要的是弹性,令牌桶是最佳选择。 - 为什么选令牌桶? - 如果我们用固定窗口或滑动窗口,比如把限制死在‘1秒1次’,用户传第 2 个视频时就会报错,这种体验非常糟糕,是在误伤正常用户。因为视频上传或 API 调用是允许合理的突发流量的。比如用户想批量上传 3 个视频,令牌桶算法允许在有库存的情况下瞬间消费 3 个令牌,让用户立即完成操作,体验很好。 - 令牌桶基于 Redis Hash + Lua 实现,时间复杂度是 O(1),性能极其强悍,不会成为系统的瓶颈。 2. 对于 CityHub这个秒杀优惠券的秒杀场景,我关注的是‘业务公平性’与‘严格风控’。 在这个场景下,我主要针对 IP 维度进行限流,目的是防止黄牛刷单和爬虫抓取。它强制要求请求必须在时间轴上均匀分布,这是对抗爬虫和刷单最有效的手段。所以在这种风控场景,要的是死板和精准,滑动窗口是最佳选择。

  • 为什么选滑动窗口(ZSet)?
  • 因为在防刷场景下,我们不能容忍突发流量。
  • 假设我限制 1 分钟 60 次。黄牛可以休眠 59 秒,攒满 60 个令牌,然后在第 60 秒的一瞬间打出 60 个并发请求。如果是令牌桶,这 60 个请求会全部通过,这对普通用户极不公平。
  • Redis ZSet 实现的滑动窗口,记录了每一个请求的真实时间戳。不管黑客怎么‘攒’,只要他在任意时间窗口内的请求数超标,我就能精准拦截。
  • 虽然 ZSet 的性能(O(logN))略低于令牌桶,但由于是针对单个用户或 IP 限流,数据量很小(也就几十个 key),性能损耗完全在可接受范围内。

  • 自定义限流注解:包含参数限流 key 前缀、时间窗口大小、时间窗口内允许的请求数、限流提示信息、限流维度(支持全局、用户、IP)。

  • 限流切面处理:获取注解参数,执行限流 Lua 脚本。
  • 限流 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 的过期策略是惰性删除,不保证实时性,过期通知可能晚几分钟,不可靠。

综合考虑:鉴于单体项目订单量不太大,我使用了 方案一(Spring Task)。这里还可以进行优化:在用户查看订单列表时,被动检查是否超时并关单(被动关单思想)。

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)。命令:PFADD, PFCOUNT

Extension points exported contracts — how you extend this code

IVoucherService (Interface)
服务类 @author 虎哥 @since 2021-12-22 [3 implementers]
src/main/java/com/hmdp/service/IVoucherService.java
IUserService (Interface)
服务类 @author 虎哥 @since 2021-12-22 [3 implementers]
src/main/java/com/hmdp/service/IUserService.java
VoucherMapper (Interface)
Mapper 接口 @author 虎哥 @since 2021-12-22 [2 implementers]
src/main/java/com/hmdp/mapper/VoucherMapper.java
IBlogService (Interface)
服务类 @author 虎哥 @since 2021-12-22 [2 implementers]
src/main/java/com/hmdp/service/IBlogService.java
IShopService (Interface)
服务类 @author 虎哥 @since 2021-12-22 [2 implementers]
src/main/java/com/hmdp/service/IShopService.java

Core symbols most depended-on inside this repo

ok
called by 41
src/main/java/com/hmdp/dto/Result.java
getUser
called by 13
src/main/java/com/hmdp/utils/UserHolder.java
fail
called by 12
src/main/java/com/hmdp/dto/Result.java
update
called by 11
src/main/java/com/hmdp/service/IShopService.java
set
called by 7
src/main/java/com/hmdp/utils/CacheClient.java
info
called by 5
src/main/java/com/hmdp/controller/UserController.java
mismatch
called by 3
src/main/java/com/hmdp/utils/RegexUtils.java
setWithLogicalExpire
called by 3
src/main/java/com/hmdp/utils/CacheClient.java

Shape

Method 127
Class 55
Interface 21

Languages

Java100%

Modules by API surface

src/main/java/com/hmdp/service/impl/BlogServiceImpl.java9 symbols
src/main/java/com/hmdp/controller/UserController.java9 symbols
src/main/java/com/hmdp/controller/BlogController.java9 symbols
src/main/java/com/hmdp/utils/CacheClient.java8 symbols
src/main/java/com/hmdp/service/IBlogService.java7 symbols
src/main/java/com/hmdp/config/QueueConfig.java7 symbols
src/main/java/com/hmdp/service/impl/UserServiceImpl.java6 symbols
src/main/java/com/hmdp/controller/ShopController.java6 symbols
src/main/java/com/hmdp/utils/RegexUtils.java5 symbols
src/main/java/com/hmdp/service/impl/ShopServiceImpl.java5 symbols
src/main/java/com/hmdp/service/IUserService.java5 symbols
src/test/java/com/hmdp/HmDianPingApplicationTests.java4 symbols

Datastores touched

(mysql)Database · 1 repos
dingpingDatabase · 1 repos

For agents

$ claude mcp add CityHub \
  -- python -m otcore.mcp_server <graph>

⬇ download graph artifact