做电商系统时,若仅仅关注代码能否运行起来,却忽略边界、状态以及并发控制,那么上线之后库存超卖、订单重复、支付对不上账这几乎就是必然会出现的情况了。许多号称“完整商城源码”的东西,九成都是在这三个方面出问题,那些基于Spring Boot的“标准模板”根本没办法承受真实的下单流量。
库存扣减必须用SQL校验加行锁
有不少源码于进行扣减库存操作之际,径直运用@Transactional去包裹整个方法,自认为如此这般便是原子操作。然而数据库行锁仅仅在事务内部方才生效,于库存校验直至扣减再至订单插入的这个过程之间存在着时间窗口,有两个请求同时读取到库存为1,均会通过校验,最终库存就变为了-1。
做正确的事要使扣减相关的操作去往单独的SQL方式那儿去,在语句之中把UPDATE sku SET stock = stock - #{num} WHERE id = #{skuId} AND stock >= #{num}写死。随后去判定数据库反馈回来的被影响的行数,只有当等于1之时才意味着扣减这件事成功了,不然的话马上抛出异常并且进行回滚操作。务必牢记,千万不要于事务的进程当中去调用远程接口,举例来说,就像是去开展请求微信支付下单的操作,不然的话,事务将会持续占据连接直至超时,整个系统的连接池很快便会被用光。
购物车用Redis Hash结构管理
在微服务架构情形下,用户登录态以及购物车数据得跨服务维持一致,单应用时起万能作用的HttpSession不行再用了。拿Redis存储购物车,有着比较常见的两方面问题:其一,采用set存储整个对象并运用JDK默认序列化方式,要是将来升级JDK版本或者修改类字段,反序列化就会直接失败;其二,不设定过期时间,用户登录或者退出时没删除key,又或者长时间没有操作致使Redis内存满溢。
在处理购物车数据时,应当采用Hash结构来进行存储,具体而言,要运用HSET cart:{userId} sku:1001 2这样的方式,针对每个SKU单独执行增加或者减少的操作,当获取数据的时候,仅获取所需要的字段,以此避免因读取全量数据然后再进行序列化这一过程所导致的性能损耗。使用SET login:token:{token} {userInfo} EX 1800来存储登录token,借助拦截器进行校验,要求所有跨服务调用都必须携带token,在日志里通过MDC打上链路ID,否则要是购物车丢了都不清楚是哪个请求导致的问题。
订单超时关单用ZSET加Lua防重
利用@Scheduled来定时进行扫表关单操作的时候,其逻辑是查询status等于'UNPAID'并且create_time小于NOW减INTERVAL 30 MINUTE,这种情况在真实环境当中根本是无法使用的。之所以无法使用,是因为全表扫描致使MySQL慢查询急剧飙升,要知道多实例同时进行扫描的话,还会出现重复关单的现象,更为可怕的是,关单这个操作以及库存回滚并不在同一个事务里面,常常会出现这样的状况,那就是订单已经取消了,可是库存却并没有加回去。
采取的正确做法是,构建一个独立的order_close表,在进行下单操作之际,插入一条记录。而更为高效有利的方案则是,运用Redis的ZSET,借助ZADD order:close 1678900000 orderId来存储订单,其中score用于存储超时时间戳。接下来,每秒执行ZRANGEBYSCORE order:close -inf 当前时间戳以获取过期订单,通过Lua脚本来确保ZREM与关单逻辑具备原子性。在进行关单操作的时候,必定要先将订单状态修改为CANCELED,接着异步发送消息以触发库存恢复,绝对不可以将其置于同一个数据库事务当中。
价格必须服务端计算并数据库校验
绝大多数“开源商城”都曾犯过一回低级失误:下单接口收受前端传来的price字段,未经校验就径直注入数据库的order.price字段。其后果便是活动价未生效、优惠遭绕过,甚至于被用户借助抓包工具直接窜改价格。
下单接口得禁止接收任何跟价格有关的字段,像是商品单价、运费、优惠金额,全都是服务器端参照SKU ID、用户等级、当下活动规则即时计算得出的。查询商品时返回的price和marketPrice仅仅用来展示的。支付回调期间必须再次核查金额,要是回调里的实际支付金额跟订单应该支付的金额不一样,那就立马抛出异常进行报警。对数据库而言,price字段在类型方面,一定要采用DECIMAL(10,2),而不要去使用Float或者Double,要不然的话,发生像0.1加上0.2却不等于0.3这样的浮点精度问题,会致使你在账目核对上出现对不上账的情况。
用户状态必须用独立存储和传递
单体应用之际,用户登录态凭借Session共享便可得以解决,然而拆分为微服务之后,务必运用独立的认证中心。JWT令牌虽说便利,却无法达成实时失效,在用户更改密码或者被封号之后,令牌仍旧能够使用。建议借助Redis来存储用户状态,并且配合短期令牌机制。
每当有请求进入时,网关会对token予以解析,接着从Redis那儿获取完整的用户信息,而后放置到请求头里传递给下游服务。服务彼此间进行调用时必须传递这个用户上下文,绝不能够每次都去查询数据库或者Redis。当用户登出之际,会直接将Redis中的token记录删除,借助黑名单机制达成实时失效。购物车、订单以及收藏这些核心数据都要携带上用户ID当作查询条件,以此来防止越权访问。
并发控制必须在数据库层面落地
诸多系统于代码层面运用了各类锁,像ReentrantLock、synchronized,然而在分布式环境里这些锁于多个实例之间根本无法发挥作用。库存扣减、优惠券领取这类高并发操作,最终均需依靠数据库的行锁来保障正确性。
于SQL语句之中,运用FOR UPDATE进行加锁之际,务必要确保锁于事务范围之内产生效用,并且加锁的顺序应当保持一致,不然极易出现死锁情况。实施扣减库存操作时,采用带有条件的UPDATE语句,借助数据库的行锁以及原子性,相较于任何应用层锁而言,更为可靠。针对诸如秒杀之类的极端场景,能够先借助Redis实施流量控制以及预热举措,然而最终的扣减确认依旧得落实到数据库层面,并且要保障最终的一致性。
电商系统最怕的是,表面看似正常,然而每一笔订单背后那几毫秒的状态博奕出现了差错,库存字段少了FOR UPDATE,购物车用错Redis数据结构,超时任务没防止重复,价格没有服务端兜底,这些地方不出问题便罢,一旦出问题就是资金损失。你在开发里遇到过最隐秘的线上漏洞是什么,欢迎于评论区分享你的经历,点个赞让更多同行瞧见这些实战经验。
// 示例:安全的库存扣减 Mapper XML 片段UPDATE product_sku SET stock = stock - #{quantity}, version = version + 1 WHERE id = #{skuId} AND stock >= #{quantity} AND version = #{version}




还没有评论,来说两句吧...