Redis 限流与计数器设计:零售 POS 系统优化

一、POS 场景的特殊挑战

零售 POS(Point of Sale)系统与常规互联网应用有本质差异:

维度 互联网电商 零售 POS
并发特征 流量洪峰(秒杀/大促) 持续高频 + 瞬时脉冲(扫码枪连扫)
网络环境 稳定云环境 门店弱网、断网续传
数据敏感 允许短暂不一致 金额必须 100% 精准
硬件限制 服务器集群 老旧收银机、Android 平板
业务风险 超卖可退款 重复收款无法追回

核心痛点

  1. 扫码枪连扫:收银员快速扫描商品,100ms 内可能触发 5-10 次请求
  2. 支付重试:POS 端网络抖动导致支付请求重复提交
  3. 离线同步:断网期间数据本地缓存,恢复后批量上传需防重复
  4. 金额精度:分位计算不能有任何累积误差

二、限流算法选型:为什么不用令牌桶?

2.1 四种算法对比

算法 突发支持 精度 内存占用 POS 适用性
固定窗口 ❌ 临界突刺 极低 ⚠️ 仅用于粗粒度保护
滑动窗口 ❌ 无突发 首选
漏桶 ❌ 匀速输出 ❌ 不适合连扫场景
令牌桶 ✅ 支持突发 ❌ 不适合 POS 精准控制

POS 不选令牌桶的原因

  • 令牌桶允许突发流量,但 POS 需要严格平滑(不能让扫码枪 1 秒内突刺 50 次)
  • 漏桶输出速率恒定,会拖慢正常收银速度
  • 滑动窗口能精确控制任意时间段的请求数,最适合收银节奏控制

2.2 滑动窗口的 Redis 实现

使用 Redis ZSet(Sorted Set) 存储请求时间戳,Score 和 Member 均为毫秒时间戳(比 UUID 节省 70% 内存):

-- sliding_window.lua
-- KEYS[1]: 限流key (如: rate:pos:store:001:terminal:003)
-- ARGV[1]: 限制次数
-- ARGV[2]: 窗口大小(秒)
-- ARGV[3]: 当前时间戳(毫秒)

local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2]) * 1000  -- 转为毫秒
local now = tonumber(ARGV[3])

-- 1. 清理窗口外的旧数据(O(logN))
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 2. 统计当前窗口内请求数
local count = redis.call('ZCARD', key)

if count < limit then
    -- 3. 记录本次请求(使用当前时间戳+微秒级随机数防重复)
    local member = now .. '-' .. 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
    -- 返回拒绝标识和最早过期时间
    local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')[2]
    local retryAfter = math.ceil((oldest + window - now) / 1000)
    return {0, retryAfter}
end

Java 封装层

@Component
public class PosRateLimiter {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final RedisScript<List<Long>> SLIDING_WINDOW_SCRIPT = 
        new DefaultRedisScript<>(
            new ClassPathResource("lua/sliding_window.lua"), 
            List.class
        );
    
    /**
     * POS 收银限流
     * @param storeId 门店ID
     * @param terminalId 收银机ID
     * @param action 动作类型(scan/pay/refund)
     * @param limit 限制次数
     * @param windowSeconds 窗口大小(秒)
     */
    public RateLimitResult tryAcquire(String storeId, String terminalId, 
                                      String action, int limit, int windowSeconds) {
        String key = String.format("rate:pos:%s:%s:%s", storeId, terminalId, action);
        long now = System.currentTimeMillis();
        
        List<Long> result = redisTemplate.execute(
            SLIDING_WINDOW_SCRIPT,
            Collections.singletonList(key),
            String.valueOf(limit),
            String.valueOf(windowSeconds),
            String.valueOf(now)
        );
        
        boolean allowed = result.get(0) == 1;
        long remaining = result.get(1);
        
        // 剩余配额低于 20% 时预警
        if (allowed && remaining < limit * 0.2) {
            log.warn("POS限流预警: store={}, terminal={}, action={}, 剩余配额={}", 
                    storeId, terminalId, action, remaining);
        }
        
        return new RateLimitResult(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 自适应限流(应对促销高峰)

@Service
public class AdaptiveRateLimiter {
    
    @Autowired
    private PosRateLimiter rateLimiter;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    // 促销期间动态调整系数
    private static final String PROMO_COEFFICIENT_KEY = "config:rate:promo:coeff";
    
    public RateLimitResult tryAcquireWithAdaptation(String storeId, String terminalId, 
                                                    String action, int baseLimit) {
        // 1. 获取当前促销系数(默认1.0)
        String coeffStr = redisTemplate.opsForValue().get(PROMO_COEFFICIENT_KEY);
        double coeff = coeffStr != null ? Double.parseDouble(coeffStr) : 1.0;
        
        // 2. 计算动态阈值(促销期间放宽 50%)
        int adjustedLimit = (int) (baseLimit * coeff);
        
        // 3. 执行限流检查
        RateLimitResult result = rateLimiter.tryAcquire(
            storeId, terminalId, action, adjustedLimit, 1
        );
        
        // 4. 记录限流指标用于监控
        if (!result.isAllowed()) {
            Metrics.counter("pos.rate_limit.blocked", 
                "store", storeId, 
                "action", action
            ).increment();
        }
        
        return result;
    }
    
    // 运营后台动态调整接口
    @PostMapping("/admin/rate/adjust")
    public void adjustRateLimit(@RequestParam double coefficient) {
        redisTemplate.opsForValue().set(
            PROMO_COEFFICIENT_KEY, 
            String.valueOf(coefficient),
            Duration.ofHours(2) // 2小时后自动恢复
        );
    }
}

四、精准计数器设计:金额计算 0 误差

4.1 为什么不用 INCRBYFLOAT?

Redis INCRBYFLOAT 使用 IEEE 754 double 精度,存在浮点误差:

127.0.0.1:6379> INCRBYFLOAT amount 0.01
"0.010000000000000000208"

POS 解决方案整数分存储(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
-- 保证库存扣减、金额累加、流水号生成三者原子性

local stockKey = KEYS[1]        -- 库存key
local txnCountKey = KEYS[2]     -- 交易计数key
local txnAmountKey = KEYS[3]    -- 交易金额key
local seqKey = KEYS[4]          -- 流水号key

local productId = ARGV[1]
local quantity = tonumber(ARGV[2])
local amountFen = tonumber(ARGV[3])  -- 金额(分)
local terminalId = ARGV[4]

-- 1. 检查并扣减库存(使用 HINCRBY 原子操作)
local stock = redis.call('HGET', stockKey, productId)
if not stock or tonumber(stock) < quantity then
    return {-1, "库存不足"}  -- 错误码-1
end

-- 2. 扣减库存
redis.call('HINCRBY', stockKey, productId, -quantity)

-- 3. 生成全局唯一流水号(时间戳+自增)
local timestamp = redis.call('TIME')[1]
local seq = redis.call('INCR', seqKey)
local flowNo = timestamp .. string.format("%06d", seq % 1000000)

-- 4. 累加交易统计(整数分,零误差)
redis.call('HINCRBY', txnCountKey, terminalId, 1)
redis.call('HINCRBY', txnAmountKey, terminalId, amountFen)

-- 5. 记录交易明细(5分钟后过期,用于对账缓冲)
local txnDetailKey = "txn: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 调用封装

@Service
public class PosTransactionService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final RedisScript<List<Object>> TXN_SCRIPT = 
        new DefaultRedisScript<>(
            new ClassPathResource("lua/pos_transaction.lua"),
            List.class
        );
    
    /**
     * 执行交易(原子性保证)
     * @return 交易流水号
     */
    public String executeTransaction(String storeId, String terminalId,
                                   String productId, int quantity, BigDecimal amount) {
        // 金额转为分(彻底避免浮点误差)
        long amountFen = amount.movePointRight(2).setScale(0, RoundingMode.UNNECESSARY).longValue();
        
        String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
        
        List<Object> result = redisTemplate.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
        );
        
        Long code = (Long) result.get(0);
        if (code == -1) {
            throw new InsufficientStockException((String) result.get(1));
        }
        
        return (String) result.get(1);  // 返回流水号
    }
}

五、幂等性设计:防重复支付

POS 场景重复提交的主要来源:

  1. 网络抖动:支付请求已发出,但响应丢失,POS 端自动重试
  2. 收银误操作:收银员误以为支付失败,手动点击"重新支付"
  3. 离线同步:断网期间缓存多笔交易,联网后批量上传

5.1 Token 机制 + 去重表双保险

第一层:客户端 Token(防误操作)

@RestController
@RequestMapping("/pos/pay")
public class PosPaymentController {
    
    @Autowired
    private IdempotentTokenService tokenService;
    
    @Autowired
    private PaymentService paymentService;
    
    // 1. 预生成支付 Token(收银台初始化时获取)
    @GetMapping("/token")
    public String generateToken(@RequestParam String terminalId) {
        return tokenService.generateToken(terminalId, Duration.ofMinutes(5));
    }
    
    // 2. 执行支付(携带 Token)
    @PostMapping("/execute")
    public PaymentResult pay(@RequestBody PaymentRequest request) {
        // 校验并消费 Token(原子操作,仅第一次有效)
        if (!tokenService.checkAndConsumeToken(request.getTerminalId(), request.getToken())) {
            throw new DuplicateRequestException("该支付请求已处理,请勿重复提交");
        }
        
        // 执行支付...
        return paymentService.process(request);
    }
}

第二层:服务端去重表(防网络重试)

-- 支付去重表(唯一索引保证幂等)
CREATE TABLE t_payment_idempotent (
    idempotent_key VARCHAR(64) PRIMARY KEY COMMENT '幂等键: terminalId:flowNo',
    terminal_id VARCHAR(32) NOT NULL,
    flow_no VARCHAR(32) NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    status TINYINT DEFAULT 0 COMMENT '0-处理中 1-成功 2-失败',
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_flow (terminal_id, flow_no)
) ENGINE=InnoDB;

-- 插入即锁定(利用唯一索引冲突防并发)
INSERT INTO t_payment_idempotent 
(idempotent_key, terminal_id, flow_no, amount) 
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE 
    status = IF(status = 0, status, status),  -- 处理中状态不覆盖
    id = LAST_INSERT_ID(id);  -- 返回已存在记录的ID

Token 服务 Redis 实现

@Service
public class IdempotentTokenService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final String TOKEN_PREFIX = "pos:token:";
    
    /**
     * 生成预支付 Token
     */
    public String generateToken(String terminalId, Duration ttl) {
        String token = UUID.randomUUID().toString().replace("-", "");
        String key = TOKEN_PREFIX + terminalId + ":" + token;
        
        // 使用 SET NX EX 原子操作
        redisTemplate.opsForValue().set(
            key, 
            "PENDING",  // 状态: PENDING -> PROCESSING -> COMPLETED
            ttl
        );
        return token;
    }
    
    /**
     * 校验并消费 Token(Lua 保证原子性)
     */
    public boolean checkAndConsumeToken(String terminalId, String token) {
        String key = TOKEN_PREFIX + terminalId + ":" + token;
        
        String lua = 
            "if redis.call('get', KEYS[1]) == 'PENDING' then " +
            "    redis.call('set', KEYS[1], 'PROCESSING') " +
            "    return 1 " +
            "else " +
            "    return 0 " +
            "end";
        
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(lua, Long.class),
            Collections.singletonList(key)
        );
        
        return result != null && result == 1;
    }
    
    /**
     * 完成支付后标记(用于查询重复提交时的结果)
     */
    public void completeToken(String terminalId, String token, String resultJson) {
        String key = TOKEN_PREFIX + terminalId + ":" + 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: yes
  appendfsync: 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 脚本保证中间状态的原子性

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐