单台售货机一天几百单,1000台就是几十万单。高峰时段午休、下班订单并发可能达到每秒上千甚至上万。如何设计高并发、高可用的后台系统?本文从架构设计到落地细节,给出完整方案。
一、高并发场景真实分析
业务数据量化
让我们先算一笔账。假设全国部署了1000台智能售货机,每台设备每天服务100到200个用户,每台每天产生150到300笔订单。那么整个系统每天要处理15万到30万笔订单。这听起来好像不多,但问题出在时间分布上。
早高峰7点到9点、中午11点到13点、下午17点到19点是三个订单高峰期,这三个时段加起来大约8个小时,却集中了全天60%到70%的订单量。换算下来,高峰期平均每秒要处理30到70笔订单,极端情况下可能瞬间达到每秒200到500笔。
对于一台传统MySQL数据库来说,每秒处理200个订单写入几乎是不可能的。MySQL单库每秒稳定写入大约在500到1000条左右,但这是在理想情况下。一旦涉及到库存扣减这种需要行锁的操作,性能会急剧下降。10个并发同时扣减同一商品的库存,可能有9个在等待锁释放,实际吞吐量连100 TPS都到不了。
所以高并发瓶颈的本质不是订单量大,而是两个关键操作特别难扛:一是订单创建时的数据库写入和库存扣减,这两个操作必须快才能保证用户体验;二是订单完成后的状态查询和统计分析,这些操作数据量大容易拖慢系统。
性能瓶颈链路拆解
一笔订单的完整流程是这样的:用户选择商品并扫码支付,后台收到支付成功的通知,然后创建订单记录,扣减对应货道的库存,触发出货指令,最后更新订单状态并通知用户。
在这条链路上,数据库写入是最大的瓶颈。尤其是库存扣减,高并发时大量请求同时修改同一行数据,会产生严重的锁竞争。一个货道只剩1件商品,100个人同时下单,如果没有合适的并发控制,99个人的请求都会失败,体验极差。
另一个瓶颈是状态查询。用户付款后需要立即知道是否出货成功,后台要实时查询订单状态。如果订单表有上千万条数据,查询响应可能需要好几秒,用户会认为系统卡死了。
二、三层架构设计
整体架构概览
高并发系统的设计核心理念是分层分流。把高频热数据放在内存里快取快写,把低频冷数据放在磁盘里持久化,把需要异步处理的任务放进队列里削峰填谷。
整体架构分为四层。最前面是接入层,用Nginx加上Lua脚本实现负载均衡、IP限流、请求分发。这一层的作用是挡住大部分无效请求和恶意攻击,把正常的业务请求均匀分布到后端服务。接入层还可以做请求预处理,比如参数校验、格式转换,减轻后端压力。
第二层是服务层,也叫业务逻辑层。这一层按照业务功能拆分成多个独立的服务:订单服务专门处理订单创建、查询、取消;库存服务专门管理库存扣减、恢复、同步;支付服务专门对接第三方支付平台;设备服务专门管理设备注册、心跳、远程指令。每个服务都是独立部署的,可以单独扩容,互不影响。
第三层是数据层,包括Redis集群和MySQL集群。Redis用来存放热点数据比如实时库存、活跃会话、今日订单。MySQL用来存放需要持久化的核心数据比如完整订单历史、设备档案、用户信息。两者的分工是:Redis回答"今天卖了多少"这种热点问题,MySQL回答"去年12月卖了多少钱"这种历史问题。
第四层是任务层,包括消息队列、定时任务、日志收集。消息队列用于异步处理耗时操作比如发送通知、生成报表。定时任务用于数据汇总、库存同步、异常检测。日志收集用于问题排查和运营分析。
为什么这样分层
分层的核心目的是让每一层做自己擅长的事。接入层擅长处理网络连接和协议转换,它离用户最近,能以最低成本实现限流和防护。服务层擅长处理业务逻辑,它可以根据业务复杂度灵活扩展。Redis擅长高速读写,它的数据存在内存里,读取速度比MySQL快10到100倍。MySQL擅长数据一致性和复杂查询,它的事务机制能保证数据不错不漏。
这种分层还有一个重要好处是容错降级。当MySQL压力过大时,可以让部分查询降级到只读缓存;当Redis崩溃时,可以让非关键功能暂停,保证核心交易不受影响。
三、Redis在系统中的角色
Redis解决什么问题
Redis在这个架构里扮演了三个关键角色。
第一个角色是高速缓存。库存数量、商品价格、设备状态这些数据查询频率极高但变更频率不高,很适合放在Redis里缓存。用户下单前查库存,毫秒级返回;查商品信息,毫秒级返回。这些高频读操作如果每次都打MySQL,数据库早就爆了。
第二个角色是写入缓冲。订单创建这个操作必须快,用户扫码支付后等2秒还没反应就会焦虑。但订单要写入MySQL保证持久化,MySQL写入又比较慢。解决方案是在Redis里先把订单存进去立即返回成功,然后用后台任务慢慢写到MySQL。这样用户感觉很快,后台也能扛住。
第三个角色是分布式锁。多个请求同时扣减同一商品的库存,必须保证不超卖。用Redis的SETNX命令可以实现分布式锁,同一时刻只有一个请求能持有锁,其他请求排队等待。
Redis数据结构设计
根据不同的数据特点选用不同的Redis数据结构。
设备实时状态用Hash来存储。一个Hash里可以存一个设备的所有状态字段,比如在线离线、温度湿度、信号强度、最近心跳时间。查询时用HGETALL一次性取回所有字段,更新时用HSET改单个字段,非常方便。
库存数据也用Hash来存储。每个设备的每个货道是一个field,库存数量是value。这样查询某个设备所有货道的库存用HGETALL,获取某个货道的库存用HGET,扣减库存用HINCRBY加Lua脚本保证原子性。
今日销售额用String来存储。key是设备编号加日期,value是累计金额。每产生一笔订单就用INCRBYFLOAT累加金额,查询时GET一下就知道今天卖了多少钱。这种计数器场景用String最合适。
待处理的订单用List来存储。新订单用LPUSH推进队列,后台任务用BRPOP阻塞弹出队列处理。List的先进先出特性完美匹配订单处理顺序。
分布式锁用String来存储。SET key value NX EX 10的意思是:如果key不存在就设置值并10秒后自动过期。业务处理完成后用DEL释放锁。这个模式可以用来控制库存扣减、支付回调等关键操作的并发。
四、MySQL表结构设计
订单表设计
订单表是最核心的表,设计不好会直接影响整个系统的性能。
首先,表必须有合理的分区策略。订单数据只会增加不会修改,历史订单几乎不会被查询。按月份分区是最常用的策略,查询某个月的数据时只扫描那一个分区,速度飞快。每个分区控制在几十万到一百万条数据,性能最优。
其次,必须有合适的索引。查询订单最常用的场景是按设备编号查某台机器的订单,或者按时间范围查某段时间的订单。所以要建两个复合索引:一个是设备编号加创建时间,用于查询某台机器某个时间段的订单;一个是订单编号,用于精确查询某笔订单。
第三,字段类型要选对。订单编号用字符串存储方便调试和对接外部系统。金额用DECIMAL而不是FLOAT避免精度丢失。状态用枚举类型而不是字符串节省存储空间也能利用数据库约束。
库存表设计
库存表记录每个货道的当前库存和容量。设计要点是唯一性约束:同一台设备的同一个货道只能有一条记录。用设备编号加货道编号做复合唯一索引就能保证。
库存表还需要记录最后更新时间。这个字段有两个作用:一是用于排查问题知道什么时候改过;二是用于检测异常,比如更新时间距离现在超过24小时说明设备可能掉线了。
销售汇总表设计
每日销售汇总表用于快速查询统计报表而不需要扫描原始订单表。表里每条记录对应一台设备一天的汇总数据,包括订单数量、销售额、客单价等核心指标。
这张表的数据由定时任务在每天凌晨计算并写入。查询日报月报时直接查这张表,几毫秒就能返回结果。
五、库存扣减的并发控制
问题分析
库存扣减是整个系统最复杂的操作,也是最容易出问题的操作。
假设某个货道剩5件商品,10个用户同时下单。理想情况是前5个人都能买到,后5个人提示库存不足。但实际执行时,如果没有任何并发控制,可能出现以下问题:
第一,库存变成负数。10个请求同时读到库存是5,然后各自扣1,最后库存变成负5,超卖了5件。
第二,库存重复扣减。同一笔订单因为超时重试或者其他原因被执行了两次,导致库存被扣了两次。
第三,数据库行锁竞争。10个请求同时执行UPDATE语句,都要锁住同一行数据等待执行,串行执行效率极低。
解决方案:Redis原子扣减加异步同步
解决思路是分两步走。第一步在Redis里用Lua脚本原子完成检查和扣减,确保不会超卖也不会重复扣减,速度极快。第二步异步把扣减结果同步到MySQL,保证数据最终一致。
Lua脚本的逻辑是这样的:先检查库存是否大于等于要扣减的数量;如果够就扣减并返回成功,如果不够就返回失败。整个脚本执行过程中没有任何其他操作能修改这个库存数据,保证原子性。
异步同步的逻辑是:扣减成功后把变动记录写进一个同步队列。后台任务从队列里取出记录批量更新MySQL。如果同步失败就重试,极端情况下需要人工介入比对和修正。
为什么要异步同步
有人可能会问:为什么不直接操作MySQL保证强一致性?
答案是性能。MySQL的单条UPDATE操作虽然只需要几毫秒,但10个并发同时执行就要排队等候,总耗时是累加的。Redis的Lua脚本执行只需要零点几毫秒,因为全程在内存里没有任何IO。
用异步同步牺牲的是强一致性,换来的是高吞吐量。什么叫最终一致性?就是允许Redis和MySQL短暂不一致,但最终一定会变成一致的。对于售货机这个场景,用户下单时关心的是能不能快点完成交易,库存数据晚几秒同步到MySQL完全能接受。
六、订单创建的异步处理
为什么需要异步
用户扫码支付成功后,系统要在几秒内完成订单创建、库存扣减、触发出货。这个链路如果全是同步操作,用户等待时间会很长。
更严重的问题是高峰期。假设高峰期每秒有100笔订单,每笔订单写入MySQL需要10毫秒。如果全部同步写入,MySQL每秒只能处理100条,队列会越来越长,请求堆积导致超时。
解决方案:写前返回后补写入
订单创建的优化策略是:先把订单关键信息写入Redis,立即返回成功给用户,然后后台任务慢慢把订单写入MySQL。
写进Redis的订单信息包括订单号、设备编号、货道编号、商品信息、金额、支付状态、创建时间。返回给用户成功后,用户就能看到订单已创建并等待出货。
后台任务运行在独立进程或线程里,持续从Redis队列里取出订单写入MySQL。写入成功后从Redis队列删除这个订单,保留订单状态缓存供查询。
异常处理
异步写入最大的风险是丢数据。假设Redis里的订单还没来得及写入MySQL,服务器突然断电了怎么办?
解决方案是Write-Ahead Logging。写入Redis的同时把订单日志追加到磁盘文件。断电恢复后从日志文件里找出未完成的订单,继续写入MySQL。
另一个风险是重复写入。同一个订单被写入两次会导致数据重复。解决方案是MySQL表里对订单编号建唯一索引,重复写入会报主键冲突,捕获异常后忽略即可。
七、缓存策略
缓存穿透
缓存穿透是指查询一个不存在的数据,每次都绕过缓存直接查数据库。比如用户输入了一个不存在的订单编号,后台会一直查MySQL,每次都查不到,白白浪费数据库资源。
解决方案是对不存在的数据也缓存起来。查询MySQL发现数据不存在时,在Redis里存一个空值标记。后续查询先检查这个标记,发现是空值就直接返回不存在,不再查数据库。空值标记的过期时间设置短一些比如5分钟,防止真的创建了这个数据后还是查不到。
缓存击穿
缓存击穿是指某个热点数据的缓存刚好过期失效,瞬间大量请求同时打到数据库。比如某台热门机器的库存数据缓存过期了,恰好此时来了一大批查询请求,全部穿透到MySQL把数据库打爆。
解决方案是对热点数据不设置过期时间,让它永不过期。更新数据时采用双写策略:先更新数据库,成功后再更新缓存。两步都成功才算更新完成。这种方式保证了缓存永远有数据,不会出现击穿。
缓存雪崩
缓存雪崩是指大量缓存同时过期失效,导致大量请求同时穿透到数据库。比如系统里所有缓存都设置了1小时过期,1小时后这些缓存同时过期,所有请求同时涌向MySQL。
解决方案是过期时间加随机抖动。假设业务允许的过期时间是1小时,那就设置为1小时再加0到30分钟的随机值。这样不同数据的过期时间分散开,不会出现大批量同时失效的情况。
缓存预热
系统刚启动时Redis里是空的,大量请求会直接打到MySQL导致启动抖动。解决方案是系统启动时主动把热点数据加载进Redis。
预热的数据包括:所有设备的库存数据、热门商品信息、系统配置参数。这些数据从MySQL查询出来后写入Redis,完成后系统才对外服务。
八、限流与熔断
为什么需要限流
系统资源是有限的,但请求量可能是无限的。高峰期可能突然涌入大量请求,如果系统来多少处理多少,最后会因为资源耗尽而全面崩溃。
限流的作用是在入口处把多余的请求挡回去,保障系统能正常服务剩余的请求。比如系统每秒最多处理1000笔订单,当前有2000笔请求过来,限流机制会拒绝掉1000笔,返回"系统繁忙请稍后重试",另外1000笔正常处理。
滑动窗口限流实现
限流算法有很多种,滑动窗口是最常用的一种。
原理是把时间线划分成固定大小的窗口,比如1分钟。每个窗口内统计请求次数,超过阈值就拒绝。比如限制每分钟1000次,那就每分钟最多处理1000个请求,超过的全部拒绝。
滑动窗口比固定窗口更精确。固定窗口的缺点是:假设限制是每分钟1000次,12点00分到01分来了1000次,01分到02分又来了1000次,看起来都没超限,但12点59分到1点01分这个时间段其实有2000次请求穿透了。滑动窗口把窗口重叠起来计算,解决了这个问题。
熔断机制
熔断是保护系统的最后一道防线。当某个依赖服务比如支付接口或短信接口持续失败时,继续调用只会浪费资源还可能拖垮自己。熔断器会检测到这种异常,自动"跳闸"断开调用,快速失败返回降级结果而不是一直等待超时。
熔断器有三个状态。关闭状态是正常状态,调用都放行。打开状态是异常检测到问题,所有调用直接返回降级结果。半开状态是试探恢复,偶尔放一个请求过去试试,如果成功就关闭熔断恢复调用,如果失败就继续打开。
九、监控与告警
必须监控的核心指标
高并发系统必须时刻知道自己在干什么,监控就是系统的眼睛。
接口层面要监控:请求量和响应时间分布,区分快慢请求看是否需要优化。错误率统计,包括业务错误和技术错误,业务错误如库存不足是正常的,技术错误如数据库连接超时需要关注。
Redis层面要监控:内存使用量和已用比例,内存快满时要及时扩容或清理。连接数包括当前连接数和峰值连接数,连接数爆表会影响吞吐。Key数量和过期键统计,大量的过期键会触发清理影响性能。
MySQL层面要监控:慢查询数量和平均执行时间,超过1秒的查询需要优化。连接池使用率,连接不够用时需要扩容。读写分离延迟,从库落后主库太多时读数据可能不准。
业务层面要监控:订单成功率反映系统健康度,目标值99%以上。平均处理时长反映用户体验,目标值500毫秒以内。库存预警数量反映补货需求。
告警策略
不是所有问题都需要立刻处理,要分清优先级。
警告级别是提醒关注但不需要立即行动。比如Redis内存使用率超过70%,比如MySQL慢查询每分钟超过5个。收到这类告警后可以安排时间处理,不影响当前业务。
严重级别是需要尽快处理否则会影响业务。比如订单成功率低于95%,比如Redis内存使用率超过85%。这类告警需要立即查看原因并处理。
紧急级别是已经造成业务损失必须马上修复。比如支付接口完全不可用,比如数据库连接失败。比如大量订单超时失败。
十、扩容与容灾
水平扩容策略
当单台服务器扛不住流量时,需要扩容增加处理能力。
Redis集群可以水平扩容。数据量不大时可以用主从复制,一主多从分担读压力。数据量大时可以用Redis Cluster,把数据分片存储在多个节点上。扩容时只需要把部分槽迁移到新节点,业务无需停机。
MySQL扩容相对复杂。小规模时可以用主从复制读写分离,写操作走主库读操作走从库。中等规模可以用分库分表,按设备编号或者日期分到多个库。超大规模可能需要引入中间件或者分布式数据库。
应用服务扩容最简单,因为服务是无状态的,多部署几份实例后面挂负载均衡就行。
容灾设计
服务器可能会宕机,机房可能会断电,网络可能会中断。系统设计必须考虑这些极端情况。
Redis要开启AOF持久化,把每个写命令追加到文件。服务重启时从AOF恢复数据,最大限度减少数据丢失。重要数据还要按策略备份到云存储。
MySQL要配置主从复制。主库挂了可以从从库拉起继续服务。定期全量备份加增量备份,物理备份加逻辑备份,多重保障。
应用服务要多实例部署。任何一个实例挂了负载均衡自动把流量切到其他实例,用户无感知。
十一、总结
高并发售货机后台系统的设计,核心在于分层分流和读写分离。
分层体现在:接入层做防护,服务层做业务,数据层分Redis和MySQL各司其职,任务层做异步处理。
分流体现在:热点数据走Redis缓存写入走异步队列,MySQL只做持久化和复杂查询,把不同类型的负载分配到最合适的存储引擎。
读写分离体现在:写入请求优先保证速度,读取请求优先保证性能,用最终一致性换取高吞吐量。
关键设计点包括:Redis缓存热点数据扛住高频读取。Lua脚本原子扣减库存防止超卖。异步队列削峰填谷保护数据库。滑动窗口限流防止系统过载。熔断机制保护依赖服务。监控告警及时发现处理问题。
没有一种方案能解决所有问题,必须根据实际业务量、预算、人员能力选择最合适的技术组合。先扛住当前压力,再逐步优化扩展,保持架构的演进能力比一步到位更重要。