# hm-dianping **Repository Path**: leocc001/hm-dianping ## Basic Information - **Project Name**: hm-dianping - **Description**: 邻里优选:基于SpringBoot + MySQL + MyBatis-Plus + Redis +Nginx的一站式本地活服务平台,涵盖短信登录、优惠券秒杀、附近商户、签到打卡及社交关注等核功能。 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: develop - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-03-29 - **Last Updated**: 2026-04-05 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 项目核心功能总结 ## 1. 短信登录 ### 1.1 业务背景与初始实现 这个项目支持用户通过手机号+验证码的方式登录。最初我参考传统Web开发模式,使用 Session 来保存用户的登录状态和验证码。 在开发过程中,我意识到 Session 有两个明显的痛点: - **分布式环境下的共享问题**:如果项目部署在多台 Tomcat 上,每台服务器都有自己的 Session,用户第一次请求落在一台机器上,下一次请求被负载均衡到另一台机器就会丢失登录状态。 - **不能主动控制有效期**:Session的有效期是固定的,很难做到“用户活跃就续期”。 ### 1.2 基于 Redis 的改造方案 用 Redis 的 String 类型存验证码,用 Hash 类型存用户信息,并生成随机 token 作为键。 - **发送验证码**: 1. 校验手机号格式。 2. 生成6位随机验证码。 3. 验证码保存到Redis中,以 `login:code:手机号` 为 key,存入Redis设置过期时间(2分钟)。 4. 模拟发送(实际开发中调用第三方短信API)。 - **登录/注册**: 1. 校验手机号。 2. 校验验证码(从Redis获取比对)。 3. 逻辑判断:根据手机号查询用户,若不存在则自动创建新用户(即注册逻辑)。 4. Token机制:生成全局唯一Token(UUID),以 `login:token:UUID` 为key,将用户信息存入Redis(Hash结构),并设置有效期(30分钟)。为了方便测试,我这边把有效期设置成超长时间。 5. 返回Token给前端,前端后续请求会放在 `Authorization` 请求头中。 - **校验登录状态**: 1. 定义拦截器,每次请求从请求头获取 Token。 2. 根据Token查询Redis中的用户信息。 3. 若存在,刷新Token有效期(解决状态登录过期问题),并将用户信息存入ThreadLocal供后续业务使用;若不存在,返回401未授权。 ### 1.3 拦截器的改动 初期我只设置了一个拦截器,只拦截需要登录的路径(如下单、点赞)。但用户访问首页、店铺列表等公开路径时,拦截器不生效,导致 token 无法续期,用户还在操作却过期了。 我的优化方案是:配置两个拦截器。 - **第一个拦截器(RefreshTokenInterceptor)**:拦截所有路径,只负责从 Redis 中获取用户、刷新 token 有效期,并存入 ThreadLocal。它**不阻断请求**,即使没有 token 或 token 无效,也放行。 - **第二个拦截器(LoginInterceptor)**:只拦截需要登录的路径,它仅判断 ThreadLocal 中是否有用户,如果没有则返回 401。 这样,无论用户访问什么路径,只要携带了有效的 token,Redis 中的有效期都会被刷新;同时,真正需要登录的接口又能被保护起来。 ### 1.4 对于短信登录功能的总结 通过这套 **Redis + Token + 双拦截器**的设计,我解决了 Session 共享和令牌续期问题,并且用户信息存储在 Hash 结构中,既节省内存,又支持单独字段更新。目前这个方案已经在项目的登录、关注、点赞等模块中稳定运行。 ## 2. 商户查询缓存 ### 2.1 业务背景与初始方案 在项目中,用户频繁查询店铺详情。如果每次请求都直接查询 MySQL,数据库压力会很大。我采用 Redis 缓存方案:查询时先查 Redis,如果命中就直接返回;如果未命中,则查数据库,将结果写入 Redis 并设置 TTL。这样可以显著降低数据库负载,提升响应速度。 - **读操作**:先查缓存,未命中则查数据库,再写入缓存。 - **写操作**:先更新数据库,再删除缓存(而不是更新缓存)。因为删除缓存比更新缓存更高效,避免多次无效写。 同时,为了保证数据库和缓存操作的原子性,我在写操作上加了 `@Transactional` 注解,确保数据库更新成功后才删除缓存;如果删除失败,下次查询时缓存缺失,会重新从数据库加载,最终达到最终一致性。并且我给缓存设置了 TTL 作为兜底,防止一直使用脏数据。 ### 2.2 缓存雪崩 大量Key同时失效,我在设置 Key 的过期时间(TTL)时,增加了一个**随机值**,让缓存的失效时间分散开来,避免集体失效。 ### 2.3 缓存穿透 缓存穿透是指请求的数据在缓存和数据库中都不存在,导致每次请求都打到数据库,这样会浪费很多内存。例如:恶意请求 `id = -1` 的店铺。 **解决方案:缓存空对象** 当数据库查询结果为空时,仍然将一个空字符串(或 null 值)写入 Redis,并设置较短的 TTL(比如 2 分钟)。这样下次同样的请求会直接返回空缓存,不会访问数据库。 ### 2.4 缓存击穿 缓存击穿是指一个热点 key(比如某个热门店铺)突然失效,导致大量并发请求同时查询数据库,瞬间压力剧增。 **解决方案:互斥锁 和 逻辑过期** - **互斥锁方案**:在缓存失效时,利用 Redis 的 `setnx` 获取锁,只有一个线程能去数据库重建缓存,其他线程等待并重试。这保证了数据一致性,但性能稍差。 - **逻辑过期方案**:我将过期时间存入缓存对象中(RedisData)。查询时判断逻辑时间是否过期,如果过期,不直接返回旧数据,而是开启一个独立线程去后台异步重建缓存,当前线程直接返回旧数据(脏数据)。这种方案性能极高,没有线程等待,但牺牲了部分数据一致性。 ## 3. 优惠券秒杀 ### 3.1 全局唯一ID生成 秒杀成功后会生成订单,订单表在分布式环境下不能依赖数据库自增ID(因为分表后ID会重复,且自增ID容易被猜测)。我实现了一个**基于Redis自增的全局ID生成器**: - ID结构:1位符号位(0) + 31位时间戳(秒级,可用69年) + 32位序列号(每秒支持2^32个订单)。 - 时间戳用自定义起始时间(如2022-01-01)与当前时间的差值。 - 序列号用 `INCR` 命令,以 `业务前缀:日期` 为key,每天一个计数器。 - 经测试,生成3万个ID耗时约3秒,性能很高。 ### 3.2 秒杀下单基本逻辑 用户点击抢购后,后端需要: - 校验秒杀时间是否开始/结束。 - 校验库存是否充足。 - 扣减库存,生成订单。 我使用了 `update ... set stock = stock - 1 where voucher_id = ? and stock > 0` 来保证库存扣减的原子性。 ### 3.3 库存超卖问题及乐观锁解决 初期直接用 `stock = stock - 1` 无库存判断,高并发下会出现超卖(库存为负)。原因是多个线程同时读到旧库存。 我采用**乐观锁**解决: - 方案一:CAS 方式 `where stock = 原值`,但会导致大量失败,实际售出不足。 - 优化为 `where stock > 0`,只要库存大于0就允许扣减,既保证不超卖,又提高成功率。 ### 3.4 一人一单问题 业务要求一个用户只能购买一次该优惠券。我增加了查询订单表判断用户是否已购买。但高并发下仍会出现同一用户多个请求同时通过校验,导致重复下单。 **解决方案:使用悲观锁(synchronized)** - 优化为锁 `userId.toString().intern()`,使同一用户串行,不同用户并行。 - 然而,在集群部署下,`synchronized` 只能锁住单个JVM,不同服务器之间无法互斥,于是需要分布式锁。 还有一个缺点,就是在并发时候,一人一单不能够实现,锁只加在了一台JVM虚拟机上。 ### 3.5 Redisson分布式锁 我实现了一个 `SimpleRedisLock`: - 利用 `setnx` 命令,同时设置超时时间(防止死锁)。 - 锁的value存入线程标识(UUID + 线程ID),释放时判断是否当前线程的锁,防止误删。 - 但仍有问题:判断和删除是两个操作,非原子性,可能造成锁误删。 - 最终使用**Lua脚本**将判断和删除合并为一个原子操作。 ## 4. 达人探店 ### 4.1 发布与查看探店笔记 用户点击加号可以发布图文笔记,图片上传到阿里云OSS上面,笔记信息存入 `tb_blog` 表。查看笔记详情时,后端根据id查询笔记,并关联查询发布者的昵称和头像,返回给前端。 ### 4.2 点赞功能(一人只能点一次,可取消) 最初设计是用户可以反复点赞,但产品要求一人一赞且可取消。 - **核心思路**:使用Redis的 **Set集合** 存储点赞用户ID,判断当前用户是否在集合中。 - 点赞时:数据库 `liked` 字段 +1,并将用户ID加入Set。 - 取消点赞时:数据库 `liked` 字段 -1,并从Set中移除用户ID。 - 查询笔记详情时,从Set中判断当前用户是否点赞,设置 `isLike` 属性。 这样就保证了点赞的幂等性。 ### 4.3 点赞排行榜(Top5按时间排序) 需求:在笔记详情页显示最近点赞的5个用户,按点赞时间顺序。 Set集合是无序的,所以我改用 **SortedSet(ZSet)**,以用户ID为member,以当前时间戳为score。 - 点赞时:`ZADD key userId timestamp`。 - 取消点赞时:`ZREM key userId`。 - 查询Top5:`ZRANGE key 0 4` 按score升序(时间从早到晚)取出5个用户ID。 - 再根据用户ID列表查询数据库,注意保持顺序:用 `ORDER BY FIELD(id, ...)` 保证与Redis返回顺序一致。 ## 5. 好友关注 ### 5.1 关注与取关 用户之间可以关注,关系存在 `tb_follow` 表。关注时插入记录,取关时删除记录。判断是否关注就是查询记录是否存在。这个功能比较简单,直接操作数据库即可。 ### 5.2 共同关注 查看当前用户与另一个用户的共同关注列表。如果用数据库,需要多次查询或联表,效率低。我利用Redis的 **Set集合** 存储每个用户的关注用户ID(key为 `follows:用户ID`)。 - 关注时:`SADD` 加入Set。 - 取关时:`SREM` 移除。 - 查询共同关注时:`SINTER` 对两个用户的Set求交集,再根据交集ID查询用户信息返回。这样做时间复杂度低,且天然支持集合运算。 ### 5.3 关注推送(Feed流) 需求:用户发布探店笔记后,推送给所有粉丝,粉丝在“关注”页面按时间倒序查看。 这是典型的Feed流问题。我采用了 **推模式(写扩散)**,因为项目初期用户量不大,没有大V。 - 发布笔记时:查询该用户的所有粉丝,将笔记ID写入每个粉丝的 **ZSet** 收件箱(key为 `feed:粉丝ID`),score为当前时间戳。 - 粉丝查询时:从自己的ZSet中按score倒序(时间从新到旧)取出笔记ID,再根据ID查数据库获取笔记详情。 - **滚动分页**:由于Feed流会实时新增,传统分页会出现重复或遗漏。我使用 `ZREVRANGEBYSCORE` 实现基于时间戳的滚动分页:每次请求带上上次返回的最小时间戳(minTime)和该时间戳的重复次数(offset),作为下次查询的起点。这样即使有新数据插入,也不会影响已读位置。 ## 6. 附近商户 我使用 **GEO 数据结构**。我先将店铺数据导入Redis:按商户类型分组,key为 `shop:geo:类型ID`,member为店铺ID,经纬度为坐标。 查询时:调用 `GEOSEARCH` 命令,以用户经纬度为圆心,半径5公里内搜索,返回结果按距离升序,再分页取出。由于旧版Spring Data Redis不支持GEOSEARCH,我升级了相关依赖。