ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Cloud电商后端:用户登录与商品库存核心设计实践

Spring Cloud电商后端:用户登录与商品库存核心设计实践 做电商后端有一段时间的同学应该都有个感受业务功能其实是最好写的真正耗时间的是怎么把用户的登录态、商品的库存、详情页的抗压这些事在分布式环境里做稳。上一篇文章把项目骨架、服务划分和注册中心这些基础讲完了这篇就直接进正题聊用户模块和商品模块在下半场里那些躲不开的细节。我尽量不念PPT把我实际开发里趟过的坑、改过的方案、最后留下来在用的代码结构都摆出来。内容适合正在做Spring Cloud电商项目、或者准备做毕业设计和简历项目的同学参考尤其是想搞明白用户登录、分布式会话、SPU/SKU建模、库存扣减这些点的时候这篇能给到一些可以直接抄的答案。1. 整体设计与模块边界用户与商品在电商系统里的定位先纠正一个容易犯的错很多人一开始做用户模块和商品模块就直接照着“用户表”和“商品表”去设计CRUD结果做到后面发现用户模块里塞了地址、积分、优惠券商品模块里塞了库存、评价、分类一个服务变成大杂烩。我的建议是先从系统边界和领域模型入手想清楚每个服务的职责和边界再动代码。1.1 为什么用户和商品要拆成独立服务而不是一个大后端单体应用里用户和商品放一起没什么问题但一旦上了Spring Cloud服务的拆分粒度直接决定了后面维护成本。我在这套电商系统里把用户和商品各拆成一个独立服务不是跟风微服务而是因为这俩的访问特征完全不同。用户模块的特点是读多写少但读的瓶颈集中在登录态校验和用户信息查询上并发峰值跟着活动走比如秒杀前的一波登录。商品模块的特点是详情页读流量巨大SKU维度的库存操作是强一致性的硬骨头而且商品的写操作上下架、改价格、调库存都集中在运营后台QPS不高但强一致要求极高。把这两个模块拆开好处有三点一是两边的数据库可以独立选型和扩容用户库用MySQL做主从商品库可以挂Redis缓存扛详情二是发布节奏可以分开运营改商品配置不需要重启用户服务三是故障隔离用户服务挂了不至于影响商品详情浏览。这也是Spring Cloud微服务架构在这个场景里最朴素的意义。1.2 服务间调用关系与数据隔离策略在这套系统里服务调用的基本规则是能不同步调用的就不同步调用能走缓存的绝不穿透到数据库。用户服务和商品服务之间唯一的强关联场景是购物车和下单预览其他场景基本都是靠Feign接口做轻量查询比如在订单服务里要展示商品名称和缩略图直接调商品服务的Feign接口。这里有个实践上的建议不要让下游服务直接查上游服务的数据库。哪怕你图省事在代码里配了双数据源直接查用户表也要忍住。用户表的数据结构一旦调整商品服务那边就可能出问题而且这种隐式依赖排查起来特别费劲。我在代码里强行规定所有跨服务的数据获取都必须走Feign接口哪怕多一次网络开销换来的是边界清晰。1.3 配置中心与注册中心的取舍Alibaba套件停更背景下怎么选热词里提到“Spring Cloud Alibaba停更了”这个确实是很多人焦虑的点我单独说一下我的理解。Spring Cloud Alibaba本身并不是停更而是部分组件比如Nacos的某些旧版本的维护节奏变化加上Spring Cloud官方的版本策略调整导致很多人误以为整条链路都废了。实际上我在这个项目里用的就是Spring Cloud Alibaba的Nacos做注册中心和配置中心目前生产环境跑得挺稳。不过选型上我会做一个折中注册中心和配置中心用Nacos但服务间调用的负载均衡和熔断降级采用Spring Cloud LoadBalancer加Sentinel的组合而不是死等老版本的Ribbon或Hystrix。原因很简单Ribbon进入了维护模式Hystrix也停止开发很久了新项目再往这两个组件上投入不是不行而是后续做版本升级会比较痛苦。既然热词里都提到Spring Cloud Sentinel datasource redis集群说明很多人已经在拿Sentinel做流控和熔断的数据持久化这个方向是对的。2. 用户模块的核心设计与关键代码落地用户模块看着简单无非注册登录改资料但真正生产级的用户模块要考虑的东西很多密码怎么存、登录态怎么保持、分布式场景下Session怎么处理、接口怎么防刷、用户信息变更之后怎么通知下游。下面逐个讲我在这套系统里的落地方式。2.1 用户注册与账号体系用户名、手机号、第三方登录的统一处理账号体系上我没有搞复杂采用邮箱或者手机号作为登录凭证同时支持一个可选的用户名用于展示。数据库设计上用户主表存的是uid、登录名、密码散列值、盐、手机号、邮箱、状态、创建时间、最后登录时间等字段。重点说一下密码存储这是很多新手项目最容易被人拿出来问的点。密码绝对不能明文也不能只做一次MD5因为MD5已经被彩虹表干穿了。我的做法是每个用户独立随机盐然后使用PBKDF2或bcrypt做加盐散列。项目中我选择的是bcrypt因为它内部自带盐且计算成本可调不需要额外存盐字段代码维护起来更省事。Spring Security自带的BCryptPasswordEncoder就能直接接入接口代码大致是这样Service public class UserRegisterService { Autowired private UserMapper userMapper; Autowired private BCryptPasswordEncoder passwordEncoder; public ResultLong register(RegisterCommand cmd) { // 校验登录名唯一 if (userMapper.countByLoginName(cmd.getLoginName()) 0) { return Result.fail(该账号已被注册); } UserAccount account new UserAccount(); account.setLoginName(cmd.getLoginName()); // 使用bcrypt哈希密码encode内部自动生成盐推荐强度10 account.setPasswordHash(passwordEncoder.encode(cmd.getPassword())); account.setCreateTime(LocalDateTime.now()); userMapper.insert(account); return Result.ok(account.getUid()); } }这里有个实操细节注册接口要加一个全局唯一约束不光是代码查一遍就完数据库层面要对login_name加唯一索引否则并发注册的时候可能插入两条相同的数据。这是我在项目里真实遇到过的压力测试阶段回调注册接口瞬间插入了好几条重复账号。2.2 登录态与分布式会话Session共享还是JWT用户登录之后的状态保持是电商系统绕不开的问题。很多单体项目直接扔HttpSession里但一旦服务部署多实例Session默认是不共享的。常规方案有两种Session共享存Redis和JWT无状态令牌。我在这套系统里最终选择了JWT为主、Redis黑名单兜底的方案。选JWT的原因首先是服务无状态化用户服务水平扩展的时候不需要关心用户的Session在哪个节点上其次是网关层可以直接解析令牌做身份识别而不需要把请求转发到用户服务去查会话。但这套方案有个致命短板令牌在签发之后没法主动失效。所以我又加了一层Redis存储把每个签发的token的jti作为key存到Redis过期时间跟token保持一致登出或者改密码的时候直接删掉这个key实现逻辑上的主动失效。登录接口的核心代码大概是public LoginResponse login(LoginCommand cmd) { UserAccount account userMapper.findByLoginName(cmd.getLoginName()); if (account null || !passwordEncoder.matches(cmd.getPassword(), account.getPasswordHash())) { throw new BizException(用户名或密码错误); } if (Objects.equals(account.getStatus(), 1)) { throw new BizException(账号已被禁用); } // 生成JWT携带uid、loginName和jti String jti UUID.randomUUID().toString().replace(-, ); MapString, Object claims new HashMap(); claims.put(uid, account.getUid()); claims.put(jti, jti); String token Jwts.builder() .setClaims(claims) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000L)) .signWith(SignatureAlgorithm.HS256, jwtSecret) .compact(); // 把jti存入Redis设置与token一致的过期时间 redisTemplate.opsForValue().set(login:token: jti, account.getUid().toString(), 7, TimeUnit.DAYS); return new LoginResponse(token, account.getUid()); }配套的登录校验写成一个HandlerInterceptor或者网关处的全局过滤器每次请求都先从Redis里查一下jti是否有效DevTools热加载的时候这个逻辑也不会出岔子。2.3 用户状态管理与分布式下的幂等控制用户资料查询接口有一个容易被忽略的问题它要面对大量读请求而这些请求最好有缓存支撑但用户资料又有很强的实时性要求。我的方案是Redis缓存加过期时间缓存key设计成user:info:{uid}修改资料的时候主动删除缓存下次查询再回源。这里我踩过一个坑就是刚开始只用了expire机制没有主动删除缓存结果用户改完头像之后详情页那边头像一直不变过了缓存过期时间才刷新。后来统一改成“更新数据库-删除缓存”的模式凡是通过用户服务修改资料的请求事务提交后立刻删除相关缓存的key。这个模式虽然引入了一点点缓存穿透的风险但对用户资料的并发度来说完全在可控范围内而且可以用一条简单的delete操作避免脏数据。用户服务的写接口还需要注意幂等性比如用户注册、绑定手机号这种操作如果用户手滑点了两下提交按钮前端又没有做防止重复提交后端就会产生脏数据。我在这套系统里用Redis setnx做了简易的分布式锁key是业务维度加用户维度比如register:lock:{loginName}加锁失败直接返回“请勿重复提交”。3. 商品模块的核心建模与详情页性能方案商品模块是电商系统里最容易被低估的部分。表面上看就是增删改查几张表但实际上要处理SPU、SKU、类目、品牌、上下架、详情、库存、价格这批数据关系还要在高并发读的场景下保证详情页能扛得住。这一节把模型设计和关键接口写法都过一遍。3.1 SPU与SKU数据模型电商商品的标准建模方式商品模型我采用的是SPU和SKU两层结构。SPUStandard Product Unit是标准化产品单元可以理解成一个商品的概念比如“iPhone 15 Pro Max”SKUStock Keeping Unit是库存量单位是具体到某个规格可卖的实物比如“iPhone 15 Pro Max 黑色 256G”。在电商系统里用户下单买的是SKU运营管理的是SPU商品详情页展示的则是SPU维度的聚合信息。库表设计上是spu表存商品主信息标题、副标题、详情、类目id、品牌id、状态sku表存规格组合spu_id、规格值JSON、图片、价格、库存类目和品牌单独建表规格模板单独建表。这层设计的关键是一定要把sku的规格值设计成JSON列数据库层面不需要为每种规格建字段前端渲染时拿JSON去解析就行省去大量动态列变更的麻烦。商品上下架的操作核心其实是状态流转spu有草稿、上架、下架三个状态sku有启用、停用两个状态。上架商品时先校验是否有至少一个启用的sku不然一个能卖的规格都没有商品挂上去就是空壳下架商品时如果还有未支付订单关联要提前做拦截提示避免用户下单了却没货可发。3.2 商品详情页性能方案缓存穿透、击穿、雪崩的应对商品详情页是读并发最高的场景之一所有优化都围绕缓存展开。我的方案是把详情页拆成三个缓存维度基础信息、销售属性、详情描述分别设置不同的过期时间再在接口层做组装。这样做的好处是细粒度更新比如运营改了库存只需要刷新sku缓存不需要把整个详情缓存全部删掉重建。缓存穿透是第一道拦路虎。恶意请求拿不存在的商品id反复刷接口每次都打到数据库能把库拖垮。我的处理方式是在接口层加一个布隆过滤器拦截掉明显不存在的id。因为商品id是雪花算法生成的相对连续但分布比较稀疏布隆过滤器的误判率控制在1%以内几乎可以忽略。实际写代码时我用Redisson自带的RBloomFilter就能实现不需要额外引入组件。缓存击穿是我更关注的场景。秒杀前某爆款商品的详情缓存刚好过期瞬间涌入大量请求全部回源数据库这个瞬间的流量其实很致命。我用的是互斥锁方案在缓存过期后重建缓存时让第一个线程去数据库加载数据并写入缓存其他线程短暂自旋等待拿到值后直接返回。代码逻辑用Redis的setnx加锁锁的过期时间不能太短我设置成5秒重建过程通常只需几十毫秒。这里给一个真实场景的补充详情页还要配合Sentinel做接口限流。热词里不是提到了Sentinel datasource redis集群嘛我在这套系统里就是把Sentinel的限流规则持久化在Redis集群里而不是存在本地内存。# Sentinel datasource配置规则存储在Redis spring: cloud: sentinel: transport: dashboard: localhost:8858 datasource: ds-redis: redis: host: redis-master port: 6379 password: ${REDIS_PWD} channel: sentinel-rules rule-type: flow这样每次在控制台上调整限流规则规则会同步到Redis多个商品服务节点共享同一份规则配置不需要逐个节点去改配置文件。这也是很多若依Spring Cloud改造方案里提到的做法用配置中心加Redis把Sentinel规则做成动态下发。3.3 库存扣减方案数据库扣减、缓存预扣减与事务一致库存是商品模块里最考验设计能力的地方。我在这套系统里把库存操作分成两个场景普通加购时的库存预占和下单时的扣减。普通场景直接读Redis里的sku库存缓存显示给用户“可购买数量”真正下单的时候再走库存服务扣减数据库库存然后反向同步缓存。我最终选用的扣减方案是“预扣库存支付回调确认”模式。用户提交订单时在Redis里先扣减缓存库存使用Lua脚本保证原子性如果缓存库存不足直接返回失败在订单服务创建订单数据状态置为待支付同步调库存服务扣减MySQL库存并记录一条库存流水用户支付成功后异步任务把订单状态改成已支付如果超时未支付系统自动取消订单并回补库存。这里最核心的代码是RedisLua的原子扣减脚本我直接贴出来-- KEYS[1] 是库存key -- KEYS[2] 是已售key -- ARGV[1] 是扣减数量 local stock tonumber(redis.call(get, KEYS[1]) or 0) if stock tonumber(ARGV[1]) then return -1 end if stock - tonumber(ARGV[1]) 0 then return -1 end redis.call(decrby, KEYS[1], ARGV[1]) redis.call(incrby, KEYS[2], ARGV[1]) return 1用Redis集群跑这套脚本时要注意key的哈希槽落在同一个节点上否则跨节点执行会报错。我当时的做法是给库存相关的key统一加上哈希tag比如{sku:1001}:stock和{sku:1001}:sold这样两个key落到同一个slotLua脚本才能正常执行。MySQL库存扣减用一条条件更新实现防超卖UPDATE sku_stock SET stock stock - #{count} WHERE sku_id #{skuId} AND stock #{count}执行后如果影响行数为0说明库存不足抛出库存不足异常同时删掉Redis缓存下次查询时回源数据库获取最新值。4. 用户与商品模块联动的核心业务场景落地用户模块和商品模块拆成了两个服务但真实电商业务里它俩是要频繁联动的。购物车、收藏夹、商品评价、优惠券领取这些操作都要同时涉及用户身份和商品数据。这一节讲讲这些联动场景里我觉得最值得注意的代码和设计。4.1 购物车模块Redis存储还是数据库存储购物车是典型的高频读、低频写模块而且每个用户的数据量很小特别适合放Redis。我用Hash结构存储购物车数据key是cart:{uid}field是skuIdvalue是商品数量。这样用户加购、改数量、删商品都只需要操作Redis性能极佳也不需要为购物车建一堆MySQL表。唯一的问题是购物车数据不能长期丢所以我在购物车服务里加了一个异步落库定时任务每隔5分钟把Redis里的购物车数据增量同步到MySQL作为兜底备份。用户重装App或者登录新设备之后如果Redis数据丢失了就从MySQL恢复购物车。这样做既保证了读写性能又不会让用户辛苦添加的商品直接蒸发。4.2 收藏夹与关注商品异步解耦思路用户收藏商品这个动作的特点是操作频繁、实时性要求不高完全没必要同步去写商品服务的数据库。我的方案是收藏服务直接操作自己的收藏表表里存uid、skuId、spuId、创建时间之后通过Spring Cloud Stream发一条收藏事件到RabbitMQ商品服务订阅消息更新商品的热度值用户服务更新用户的收藏数量。这个异步解耦在代码上的体现是收藏接口只负责写库和发消息用户看到的是“收藏成功”至于热度异步更新、推荐位异步刷新这些都是后台慢慢完成的事。这样真实业务里你能少调至少两个Feign接口接口响应时间也能缩短不少。这里顺手提一个RabbitMQ消息可靠性的配置发送消息时我开启了publisher-confirm和publisher-returns消费者使用手动ack模式。一旦消息发送失败或者消费失败就进本地消息表做补偿每30秒扫描一次未成功处理的记录重新发送。这套机制看起来简单但非常管用。4.3 下单过程中的用户与商品数据一致性订单服务在下单时要同时读取用户地址和商品信息。这两个数据分布在两个不同服务里我选择了编排式开发而不是链式调用订单控制器同时并行调用用户服务的地址查询接口和商品服务的sku查询接口使用CompletableFuture并行组装最后聚合成下单预览数据。这样做的好处有一个非常实在的点避免串联调用导致的总耗时累加。如果用Feign先查用户地址再查商品信息一次下单预览最少要串行等两个RPC差不多50毫秒才能拿到全部数据并行调用的话总耗时基本等于最慢的那个接口一般能控制在25到30毫秒左右。还有个细节是用户下单后要扣减积分、更新用户等级这些联动操作也全部走消息队列异步处理不在下单主链路里同步等待确保交易核心链路的响应速度。5. 压测、监控与常见问题的排查实录这一节我专门整理一些项目跑起来之后才会遇到的真实问题。这些细节常规教程里很少讲但做项目的时候几乎一定会碰上。5.1 压力测试结果与性能瓶颈定位这套系统我用JMeter做了三轮简单压测先给出吞吐量的基准数据单机8C16G云服务器商品详情接口在缓存命中率超过95%的情况下QPS大概在2800左右用户登录接口因为有密码哈希计算而且bcrypt强度设为10单机QPS只能到500左右。这个数据说明用户登录接口会成为整个系统的瓶颈点因为bcrypt的哈希计算本来就是故意的CPU密集型操作。优化思路有几个一是加盐哈希计算强度降到合理的中间值不要一上来就上12这种重型参数我降到10之后单机QPS能提升约30%二是网关层对登录接口做Sentinel限流比如单机每秒只允许通过200个请求防止被打爆三是横向扩展用户服务节点把登录流量分摊到多台机器。压测时我遇到过接口线程池被打满的问题表现为Tomcat默认线程池200个线程用尽后续请求全部排队甚至超时。排查思路是先看Sentinel监控面板看哪个接口被限流了再看Tomcat线程状态和JVM堆占用确认是线程阻塞还是内存溢出。这个排查路径比较通用我把它整理成一个速查表供参考现象可能原因排查手段接口超时率上升数据库连接池打满或慢SQL看Druid监控慢SQL日志explain分析某接口QPS上不去缓存命中率低大量请求打到DB看Redis命中率加缓存预热登录接口CPU高bcrypt哈希计算太耗时降低加密强度限流多实例分摊某个节点假死JVM GC频繁或内存溢出dump堆日志分析GC调大堆内存服务注册后调用不通网关路由或Nacos实例状态异常检查Nacos服务列表确认实例健康检查5.2 分布式场景下的数据一致性与缓存一致性复查用户和商品模块最常见的坑就是缓存一致性问题。我在这里单独列一下我在项目中坚持的几条纪律第一所有更新数据库的操作成功后立即删除对应的Redis缓存而不是更新缓存。删除比更新简单而且能避免并发更新导致的时序错乱。第二如果允许缓存短暂不一致可以在删除缓存后发一条延迟消息比如延迟1秒后再删一次作为双删策略的兜底。我第一次做的就是删缓存结果遇到并发读把旧值重新写进缓存的情况后来加了第二次延迟删除这个问题就基本消失了。第三商品库存这类强一致数据不能直接依赖缓存数据库永远是真源Redis中的库存可以有一小段时间不一致但最终必须通过定时任务和流水记录校准回数据库的值。5.3 面试追问与简历项目提点这套基于Spring Cloud的电商系统写到简历上面试官大概率会追问这几个方向为什么用Spring Cloud Alibaba、Sentinel限流规则怎么做持久化、缓存与数据库一致性怎么解决、库存防超卖怎么做、分布式Session方案怎么选的。这篇里我基本把每个问题对应的方案设计都讲到了面试前还可以把Sentinel加Redis数据源和库存Lua脚本这两个点再细扣一遍这两个最容易被追问编码细节。如果简历上还写了Redis集群那热词里提到的“Sentinel datasource redis集群”就是很好的一条扩展线可以主动讲你用Sentinel配合Redis集群把限流规则做成动态下发避免了传统Sentinel规则只存在单个控制台内存里、重启丢失的问题。这个点能体现你对生产级配置的思考而不仅仅是会用注解。不过我还是要多提醒一句想把这个写成简历亮点前提是你真在项目里跑过这套配置别把别人的经验背过来当自己的面试官顺着细节多问两轮就容易露馅。写在最后的个人体会整个用户与商品模块从搭建到稳定运行我最大的体会是做微服务最容易翻车的不是技术选型而是边界感的缺失。用户和商品这两个模块业务边界划清楚、数据存储独立、缓存设计分层、跨服务通信走接口在Spring Cloud这套体系下其实没有太多玄学按部就班做每个环节都扎实一点系统自然不会差到哪里去。最后再分享一个小技巧如果你是在做毕业设计或者个人项目建议在商品模块里把SPU和SKU的建模、在用户模块里把JWT加Redis黑名单这套方案做扎实这两块最能体现你是不是真的理解电商核心业务而不只是会用几个框架注解。先把这两个点讲明白再去聊Nacos、Sentinel这些组件整个项目的含金量会高很多。
返回列表