Redis 限流与计数器设计零售 POS 系统优化一、POS 场景的特殊挑战零售 POSPoint of Sale系统与常规互联网应用有本质差异维度互联网电商零售 POS并发特征流量洪峰秒杀/大促持续高频 瞬时脉冲扫码枪连扫网络环境稳定云环境门店弱网、断网续传数据敏感允许短暂不一致金额必须 100% 精准硬件限制服务器集群老旧收银机、Android 平板业务风险超卖可退款重复收款无法追回核心痛点扫码枪连扫收银员快速扫描商品100ms 内可能触发 5-10 次请求支付重试POS 端网络抖动导致支付请求重复提交离线同步断网期间数据本地缓存恢复后批量上传需防重复金额精度分位计算不能有任何累积误差二、限流算法选型为什么不用令牌桶2.1 四种算法对比算法突发支持精度内存占用POS 适用性固定窗口❌ 临界突刺低极低⚠️ 仅用于粗粒度保护滑动窗口❌ 无突发高中✅首选漏桶❌ 匀速输出中低❌ 不适合连扫场景令牌桶✅ 支持突发中低❌ 不适合 POS 精准控制POS 不选令牌桶的原因令牌桶允许突发流量但 POS 需要严格平滑不能让扫码枪 1 秒内突刺 50 次漏桶输出速率恒定会拖慢正常收银速度滑动窗口能精确控制任意时间段的请求数最适合收银节奏控制2.2 滑动窗口的 Redis 实现使用 RedisZSetSorted Set存储请求时间戳Score 和 Member 均为毫秒时间戳比 UUID 节省 70% 内存-- sliding_window.lua-- KEYS[1]: 限流key (如: rate:pos:store:001:terminal:003)-- ARGV[1]: 限制次数-- ARGV[2]: 窗口大小(秒)-- ARGV[3]: 当前时间戳(毫秒)localkeyKEYS[1]locallimittonumber(ARGV[1])localwindowtonumber(ARGV[2])*1000-- 转为毫秒localnowtonumber(ARGV[3])-- 1. 清理窗口外的旧数据O(logN)redis.call(ZREMRANGEBYSCORE,key,0,now-window)-- 2. 统计当前窗口内请求数localcountredis.call(ZCARD,key)ifcountlimitthen-- 3. 记录本次请求使用当前时间戳微秒级随机数防重复localmembernow..-..redis.call(INCR,key..:seq)redis.call(ZADD,key,now,member)-- 4. 设置过期时间窗口1秒冗余redis.call(EXPIRE,key,math.ceil(window/1000)1)-- 5. 返回剩余配额便于前端展示return{1,limit-count-1}else-- 返回拒绝标识和最早过期时间localoldestredis.call(ZRANGE,key,0,0,WITHSCORES)[2]localretryAftermath.ceil((oldestwindow-now)/1000)return{0,retryAfter}endJava 封装层ComponentpublicclassPosRateLimiter{AutowiredprivateStringRedisTemplateredisTemplate;privatestaticfinalRedisScriptListLongSLIDING_WINDOW_SCRIPTnewDefaultRedisScript(newClassPathResource(lua/sliding_window.lua),List.class);/** * POS 收银限流 * param storeId 门店ID * param terminalId 收银机ID * param action 动作类型(scan/pay/refund) * param limit 限制次数 * param windowSeconds 窗口大小(秒) */publicRateLimitResulttryAcquire(StringstoreId,StringterminalId,Stringaction,intlimit,intwindowSeconds){StringkeyString.format(rate:pos:%s:%s:%s,storeId,terminalId,action);longnowSystem.currentTimeMillis();ListLongresultredisTemplate.execute(SLIDING_WINDOW_SCRIPT,Collections.singletonList(key),String.valueOf(limit),String.valueOf(windowSeconds),String.valueOf(now));booleanallowedresult.get(0)1;longremainingresult.get(1);// 剩余配额低于 20% 时预警if(allowedremaininglimit*0.2){log.warn(POS限流预警: store{}, terminal{}, action{}, 剩余配额{},storeId,terminalId,action,remaining);}returnnewRateLimitResult(allowed,remaining,allowed?0:result.get(1));}}三、POS 专用限流策略设计3.1 分层限流架构┌─────────────────────────────────────────┐ │ 网关层 (Nginx) │ │ 粗限流: 1000 req/s per store │ ├─────────────────────────────────────────┤ │ 应用层 (Spring Gateway) │ │ 细限流: 100 req/s per terminal │ ├─────────────────────────────────────────┤ │ 业务层 (POS Service) │ │ 精准限流: 10 req/s per action │ │ scan: 20/s | pay: 5/s | refund: 3/s │ └─────────────────────────────────────────┘策略配置表动作限流阈值窗口业务原因scan扫码20次/秒1s扫码枪连扫 人工确认间隔pay支付5次/秒2s支付接口调用成本较高refund退款3次/秒5s资金安全风险必须严格限制sync离线同步50次/分钟60s批量上传防拥塞3.2 自适应限流应对促销高峰ServicepublicclassAdaptiveRateLimiter{AutowiredprivatePosRateLimiterrateLimiter;AutowiredprivateRedisTemplateString,StringredisTemplate;// 促销期间动态调整系数privatestaticfinalStringPROMO_COEFFICIENT_KEYconfig:rate:promo:coeff;publicRateLimitResulttryAcquireWithAdaptation(StringstoreId,StringterminalId,Stringaction,intbaseLimit){// 1. 获取当前促销系数默认1.0StringcoeffStrredisTemplate.opsForValue().get(PROMO_COEFFICIENT_KEY);doublecoeffcoeffStr!null?Double.parseDouble(coeffStr):1.0;// 2. 计算动态阈值促销期间放宽 50%intadjustedLimit(int)(baseLimit*coeff);// 3. 执行限流检查RateLimitResultresultrateLimiter.tryAcquire(storeId,terminalId,action,adjustedLimit,1);// 4. 记录限流指标用于监控if(!result.isAllowed()){Metrics.counter(pos.rate_limit.blocked,store,storeId,action,action).increment();}returnresult;}// 运营后台动态调整接口PostMapping(/admin/rate/adjust)publicvoidadjustRateLimit(RequestParamdoublecoefficient){redisTemplate.opsForValue().set(PROMO_COEFFICIENT_KEY,String.valueOf(coefficient),Duration.ofHours(2)// 2小时后自动恢复);}}四、精准计数器设计金额计算 0 误差4.1 为什么不用 INCRBYFLOATRedisINCRBYFLOAT使用 IEEE 754 double 精度存在浮点误差127.0.0.1:6379INCRBYFLOAT amount0.010.010000000000000000208POS 解决方案整数分存储1元 100分4.2 交易计数器架构Redis Key 设计: ├─ txn:daily:{storeId}:{yyyyMMdd} Hash {terminalId - 交易笔数} ├─ txn:amount:daily:{storeId}:{yyyyMMdd} Hash {terminalId - 交易金额(分)} ├─ txn:hourly:{storeId}:{yyyyMMddHH} Hash {terminalId - 交易笔数} ├─ txn:realtime:{storeId} String 当前门店实时流水号原子递增 └─ txn:terminal:{terminalId}:seq String 单收银机流水号防断网重号原子扣减库存 记录交易Lua 脚本-- pos_transaction.lua-- 保证库存扣减、金额累加、流水号生成三者原子性localstockKeyKEYS[1]-- 库存keylocaltxnCountKeyKEYS[2]-- 交易计数keylocaltxnAmountKeyKEYS[3]-- 交易金额keylocalseqKeyKEYS[4]-- 流水号keylocalproductIdARGV[1]localquantitytonumber(ARGV[2])localamountFentonumber(ARGV[3])-- 金额分localterminalIdARGV[4]-- 1. 检查并扣减库存使用 HINCRBY 原子操作localstockredis.call(HGET,stockKey,productId)ifnotstockortonumber(stock)quantitythenreturn{-1,库存不足}-- 错误码-1end-- 2. 扣减库存redis.call(HINCRBY,stockKey,productId,-quantity)-- 3. 生成全局唯一流水号时间戳自增localtimestampredis.call(TIME)[1]localseqredis.call(INCR,seqKey)localflowNotimestamp..string.format(%06d,seq%1000000)-- 4. 累加交易统计整数分零误差redis.call(HINCRBY,txnCountKey,terminalId,1)redis.call(HINCRBY,txnAmountKey,terminalId,amountFen)-- 5. 记录交易明细5分钟后过期用于对账缓冲localtxnDetailKeytxn:detail:..flowNo redis.call(HMSET,txnDetailKey,terminal,terminalId,product,productId,qty,quantity,amount,amountFen,time,timestamp)redis.call(EXPIRE,txnDetailKey,300)return{1,flowNo,redis.call(HGET,stockKey,productId)}Java 调用封装ServicepublicclassPosTransactionService{AutowiredprivateStringRedisTemplateredisTemplate;privatestaticfinalRedisScriptListObjectTXN_SCRIPTnewDefaultRedisScript(newClassPathResource(lua/pos_transaction.lua),List.class);/** * 执行交易原子性保证 * return 交易流水号 */publicStringexecuteTransaction(StringstoreId,StringterminalId,StringproductId,intquantity,BigDecimalamount){// 金额转为分彻底避免浮点误差longamountFenamount.movePointRight(2).setScale(0,RoundingMode.UNNECESSARY).longValue();StringtodayLocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);ListObjectresultredisTemplate.execute(TXN_SCRIPT,Arrays.asList(stock:store:storeId,txn:daily:storeId:today,txn:amount:daily:storeId:today,txn:realtime:storeId),productId,String.valueOf(quantity),String.valueOf(amountFen),terminalId);Longcode(Long)result.get(0);if(code-1){thrownewInsufficientStockException((String)result.get(1));}return(String)result.get(1);// 返回流水号}}五、幂等性设计防重复支付POS 场景重复提交的主要来源网络抖动支付请求已发出但响应丢失POS 端自动重试收银误操作收银员误以为支付失败手动点击重新支付离线同步断网期间缓存多笔交易联网后批量上传5.1 Token 机制 去重表双保险第一层客户端 Token防误操作RestControllerRequestMapping(/pos/pay)publicclassPosPaymentController{AutowiredprivateIdempotentTokenServicetokenService;AutowiredprivatePaymentServicepaymentService;// 1. 预生成支付 Token收银台初始化时获取GetMapping(/token)publicStringgenerateToken(RequestParamStringterminalId){returntokenService.generateToken(terminalId,Duration.ofMinutes(5));}// 2. 执行支付携带 TokenPostMapping(/execute)publicPaymentResultpay(RequestBodyPaymentRequestrequest){// 校验并消费 Token原子操作仅第一次有效if(!tokenService.checkAndConsumeToken(request.getTerminalId(),request.getToken())){thrownewDuplicateRequestException(该支付请求已处理请勿重复提交);}// 执行支付...returnpaymentService.process(request);}}第二层服务端去重表防网络重试-- 支付去重表唯一索引保证幂等CREATETABLEt_payment_idempotent(idempotent_keyVARCHAR(64)PRIMARYKEYCOMMENT幂等键: terminalId:flowNo,terminal_idVARCHAR(32)NOTNULL,flow_noVARCHAR(32)NOTNULL,amountDECIMAL(10,2)NOTNULL,statusTINYINTDEFAULT0COMMENT0-处理中 1-成功 2-失败,create_timeTIMESTAMPDEFAULTCURRENT_TIMESTAMP,UNIQUEKEYuk_flow(terminal_id,flow_no))ENGINEInnoDB;-- 插入即锁定利用唯一索引冲突防并发INSERTINTOt_payment_idempotent(idempotent_key,terminal_id,flow_no,amount)VALUES(?,?,?,?)ONDUPLICATEKEYUPDATEstatusIF(status0,status,status),-- 处理中状态不覆盖idLAST_INSERT_ID(id);-- 返回已存在记录的IDToken 服务 Redis 实现ServicepublicclassIdempotentTokenService{AutowiredprivateStringRedisTemplateredisTemplate;privatestaticfinalStringTOKEN_PREFIXpos:token:;/** * 生成预支付 Token */publicStringgenerateToken(StringterminalId,Durationttl){StringtokenUUID.randomUUID().toString().replace(-,);StringkeyTOKEN_PREFIXterminalId:token;// 使用 SET NX EX 原子操作redisTemplate.opsForValue().set(key,PENDING,// 状态: PENDING - PROCESSING - COMPLETEDttl);returntoken;}/** * 校验并消费 TokenLua 保证原子性 */publicbooleancheckAndConsumeToken(StringterminalId,Stringtoken){StringkeyTOKEN_PREFIXterminalId:token;Stringluaif redis.call(get, KEYS[1]) PENDING then redis.call(set, KEYS[1], PROCESSING) return 1 else return 0 end;LongresultredisTemplate.execute(newDefaultRedisScript(lua,Long.class),Collections.singletonList(key));returnresult!nullresult1;}/** * 完成支付后标记用于查询重复提交时的结果 */publicvoidcompleteToken(StringterminalId,Stringtoken,StringresultJson){StringkeyTOKEN_PREFIXterminalId:token;redisTemplate.opsForValue().set(key,COMPLETED:resultJson,Duration.ofMinutes(10)// 保留10分钟供查询);}}六、完整架构与部署建议6.1 部署拓扑门店网络 ├─ POS 终端 x N (Android/Windows) │ └─ 本地 SQLite (离线缓存) 断网队列 │ ├─ 门店路由器 │ └─ 本地 Redis 哨兵 (1主2从自动切换) │ └─ VPN/专线 ──────► 总部数据中心 └─ Redis Cluster (6主6从) ├─ 限流数据 (过期快) ├─ 计数器数据 (持久化) └─ 对账数据 (AOF每秒刷盘)6.2 关键配置参数# Redis 配置针对 POS 场景优化redis:# 限流数据使用 LRU 淘汰不重要maxmemory-policy:allkeys-lru# 计数器数据必须持久化appendonly:yesappendfsync:everysec# 每秒刷盘平衡性能与安全# 避免 OOM 导致限流失效maxmemory:2gb# 监控告警slowlog-log-slower-than:10000# 10ms 慢查询记录6.3 监控指标指标采集方式告警阈值限流拦截率Redis Keyspace Hits/Misses 5% 触发预警计数器延迟Lua 脚本执行时间 P99 20ms流水号连续性检查 sequence 跳号跳号 10库存一致性Redis vs MySQL 定时校验差异 0七、总结POS 限流设计 checklist限流算法选择滑动窗口拒绝令牌桶突发风险金额存储全部使用整数分禁止浮点运算原子操作库存 金额 流水号必须 Lua 脚本原子化幂等设计Token 预生成 去重表双保险降级策略Redis 故障时切换本地限流保守模式对账机制Redis 计数器与数据库每日对账校验这套方案核心思想是用 Redis 做高性能临时计算用关系型数据库做最终持久化用 Lua 脚本保证中间状态的原子性。