摘要
从成千上万错误到0报错、从勉强4kqps到2wqps高性能稳定下单服务、从999线5秒到250毫秒响应能力,荣幸与您一起回顾虚拟商城优化演进的心酸、迷茫与惊喜。
如果您没空阅读直接看结论:
高并发潜在问题防范心得
- 不信任每一个DB操作
- 不信任平日运行正常的代码,高压下会有惊喜
- 网络请求尽量少
- 适当抛弃、压缩、拆分事务
- 合理的重试、回滚机制
- 测试环境持续高压和火焰图观察一定能防范大部分常规高并发问题
目录
如何入手,初定方向
多方讨论发现大刀阔斧修改面临几大问题:巨大QA工作量和线上稳定性风险、周期长、预期效果不确定、细节优化易忽略。本次优化将对未来订单研发深远影响,面对复杂业务代码确定如下优化方向。
- 整理代码,逻辑分明,结构简化
- 先不动主干,着手细节优化
- 支持开关切换保证线上稳定性
- 渐进、摸索式的迭代更新
- 逐步放弃DB:基础数据缓存、库存缓存、订单缓存、异步订单
- 精益求精再优化
代码整理与重构
代码初期功能层次嵌套较深,无法100%掌控代码时,总给人惊喜!又谈何深入优化?精简方法入口,保证dubbo、http等所有下单api对应内部唯一的Biz或Service层入口,为专心优化做好铺垫。简化明确逻辑层次,使基础数据查询、订单前校验、库存操作、券操作、订单生成、后续操作的线性逻辑一目了然,拒绝层层嵌套。
直连依赖服务限制
告别低性能网关
连续几周抢购大面积请求超时甚至超10秒,借助trace链路监控发现外部服务券系统交互慢。但券系统负责人出乎意料表示系统自身处理最慢百毫秒级。那请求到达券系统之前就出了问题。
抢购业务方前后端交互和券系统使用同一个Spring项目的七层网络层软件级网关。它高压下出现GC频繁、响应过慢、CPU满载,同时是各应用交互中间节点,高压与重试令网关崩溃。随后从核心接口开始逐渐迁至智能网关后再无网关问题。
外部服务耗时控制
警惕外部服务超时,商城设置http和dubbo交互均为最大1秒超时,避免资源占用系统雪崩。也同时为商城服务设下目标:我们的服务在999线必须无一例外1秒内完成。
事务-大事化小小事化了
高并发下每一条sql都是炸弹,初期无法全面放弃DB,只能从缩小事务、减少操作、放慢操作入手。
缩小事务
下单时商品信息、商户信息、券信息等查询和第三方校验等接口访问全部移出事务,时效性、数据一致性要求不高业务也搬出事务,保证事务中操作较少,耗时减少。
慢化其他事务
资源占用相互影响,具体到下单这种线性流程,即下单>支付中>支付完成,时间跨度短,宏观短时间大量订单集中爆发等同于同时执行。为保证下单核心功能资源充足,委屈支付过程。借助消息队列异步处理,控制消费者数量并采用单消息拉取模式。放缓支付完成时一些重量级操作(售卖记录、券消费、库存记录、支付后续等系列操作)。
精简查询
有人说我肯定有需要才做一次查询,但多人开发多次迭的模块划分难免出现重复查询、多余查询。
图6-1下单部分查询,druidMonitor
重复查询
通过图6-1不难发现product_display_c字样查询较多,并发度高,检查代码商品相关查询重复,方法间商品数据透传和缓存是重复DB查询的解决方式。
非必要查询
DB中间件生成代码或者偷懒,全量查询随处可见却常被忽略,只查关键字甚至利用覆盖索引尤为重要。订单只查需要关键字,订单过滤放弃select * 改为 select orderId from where orderId in(…)覆盖索引。
合理移除查询
图6-1中event_record看上去与订单关系不大却top1的执行数和较高并发度,显然不合理。该表即内部事件记录,负责记录和更新包括订单变动在内的所有内部事件。从业务层面分析让成功事件日志记录,仅报错事件写库方便重试,再次拯救下单砍掉数十万查询。
索引不可信
合理的索引不一定快,不确定DB使用哪个索引,force index可强指定索引。核心流程警惕SQL,线下做好explain并注意possible_keys和实际执行耗时,线上可观察trace链路监控和druidMonitor查看耗时。警惕百毫秒查询,核心流程不允许过百毫秒耗时。
里程碑-非核心优化完成 8k
较优化前4000qps提升一倍
图7-1-8kqps下单效果
(图7-1中mean cnt平均每秒执行数,8k是持续压测峰值,7384.615是平均qps值)
库存缓存
MySQL的库存操作耗时超长和并发度超高,这正是商城第一个核心问题——并发库存扣减瓶颈。不言而喻下单必减库存,是DB资源消耗大户,单商品抢购加锁线性排队更新以至勉强100qps。
Redis库存初期方案(艰难弃用)
数据重心落于缓存,缓存必须高可用。
简要设计
- Redis数据结构采用hash,存储可用库存和锁定库存值
- 发送库存变动消息,按skuId散列选择partition,消费者将库存写入DB
问题
消息队列数据待消费,缓存数据丢失后立即同步DB的数据是错误的,如不立即同步则期间禁止库存操作,待队列消费完毕后同步数据。新增数据同步保证数据严格一致性措施复杂度,考虑异常和重试。
Redis库存线上方案
当商城按初期方案,大刀阔斧完成主要逻辑研发,稳定的8kqps压测效果十分不错,不再出现报错,999线耗时锐减。但逐步开发完善以保证数据最终一致性时,各种问题层出不穷,每每报错令人措手不及,重试与补偿代码混合,最终代码复杂度远超主逻辑,维护性和可读性降低,艰难决定放弃第一版库存缓存。
简要设计
- 库存数据重心于DB,缓存分钟级短周期,生命结束自动同步修正数据
- 数据结构保持hash,保留可用库存,取消锁定库存
- 新增库存待分配订单状态
- 库存操作均直接操作缓存并发出库存变动消息
- 低概率超卖放行的下单请求,库存消费者扣减库存失败并取消订单保底
再优化
加锁解决Redis并发库存同步
偶发性超卖源于并发数据同步,设想在DB库存100并发请求下单,缓存同时同步库存,A同步成功并下单库存减1,B刷回库存100。分布式锁解决了该问题。
Lua保证原子性操作
缓存很快,但高并发中上下两行读写必然出现不一致,商城库存操作采用Lua汇总key存在性判断、可用库存判断、库存计算、返回执行结果等操作一次请求完成。
使用Lettuce
Redisson转Lettuce后1Wqps缓存CPU使用率96%降至74%,降幅20%。
日志异步化
支付系统将日志异步后qps能力提升30%,商城直接用上。
事务与非事务混合的难点
事务出错自动回滚保证数据一致性是事务于业务中的一大优点。倘若事务回滚,缓存怎么恢复?正是事务与非事务混合面临的数据一致性挑战。
调整执行顺序
建议事务中先执DB操作,再执行非事务操作。DB报错回滚,缓存代码未执行,缓存无需恢复;尽量将非必须操作移至事务外层,下面编码方式能解决代码于事务内,执行于事务外的目标,同时保留原代码逻辑顺序。(注意:下图分布式事务中afterCommit并不会真正提交)
自定义回滚
如下图catch中显示回滚
如下图编码方式实现隐式回滚
自定义重试
重试可以保证回滚自身报错数据最终一致性;抢购资源不足报错家常便饭,错峰重试还能让订单正常扭转增强用户体验。做到回滚报错重试,非代码逻辑错误重试,定义合理的重试次数,重试数据可暂写入缓存或消息队里(切记写入DB,重试大概率是为DB资源不足做的补偿措施,即DB本来就是崩溃边缘)并异步处理。
消息队列异步处理坑
打印发送结果日志
发送消息时实现ListenableFutureCallback并打印失败和成功日志,现实证实在网络不佳情况下偶性发送失败。
合理采用同步发送
订单状态变更是一个线性过程,订单待支付不可能直接变化为交易完成。不必要的乱序造成资源浪费式重试。
消息顺序与批量消费
根据业务消息发送按skuId散列选择partition,让同一商品库存消息进入同一partition,批量消费N个库存变动消息,库存于内存中计算后原N次操作化为一次DB操作,结果库存分配能力提升10倍。
里程碑-库存缓存完成
- 单商品下单能力提升80倍,100提升到8kqps
- 单商品库存分配能力提升10倍,100提升到1kqps
- 总下单能力保持8kqps,999线470毫秒跌进300毫秒大关
- 2W单量抢购的DB相关报错量从6k+报错量降低1-2k
图9-1-8kqps下单响应
整体请求响应明显提升,报错量下降,任然大量DB操作以致8kqps未突破。移除了大规模并发库存操作也必然节省DB资源,潜在提升其他服务能力。
订单缓存第一版
订单信息依然是数据库重量级业务数据,对内对外的查询量相当巨大。秉着尽可能少DB操作目标,决定订单数据缓存。
简要设计
- 整理代码确保订单查询方法唯一
- 订单写操作先缓存后DB同步完成
- 订单查询只读缓存,砍掉DB订单查询操作
- 订单基础数据来源数据表单行记录,缓存采用hash结构,便于字段单独高频修改
- 订单详情数据结构复杂且低频更新则采用json存入缓存
- 用户订单列表数据来源从库查询与用户订单缓存数据合并的结果
抢购实际效果
- DB相关报错量为0
- 库存分配耗时超1秒量为0
- 订单支付等待库存分配报错量为0
- 业务方反馈线上dubbo线程池报错量骤降
长期报错令人身心俱疲、此刻商城看到了希望!
订单缓存第二版 异步订单
简要设计
正是下单始终包含DB操作,让qps仿佛看到了8k天花板,即便勉强10k,999线值数秒也无法直视,更谈不上可用。商城目标是下单取消DB操作,决定实施异步订单设计。
- 代码整理收拢所有订单更新入口
- 申请2台redis,订单更新先写缓存后异步写DB
- 发送订单更新消息,按订单号散列选择partition
- 批消费订单创建消息且批插入DB
- 批消费订单更新消息且并行更新DB
异步订单多线程消费问题
未分配库存-待支付-支付中-待发货-已发货-交易完成,是一个单一的线性扭转过程,不允许跨越、逆序变更,但现实中遇到并发乱序导致更新报错。
因此取消原有单机一个Executor最大6线程数配置,改为构建包含6个Executor线程池列表,消息通过orderId散列值选择0-5下标的线程池;每个Executor最大线程数1,保证一个线程池只有一个线程去处理;阻塞队列大小设置256,给与暴增订单消息排队机会;保持CallerRunsPolicy拒绝策略,队列爆满交由主线程执行时,主线程仍有与线程池执行中线程并行消费同一订单冲突可能,被迫添加异步重试解决低概率冲突问题。
精益求精-压缩网络I/O次数
下单已无DB交互了,迷茫之际,火焰图意外开启深度优化征程。
火焰图利器
图10-3
图10-3发现一个异常点,keyModPartitionSize方法在业务中其实根据orderId与partition总数取模,预期不耗时,但它火焰图横条宽度所占比例巨大,点进详情图10-3可见基本是kafkaTemplate.getDefaultTopic()严重耗时。
IDE火焰图插件在平时本地压测也可用上,偶然发现Redis库存查询明显比其他Redis操作横条宽度长很多,检查代码发现库存查询手误执行了两次,该重复查询bug不会业务逻辑报错,火焰图才让它浮出水面。因此观测火焰图中横条宽度所占比例和横条宽度对比能帮助发现一些意外耗时或隐藏错误操作。
配合火焰图,让下单各种I/O耗时操作显露无疑,从此吹毛求疵缓存查询次数,决定结合业务和技术压缩缓存I/O次数。
商城1Wqps压测的常规数据缓存的CPU使用率,接近75%,该Redis主要缓存商户、商品详情、商品售卖配置、商品展示模板等低频更新数据。借助日志发现,一次下单请求仅商品详情便重复查询若干次,整体基础数据缓存查询15次。
不同业务数据不同的本地缓存生命周期,下单容忍秒级数据不一致现象。可以使用Guava本地缓存工具。本地缓存好处一是无网络请求和序列化耗时,二是代码上不用为减少调用次数改变原有代码的逻辑性和隔离性,只要在生命周期和单机范围内最多执行一次I/O操作。优化后下单基础数据缓存查询最多6次,基本0次。CPU使用率降低至33.4%左右。
节省I/O次数
易忽略的网络操作
template.opsForHash().putAll(key, …)与template.expire(key,…)两行代码确实只能这么写,Spring没提供同时设置值和超时的方法,发起了两次网络请求。
LUA脚本
lua脚本在库存缓存时提到过解决原子性操作问题,其实还能使多次网络请求转化为一次,必须遵循代码可读性、可维护性原则以免滥用。
业务层面砍掉不必要的请求
商城在kafka消息消费时利用redis打标记以做消息去重的目的。在低概率消息重复情况下放弃了Redis去重,让重复消息容错体现在业务代码中。
遗忘的Transactional注解
它偷偷打在方法上,哪怕方法空操作,其代理方法也会开启无用事务与DB交互。(深有感触,高压下莫名报错DB连接池使用达到maxActive报错)
预缓存
下单校验首次调用有很多DB用户级数据查询动作,二次下单才能利用缓存。往往抢购用户仅下单一次从而无法达到高效利用缓存。最后设计从业务层面入手,下单前用户自行某动作触发下单检查。保证下单一定使用缓存。实际效果抢购下单999线再次下降300毫秒。
里程碑-完成异步订单 2Wqps
下单突破2Wqps,999线低于1秒,日常2W单量抢购999线低至250毫秒。