Spring Cloud微服务电商实战:用户与商品模块落地实现
接手这套基于Spring Cloud的电商系统时负责的是用户模块和商品模块这两个最基础也最容易写出问题的部分。说它基础是因为几乎每个接口都要被调用说它容易出问题是因为服务一旦拆分注册、登录、商品查询这条链路里任何一环出问题用户体感都会被无限放大。这篇是下篇重点讲用户与商品模块在微服务环境下的落地实现——包括表结构、注册登录链路、库存并发控制、检索缓存以及服务间调用的数据一致性。适合已经完成框架搭建、正准备往里面填业务模块的开发者参考内容偏实践不扯理论上的完美架构。1. 先拆用户模块边界划分和表结构里的取舍1.1 为什么用户域要单独拆服务单体应用里用户表和订单表、商品表放在同一个库查询时一个JOIN就完事。但拆成微服务后用户服务就是一个独立的进程、独立的数据库它要负责的不只是user表那张简单的CRUD而是整个用户域的自治。我在实际划分时有一个很明确的边界凡是用户的身份、资料、收货地址、登录凭证都归用户服务管凡是用户在某笔业务里产生的关系数据比如积分明细、订单列表一律不放进用户服务。这个边界划清楚后有个最大的好处不管订单服务、购物车服务、支付服务怎么迭代用户服务对外提供的接口足够稳定。我只需要维护好几个核心接口用户信息查询、地址管理、凭证校验。业务服务要拿用户数据一律通过Feign调用不允许直接去连用户库。这一点在团队协作时尤其重要否则就会出现多个服务直连同一张表改一个字段要通知五个服务一起改的尴尬场面。1.2 用户模块的表结构少即是多用户模块的表我最终只保留了四张核心表用户主表、用户地址表、登录记录表、用户扩展信息表。用户主表的字段设计上有两个值得注意的地方用户名、手机号、邮箱三个字段都是可登录账号但实际使用中绝大多数用户只会用手机号登录。我在user表里用手机号做主登录标识加唯一索引用户名和邮箱允许为空避免注册时的强约束导致体验变差。密码字段固定存BCrypt哈希后的字符串长度设置为60个字符不要用varchar(32)去迁就MD5或SHA1的固定长度。用过BCrypt的人都知道它每次加密生成的字符串都不同但验证明文密码时都能匹配这就是加盐的效果。用户地址表相对简单但我加了一个is_default默认地址标记并且用联合唯一索引加每个用户至多一个默认地址的逻辑约束。这里有个小坑MySQL里唯一索引遇到NULL值不会生效所以默认标记不能直接建(user_id, is_default)唯一索引。我的做法是业务层加分布式锁控制或者用update ... set is_default0 where user_id? and is_default1先清空再设置新的默认地址两条SQL包在一个事务里。1.3 用户服务的代码分层别被网上的三层架构带偏网上很多Spring Cloud教程喜欢controller、service、dao三层业务复杂后就开始出现互相依赖。我这次用的是更务实的分层controller只做参数校验和结果包装service负责业务编排dao层只做数据读写。但为了不让service变成一个上帝类我按业务域拆了多个serviceAuthService登录注册、UserProfileService资料维护、AddressService地址管理。Controller里只注入对应的服务接口不允许跨域调用。比如注册这个动作AuthService内部会依次调用用户查重、密码加密、插入用户、发送注册消息这四步是有先后顺序的。一旦注册成功但发送消息失败我会用本地消息表补偿而不是把发送逻辑硬塞进主事务里。这块后面讲数据一致性时会展开先按下不表。2. 注册登录链路密码存储、JWT签发与网关校验2.1 密码加密从MD5换到BCrypt过程比想象中简单系统初期用的是MD5加盐方式后来安全评审被打回要求换成自适应哈希算法。我选了BCrypt理由是Spring Security的BCryptPasswordEncoder可以无缝接入而且自带的强度参数默认10让暴力破解的成本按指数增加。换成BCrypt后登录时不再需要对密码解密——你永远没法解密BCrypt只能把用户输入的明文重新加密再去比对。这里有个经验不要自己写加密工具类直接用框架集成的能力。自己实现一方面容易出低级错误比如盐值生成方式不安全、哈希算法选错另一方面安全评审看代码的时候框架类的调用比一通自己写的位运算要可靠得多。我实际做法是在启动时加了一个Bean让Spring容器管理一个全局的BCryptPasswordEncoder实例需要加密或校验时直接注入。2.2 JWT方案access token和refresh token分开还是合并登录成功后怎么发凭证我的选择是发一个JWT作为access token有效期2小时同时落一个refresh token存Redis有效期7天。access token里只放三个核心声明用户ID、用户名、令牌IDjti。不往JWT里塞一堆用户资料比如手机号、头像、积分这些信息放进去后一旦变更已签发的token里还是旧值会引发各种为什么我的信息没更新的疑难问题。Spring Cloud Gateway里加了一个全局过滤器拦截所有非放行路径从Authorization头提取Bearer token用固定的HMAC密钥做签名校验和过期校验。校验通过后把解析出的userId放到请求头里向下游传递下游服务直接用这个userId作为当前操作人。内部服务之间通过Feign调用时我再注册一个RequestInterceptor把上游传过来的userId原样透传到下一个服务这样就实现了全链路操作人识别不需要每个服务都去解析一次Token。2.3 登录接口的防暴力破解登录接口是攻击面最大的接口尤其是公开到公网环境时。我加了三个基础防护验证码、失败次数锁定、登录风控日志。前两个比较常规风控日志这块我做得比较细——每次登录失败不仅记失败原因还会记IP、设备指纹、UserAgent存到登录记录表。当同一个账号在一分钟内失败超过5次时间窗口内锁定该账号10分钟。这里要注意锁定时间的设置锁太久会导致用户投诉太短则防不住暴力破解10分钟是业务和安全的平衡点。我还把用户状态字段正常/禁用/锁定单独拎出来和账号密码区分对待。用户被锁定后即使密码正确也不能登录这避免了改密码就能绕过锁定的漏洞。这个状态由专门的运维后台管理用户服务只提供查询和更新的接口。3. 商品模块SPU/SKU模型与库存并发控制的难点3.1 商品数据模型的三层结构商品模块如果设计不到位后面接订单、营销、搜索都会想骂人。我采用的是业界最常见的三层模型分类、SPU、SKU。分类category管品类层级SPUStandard Product Unit是商品抽象层——比如一部手机型号就是SPUSKUStock Keeping Unit是具体可售层——比如某手机8G128G黑色就是一个SKU。价格、库存、促销锁定都是挂在SKU上SPU只保留了描述信息、主图、详情页一个SPU对应多个SKU的关系简化了商品复用的逻辑比如同款手机不同颜色不需要重复创建商品详情。实际表结构里我额外加了一张商品扩展属性表。因为不同品类商品的属性不一样手机要存屏幕尺寸、摄像头像素衣服要存材质、版型。如果都用字段存表结构会被撑爆。我的做法是用一行记录存一个属性product_id attr_name attr_value查询时按product_id聚合后拼成JSON返回前端。这个设计适合属性种类多的场景但不适合按属性字段做数据库过滤。如果后续业务要求按屏幕尺寸筛选手机再考虑引入搜索引擎或者单独的属性搜索表。3.2 库存扣减的并发控制从超卖到不超卖库存是最能体现微服务并发功底的地方。我在压测阶段故意用200个并发线程去抢同一个SKU的最后一件库存结果不控制的话超卖率接近80%。这说明库存扣减必须放在数据库层面做原子操作不能先查库存再判断够不够然后更新——那两步之间一定存在窗口期。我最终的库存扣减方案分为两层Redis层用Lua脚本先行拦截脚本逻辑是判断现有库存是否充足充足则扣减Redis库存并返回成功。Lua脚本在Redis里是原子执行的不会出现并发计数错乱。数据库层再用UPDATE sku SET stockstock-#{num}, salessales#{num} WHERE sku_id? AND stock#{num}做最终扣减。这个SQL的stock#{num}条件就是乐观锁影响行数为0则说明库存不足直接抛异常。有人会问为什么Redis扣了库存数据库还要扣因为Redis只是快数据最终还是要落到数据库里而且数据库的锁层级才能保证和订单、支付这些事务表的一致性。如果中间某个环节失败我会反向执行补回库存的操作把Redis和数据库两边都恢复。这个补偿动作很关键漏掉的话会出现Redis说没库存但实际上数据库还有货的严重问题。3.3 商品上下架的状态流转用乐观锁防乱改商品状态我设计了五个草稿、待审核、已上架、已下架、已删除。状态流转不是随便什么状态都能跳的比如已上架不能直接跳到待审核必须先下架再走审核流程。我的做法是用一个状态校验类把允许的状态流转路径定义好每次更新时带上原状态条件UPDATE product SET status #{newStatus} WHERE product_id #{productId} AND status #{oldStatus}影响行数为0就说明有并发修改直接提示操作失败请刷新后重试。这个方案比在代码里加synchronized靠谱多了——微服务环境里多个实例并发本地锁根本管不住跨进程的并发。4. 商品检索的隐形重活缓存与索引的实战取舍4.1 缓存穿透、击穿、雪崩一次说清商品详情是电商系统里QPS最高的接口之一直接怼数据库肯定不行。我接了两层缓存第一层是本地缓存Caffeine第二层是Redis数据库兜底。引入Redis之后缓存三兄弟的问题就绕不开。穿透恶意请求用一个不存在的商品ID反复打接口每次都穿透到数据库。我的应对方案很简单如果数据库查不到这个商品就在Redis里写一个空值缓存过期时间设为60秒。这个空值虽然不能完全防止穿透但能把压力挡在Redis层。击穿某个热点key在过期的一瞬间大量请求同时打到数据库。我采用的是互斥锁方案查询Redis拿到null后先尝试获取分布式锁拿到锁的请求才去查数据库并回填缓存其他请求短暂等待后重试。这个方案下热点key最多有一次数据库查询代价是缓存重建期间请求有几十毫秒的等待。雪崩大量key在同一时间过期数据库瞬间被打爆。我在设置商品详情缓存过期时间时用baseExpire random(0, 300)秒让过期时间天然打散。另外Caffeine本地缓存也设置了较短的有效期作为Redis缓存的第二道缓冲。4.2 商品列表查询数据库扛不住搜索引擎顶上商品列表页的筛选条件五花八门按分类、按品牌、按价格区间、按属性。早期用MySQL硬查加了一堆索引后勉强能跑但一旦数据量上万、并发几百MySQL的全文检索和复杂的多条件组合查询性能就明显拖后腿。我的做法是引入Elasticsearch作为商品的检索层MySQL仍然作为数据主库。数据同步路径是商品服务写库成功后发一条MQ消息给同步服务同步服务读取商品数据后写入Elasticsearch。这样MySQL和Elasticsearch之间是异步最终一致的。接口路由上详情页走Redis缓存列表页和搜索页走Elasticsearch只有管理后台校验数据时才直查MySQL。这个架构让商品查询的P99响应时间从早期MySQL方案的800ms左右降到了150ms以内索引优化后的收益非常直观。4.3 缓存一致性更新数据库之后先删缓存还是先更新缓存商品价格和库存一旦变更Redis缓存的旧数据如果不清理用户看到的还是涨价前的价格这笔账早晚会算到程序员头上。我的原则是更新数据库后直接删缓存不主动更新缓存。删缓存带来的代价是下一次请求要重新加载但保证了不会被旧的脏数据覆盖。更进一步我订阅了数据库的Binlog变更用的是一款开源的订阅中间件当商品表的update操作被捕获后自动删除对应商品的Redis缓存。这个方案把删缓存这件事从业务代码里剥离开来即使某个接口漏了写删缓存的逻辑数据变更也能驱动缓存失效算是一道兜底保障。不过要注意Binlog订阅会有短暂延迟所以对于价格这种敏感字段我同时还是会走业务代码主动删缓存两边都做确保一致性的时效性。5. 服务间调用的数据一致性从Feign到分布式事务边界5.1 Feign调用的超时与重试必须显式配置用户服务和商品服务之间没有直接调用但订单服务在创建订单时要同时拿到用户信息、商品信息这时候就会出现跨服务调用链条。Feign底层走的是HTTPHTTP调用最大的不确定性是网络。我碰到过一个非常隐蔽的问题下单接口偶发耗时高原因是订单服务调用用户服务超时而Feign默认的超时设置是1秒数据库慢查询直接拖垮了整个下单链路。后来我把Feign的配置显式调整了连接超时设为3秒读取超时设为10秒同时关闭默认的重试机制。这里有一个关键考虑Feign自动重试只对连接异常有效如果请求已经发出、服务端也处理了但响应超时重试就会导致重复下单。所以我在下游接口上做了幂等设计——每个下单请求带一个流水号服务端用这个流水号去重重复请求只返回第一次的处理结果。这样才敢重新开启有限的重试。5.2 分布式事务的取舍不是所有链路都需要强一致下单链路涉及订单服务写订单表、库存服务扣库存、用户服务扣积分或更新优惠券。这套链路如果全部用强一致分布式事务性能会被压得很低。我的取舍方案是核心的主单扣库存用Seata的AT模式保证强一致而扣积分、发消息这类辅助操作用接MQ的最终一致性方案。具体到下单场景我在订单服务本地建了一张消息表在同一个本地事务里完成创建订单写入活动记录两步事务提交后启动一个定时任务把未发送的消息推到MQ。库存服务消费MQ消息后扣减库存扣减成功再回调订单确认状态。如果中途某个环节失败订单状态会一直停留在待确认定时任务会反复尝试推送直到成功。这套方案牺牲了一点实时性但换来的是整个链路的可用性和数据可补救性。实践中我还发现能用状态机解决的问题就不要上分布式事务。比如订单状态本身就是一条状态机从已创建到已支付到已完成每一步都记录时间戳和操作人。某个下游操作失败时把订单打回已创建并标记失败原因由后台任务驱动状态重试这比分布式事务中的回滚操作简单得多也更容易排查问题。5.3 补偿网关分布式事务失败的兜底设计无论用哪种事务方案总有极端情况会失败比如服务宕机、网络闪断。我加了一个独立的补偿任务扫描那些长时间处于中间状态的订单数据比如超过5分钟还没确认的订单根据其来源和业务类型执行对应的补偿逻辑。补偿逻辑包括查询下游库存状态、补齐扣减操作、或者反向回补库存。补偿任务单独部署在定时调度的模块里不依赖订单服务所在的应用进程。这样做的好处是即使订单服务重启补偿任务依然可以继续执行不会把恢复工作的希望寄托在故障服务自己身上。这段设计一开始被团队吐槽过度设计后来线上真的发生了一次库存服务宕机补偿任务在服务恢复后自动把积压的未处理订单全部推进到正常状态大家才认可了这套兜底方案的价值。6. 编排部署与链路追踪让微服务从能跑到稳跑6.1 Nacos注册中心与配置中心的联动服务拆分后每个服务的地址都在变化。我用Nacos同时承担了服务注册中心和配置中心两个角色。服务启动时把自己注册到Nacos消费者从Nacos拿到服务实例列表后做负载均衡本地不再硬编码对方的IP地址。这个组合有两点要注意命名空间必须隔离dev环境、测试环境、生产环境用不同的namespace避免开发环境的服务把生产环境的配置覆盖掉。这个坑我踩过一次开发调试时把配置写乱了直接影响了测试环境后来所有配置都强制按namespacegroup进行隔离。配置变更要可控Nacos支持配置文件的动态刷新但不要盲目把所有配置都交给配置中心管理。我的原则是可能随环境变化的配置才放Nacos硬编码的规则参数留在代码里。比如数据源、Redis地址、线程池参数、开关配置放Nacos一些业务阈值和不可变的常量就留在代码中避免配置项过多导致线上改一处配置文件引发全部服务刷新。6.2 全链路追踪一次请求到底经过了多少个节点用户的一次下单操作在网关层落地后会经由订单服务、商品服务、库存服务多次路由和调用。以前排查一个耗时问题要在每个服务里翻日志用时间戳拼凑调用链效率极低。我接入了全链路追踪组件在每个服务里通过拦截器自动生成一个全局TraceId伴随着请求在整个链路中传递每次跨服务调用都在日志里打上同样的TraceId。这个接入过程不复杂网关在请求头里生成TraceId各服务日志框架的Pattern里加上这个TraceIdFeign的RequestInterceptor负责传递。线上排查问题时只需要拿用户反馈的时间点或订单号在日志平台搜TraceId就能筛出这一条请求在用户服务、商品服务里的所有日志。几秒就能定位是哪个环节慢了不用再求着各个服务负责人帮忙捞日志。排查效率提升是肉眼可见的。6.3 压测与调优线程池和连接池并发参数的一次修正系统上线前我做了一轮压测目标是在200个并发线程下商品详情接口和下单接口不能拖垮网关。第一轮压测结果惨不忍睹——网关线程池被打爆大量请求直接超时。排查后发现两个问题网关默认的线程池配置偏小我调大了worker线程上限并给核心路由规则单独配置了独立的线程池避免一个慢接口拖垮所有路由。订单服务里的数据库连接池初始配置是maxActive20200并发下连接池瞬间被占满大量线程等待获取连接。我根据MySQL的最大连接数和服务部署数量做了计算最终调到50并配合合理的等待超时时间。调优后重新压测商品详情接口的吞吐量提升了近3倍下单接口的P99从1.2秒降到400毫秒左右。这里我的体会是微服务架构下瓶颈往往不在业务代码而在这些容易被忽略的基础参数上。线程池大小、连接池大小、超时时间、重试次数每一项都值得结合压测结果反复调整而不是套用某个标准配置。我自己在这套系统里最大的收获是微服务改造不是把原来的模块拆成多个服务就算完事每个服务都要想清楚自己的数据边界、故障边界和性能边界。用户模块和商品模块看起来是最简单的两个模块但恰恰是因为它们被所有服务依赖所以任何一处设计欠账都会在联调和压测阶段加倍偿还。如果你也在做类似的电商系统研究建议从这两个模块入手把并发控制和数据一致性这两条主线走通整个微服务体系的搭建思路也就顺了。