Skip to content

Qulamb/CityAIHub

Repository files navigation

CityAI Hub —— Agent同城生活服务平台

Spring Boot Java Spring AI MyBatis-Plus Redisson MySQL Connector Redis RocketMQ Spring Milvus

CityAI Hub 是一个类“大众点评”的本地生活服务平台。

我在保留原本点评、优惠券、秒杀、支付这些核心业务链路的基础上,新增了:

  • 使用布隆过滤器预防缓存穿透
  • 使用滑动窗口策略进行限流
  • 使用RocketMQ进行异步下单
  • 利用RocketMQ的延迟消息实现定时关闭订单功能
  • 使用分布式锁解决解决支付回调与超时关单状态下的并发问题

重点新增了AI模块:

  • 基于 Spring AI 实现多轮 Think-Execute推理链路,支持工具调用、任务终止、异常兜底,并预留外部工具扩展能力(MCP Client)。
  • 基于 Milvus 构建知识库、店铺画像、评论数据三类向量索引,打通多源向量检索链路。
  • 在 RAG 检索环节结合向量召回与关键词 fallback,提升平台规则问答、门店推荐等场景下的命中率与稳定性。
  • 实现会话、消息与工具日志持久化,设计会话记忆与上下文窗口限制方案,支持多轮对话连续理解,并通过控制历史消息窗口有效降 低Token 消耗。
  • 基于 SSE 实现 AI 对话流式输出,缓解大模型响应延迟带来的体验问题,提升本地生活场景下智能助手的交互体验、吞吐能力与可观测性

CityAI Hub 中目前具备的功能:

  1. 用户验证码登录,进入系统
    我这里做了完整的登录态链路:前端发送验证码,后端校验手机号格式后把验证码写进 Redis;请求进入系统后先由 RefreshTokenInterceptor 负责读取 token、恢复用户上下文并刷新 TTL,再由 LoginInterceptor 对需要登录的接口做二次拦截。这样做的目的是把“恢复登录态”和“权限拦截”拆开,职责更清晰,也更方便后面扩展。
  2. 浏览首页、分类、点赞热榜、店铺详情
    首页把搜索、分类、探店热榜、店铺详情都串到了真实接口上。店铺查询支持按分类分页,也支持带坐标时走 Redis GEO 做附近检索;店铺详情则叠加了缓存、布隆过滤器和评论/菜品展示,是后面优惠券和 AI 分析的入口。
  3. 对笔记点赞、查看详情、关注作者、发布笔记
    这一块本质上是一个轻量社交链路:博客点赞状态放在 Redis ZSet 中,既可以支持点赞/取消点赞,也可以做点赞用户 TopN 回查;关注关系写 MySQL 的同时同步 Redis Set,方便做共同关注查询;用户发布笔记后会把笔记 ID 推送到粉丝收件箱,前端可以继续拉关注流。
  4. 购买普通券,或者抢购秒杀券 普通券和秒杀券我故意做成了两条不同链路:普通券同步下单,适合直接支付;秒杀券先用 Redis + Lua 做库存和一人一单校验,成功后再写入 RocketMQ,由消费者异步落库。
  5. 在支付前后查看订单变化,验证异步链路和超时关单
    支付这块虽然还是模拟支付,但状态机和并发保护都是真实设计:支付前可以回查订单状态,支付过程中通过 Redis 分布式锁避免并发修改订单,超时未支付则由延迟消息驱动自动关单并回滚库存。
  6. 打开 AI 助手,发起推荐、单店分析、攻略、路线和天气相关提问 AI助手并非简单的单轮问答,而是一个支持会话管理、SSE 流式输出、多轮工具调用与知识检索的Agent 系统。当前已完成路线与天气能力的工具接入框架及本地 fallback 兜底逻辑,但默认未启用真实远端 MCP 服务,即目前已具备接入与降级能力,尚未默认接通线上服务。
  7. 切换历史会话,继续基于上下文追问 系统实现了会话、消息与工具日志的持久化存储, 并设计了会话记忆与上下文窗口控制机制,支持多 轮对话下的连续语义理解;同时通过限制历史消息加载窗口,有效降低 Token消耗与模型调用成本。

这个项目最初的业务代码参考了经典的某个点评实战项目。这里重点记录的是我自己在学习、重构和联调过程中对架构和实现的一些思考。

1. 缓存穿透问题

店铺详情和优惠券详情是高频查询接口。如果恶意请求大量携带不存在的 ID,请求会直接穿透缓存打到数据库。这就是缓存穿透。我对比了两种主流方案:

  • 缓存空对象:第一次查询不存在时,在 Redis 中写入一个空值(带较短 TTL)。后续相同请求直接返回空,不再打库。
  • 布隆过滤器:在请求到达缓存前,先经过布隆过滤器判断 ID 是否存在。如果不存在,直接拦截。

方案对比: 缓存空对象实现非常简单,只需在回查数据库后多做一步写入操作,但缺点在于第一次请求仍会打库,且空 key 过多时会浪费缓存空间。布隆过滤器能在缓存层之前拦掉绝大多数无效请求,节省资源,但存在误判率,且需要额外维护过滤器组件,实现复杂度高。

最终结论: 这两种方案实际上是互补的,而不是互斥的。布隆过滤器无法阻拦100%的无效请求,漏掉的请求就可以通过缓存空值的方式进行缓存。我最终选择的是两种方案同时使用,对于容易被攻击的接口、高频访问接口、比较重要的接口加入了布隆过滤器,漏掉的请求通过缓存空值的方式拦截。对于剩余的访问量低的、不重要的普通接口直接使用缓存空值的方式。这样最大程度的降低了内存的开销,并有效解决了缓存穿透问题

2. 缓存击穿问题

当一个热点 Key(例如秒杀活动详情)在 TTL 过期的瞬间,可能有成千上万请求同时涌入。如果所有请求都去数据库重建缓存,数据库瞬间就会被打垮。这就是缓存击穿。缓存击穿有两种主流方案:

  • 互斥锁方案:缓存失效后,让所有请求竞争一把锁,只有成功抢到锁的线程去查库重建缓存,其他线程等待重试。
  • 逻辑过期方案:不给 Key 设置物理 TTL,而是在 Value 中存储一个逻辑过期时间。线程取到数据后判断是否过期,如果过期则尝试获取互斥锁,另起一个线程去后台更新缓存,自己直接返回旧数据。

方案对比: 互斥锁保证了强一致性(重建后立即生效),但牺牲了可用性(抢锁失败需等待或报错)。逻辑过期保证了高可用性(请求永远不阻塞),但牺牲了短暂的一致性(返回旧数据),属于最终一致。

最终结论: 店铺信息和活动榜单这类数据,用户对几秒钟的延迟并不敏感,更重要的是系统在高峰期依然稳定可用。因此我选择了逻辑过期方案来解决缓存击穿问题。

3. 缓存雪崩问题

雪崩是大量 Key 在同一时间过期,导致数据库压力瞬间骤增。我并没有采用单一的“万能方案”,而是从系统层面做了几点设计:

  • 过期时间打散:给不同 Key 的 TTL 增加随机偏移,避免同时失效。
  • 热点数据差异化:核心数据使用逻辑过期(永不过期),普通数据设置不同 TTL。
  • 多级防护:缓存层之外,接口入口处增加限流,数据库本身也要做好连接池和慢查询防护。

总结: 缓存雪崩的预防更依赖于整体架构的“节奏感”,而不是某一个缓存技巧。

4. 消息队列如何选择?

秒杀链路中,我使用了异步下单的方式,这需要一个消息队列来存储,并且也能起到削峰的作用。同时我考虑到订单在一定时间后应该能够自动过期,所以在选择消息队列时应该考虑:既要削峰,又要支持超时关闭。我对比了三种主流消息队列:

  • Kafka:吞吐量极高,适合日志、埋点、流式数据处理。但业务语义较弱,且有概率消息丢失,延迟消息原生不支持。
  • RabbitMQ:交换机模型灵活,资料丰富。延迟消息依赖 TTL+死信队列,符合所有要求。
  • RocketMQ:原生支持延迟消息、事务消息,与 Java/Spring Boot 生态集成友好,业务语义清晰。

方案对比: Kafka 的海量吞吐确实很快,但是订单业务需要的延迟消息和数据安全性保障与他不够适配。RabbitMQ 的路由模型虽然灵活,但为了延迟消息引入 TTL+死信组合会增加理解和维护成本,并且相比RocketMQ,rabbitMQ的吞吐量稍显乏力。RocketMQ 原生支持延迟消息和事务消息,和订单场景的贴合度最高。

最终结论: RocketMQ 的延迟消息和事务消息恰好贴合这些需求。因此选择 RocketMQ。

5. 滑动窗口限流选择原因

随着项目演进,我意识到限流不再是某个模块的专属优化,而是必须成为全局入口层的基础治理能力。无论是秒杀接口、订单查询,还是后来接入的 AI 对话,亦或是缓存雪崩问题,每一个接口都可能因突发流量或恶意请求对系统造成压力。尤其是 AI 模块接入后,一次请求可能触发多轮推理、工具调用和 SSE 推送,资源成本远超普通 CRUD,这更让我确信:限流必须前置,且为每个接口独立配置。

我对比了几种常见的限流算法:

  • 固定窗口:实现最简单,但存在窗口边界突刺问题。例如在窗口切换时可能瞬间涌入两倍流量,虽然统计上符合阈值,但系统感受却是两次流量尖峰。

  • 漏桶:核心是请求以固定的速率被处理,不管请求的突发性。缺点是无法处理突发流量,在秒杀时我们希望系统在有能力的情况下尽可能处理更多请求,漏桶会导致资源无法充分利用。

  • 令牌桶:允许一定程度的突发流量,并且也可以调整生成令牌的速率控制持续流量,属于漏桶算法的升级版。但在当前业务中可能会有用户攒令牌抢券,这样设置不合理。

  • 滑动窗口:将时间窗口细分为多个小格子,统计更精确,能真实反映请求密度,平滑拦截流量尖峰。并且和redis的zset非常契合,可以使用zset维护滑动窗口,并定期删除过期元素

方案对比: 固定窗口和漏桶算法都属于不太常用的算法,几乎可以算滑动窗口和令牌桶的下位替代。所以说一下比较常用的令牌桶算法和滑动窗口算法对比:

在秒杀抢券场景中,用户行为高度集中,开售瞬间流量峰值可能达到正常值的数百倍。如果采用令牌桶,为了容纳这个峰值,要么将桶容量设得极大(等于放弃限流保护),要么容忍大量请求被限流(浪费瞬时处理能力)。而滑动窗口通过细粒度统计,可以在不牺牲安全性的前提下,让系统在每一秒都满载运行,既保护了后端,又最大化利用了资源。 令牌桶的“攒令牌”问题在实际业务中确实存在——例如用户可以在低峰期通过定时任务不断发送请求积攒令牌,然后在高峰时一次性消耗,这对于公平性要求高的秒杀场景是不可接受的。滑动窗口则没有这个漏洞,因为它的计数基于真实时间窗口,无法通过提前“储蓄”来作弊。

最终结论: 我最终选择了滑动窗口作为统一的限流实现,因为它能有效防止短时间内的流量突刺,且足够通用。具体落地采用 注解 + AOP + Redis ZSet + Lua 脚本,保证判断和计数的原子性。

6. 秒杀链路(一人一单与库存超卖)版本更迭过程

秒杀的本质不是“下单”,而是“在极短时间内判断资格、扣减库存、保护数据库”,需要保证不超卖以及一人一单。我尝试过几种方案:

  • 无锁:库存直接超卖,原因是多线程情况下库存校验与库存扣减不是原子性,比如库存剩余1,这时候线程A库存校验大于0,刚准备执行,这时候线程B也校验了库存大于零,A开始执行库存扣减,B也执行了库存扣减,超卖了。
  • 本地锁synchronized:保证了单机情况下的库存不会超卖,但是在实际业务的分布式场景下仍旧无法保证不会超卖,并且锁粒度太大,整个方法都被锁住了,并发量也上不去。
  • 分布式锁,基于redis的setNX:分布式集群下,实现了跨 JVM 的请求互斥,解决了分布式超卖。但是存在锁误删问题,若请求 A 的锁超时(执行时间超过锁有效期),请求 B 拿到锁,此时 A 执行完 finally 删除锁,会误删 B 的锁
  • 分布式锁优化(过期时间 + 唯一标识):给锁加过期时间,防止死锁;锁的值设为唯一标识(如 UUID),释放锁时先判断是不是自己的锁,避免误删.释放锁仍非原子:但是判断锁和删除锁是两步操作,并发下仍可能误删,比如 A 判断锁是自己的,但还没删除时锁过期,B 拿到锁,A 最终删除了 B 的锁;
  • Lua 脚本:利用 Redis单线程执行 Lua 脚本的特性,将判断库存与扣减库存放到lua脚本内执行。但是同步的下单逻辑需要等待数据库成功下单再进行响应,用户等待时间长,接口响应慢。
  • 引入消息队列异步下单:在lua脚本内进行redis的库存预扣减并向消息队列发送下单消息,立即返回下单成功的响应。后台慢慢消费消息队列中的消息,让下单与落库异步执行,降低了用户的等待时间并且不会出现超卖现象。

在逐步优化的过程能感受到实际开发场景下很难一步做到最优方案。可能更真贴切的是发现问题->针对问题进行优化->遇到新的问题->补全之前不完善的地方这种不断完善的流程,但是如果有扎实的基本功可以很大程度缩短这个过程。

7. 流式消息推送

流式消息这个功能可以说非常有用,我在使用AI的时候也是希望它能提前给我一部分消息让我先看着,而不是一直等待,这样其实一定程度上算是降低了响应时间的。我对比了三种推送方式:

  • 轮询:实现简单,但实时性差,无效请求多,浪费资源。
  • WebSocket:全双工通信,灵活强大,但当前场景只需服务端持续推送,WebSocket 显得过重,需要维护连接状态。
  • SSE:浏览器原生支持,轻量,自动重连,适合单向流式输出。只需一个 EventSource 即可监听服务端推送。

方案对比: 轮询实时性弱且浪费资源;WebSocket 适合高频双向交互,但当前场景是“前端发一次,后端持续推状态和结果”,SSE 刚好够用,且实现更简单。

最终结论: 我选择 SSE 作为推送协议。但更关键的是事件驱动设计:用户消息 → 创建消息 → 发布 ChatMessageCreatedEvent → 监听器异步执行 Agent → 通过 SSE 推送 status、delta、done 等事件。这样即使流式失败,也能 fallback 成分块推送,系统有状态、可追踪。

8. RAG 检索

这个项目里的 RAG,不只是简单“给大模型补知识”,而是让 AI 助手在本地生活场景下既能回答平台规则,也能做店铺推荐和评论分析。因此我没有把所有数据都塞进同一种检索方案,而是按知识类型拆成了三类索引:平台知识库、店铺画像、探店笔记。

在索引构建上,我分别做了不同处理:

  • 平台知识库:先切块再入向量库,避免规则文档过长导致召回粒度过粗。
  • 店铺画像:把商圈、均价、评分、评论数、营业时间等结构化字段组织成画像文本,兼顾推荐召回和结果解释。
  • 探店笔记:除了正文,还补上标题和店铺名,降低短文本评论的语义损失。

检索策略: 我起初使用的是纯向量检索,语义理解能力强。但是我在测试的时候发现AI经常无法命中我保存在数据库中的规则,比如我设置了CityAI超级会员的福利,当用户通过AI提问时经常无法命中。于是我想办法让这些高确定性的问题比较容易命中,最终确定了

最终结论: 我最终选择的是“分层 RAG”而不是“纯向量 RAG”。平台知识库走混合检索,先做向量召回,再用关键词扫描知识文本做 fallback,并对两路结果去重排序,避免出现“明明知识库里有内容却答不上来”的情况;店铺画像和探店笔记则走向量检索,服务推荐、单店分析和评论总结这类语义场景。这样既保留了向量检索的语义理解能力,又保证了对于一些高确定性答案的识别准度。

9. 多轮推理

本地生活场景的问题往往需要多步推理(推荐店铺需考虑画像、距离、评论等)。我尝试过两种极端:

  • 固定流程:为每种场景写死链路,稳定可控。
  • 完全自动 Agent:让 LLM 自主决定调用哪些工具,灵活。

开发过程: 第一种方案的问题还是比较大的,就是复用性太差了,非常不灵活,实际业务场景不会完全采用这种方案,而第二种方案经常会陷入死循环,但是又非常不好调试。

于是我采用手动控制的 Think-Execute 模式。模型负责思考下一步需要什么工具,代码负责注册工具、限制边界、记录日志、控制步数;工具结果再回到下一轮推理,满足条件后输出最终答案。这样既保留了模型的推理能力,又通过代码约束边界,让系统可解释、可控制。

10. 上下文窗口管理

多轮对话中,历史越长,Token 成本和响应延迟越高,噪音也越多。于是我进行了最近窗口截取,类似滑动窗口,每次只取最近N轮消息,保留上下文。这样即保留了会话记忆的能力,又控制了大量久远历史导致的无意义token消耗。

后续优化方向: 后续可以选择摘要压缩的形式,对前几次会话的信息进行几次抽取,获取少部分关键信息,本轮会话的历史记忆采用最近窗口截取+摘要压缩,不过这里要获取大部分有效信息,可以进一步减少token的消耗并且可以一定程度记录用户的部分信息,有针对性的进行回答

11. 会话记忆

AI 助手需要支持历史会话切换和问题回溯。起初我想的是redis存储短期记忆,MySQL全量会话记忆,但是实现的时候刷到别人的面经,那个面试官说实际场景没有用redis的,恰好那个时候我在用AI做前端,我就用前端本地存储了短期会话记忆,服务端落库会话、消息、工具日志;前端维持当前窗口状态。

不过实际上可能是我当时理解错了,或者那个面试官也不懂这方面,redis是完全可以存储短期记忆的!并且很多大型项目都是冷热数据分离,采用类似redis的引擎存储热数据与实时状态(超大型项目比如ChatGPT都是自研存储引擎,但是原理都是相同的),类似MySQL的引擎存储用户信息、全量会话历史、工具调用记录等数据,使用milvus存储向量化的长期记忆、用户画像等。

感谢阅读,如果对你也有帮助的话就点个star吧!(在读大学生的一份项目总结,如果有什么问题欢迎随时提问)

About

(黑马点评包装与优化)CityAI Hub 具有的亮点:AI Agent模块(RAG、Milvus向量数据库、流式对话、循环思考、工具调用等)、布隆过滤器、滑动窗口限流、RocketMQ异步下单、定时关闭订单等功能,项目健壮性更好、实用性更强

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors