Redis 限流与计数器设计:零售 POS 系统优化
·
Redis 限流与计数器设计:零售 POS 系统优化
一、POS 场景的特殊挑战
零售 POS(Point of Sale)系统与常规互联网应用有本质差异:
| 维度 | 互联网电商 | 零售 POS |
|---|---|---|
| 并发特征 | 流量洪峰(秒杀/大促) | 持续高频 + 瞬时脉冲(扫码枪连扫) |
| 网络环境 | 稳定云环境 | 门店弱网、断网续传 |
| 数据敏感 | 允许短暂不一致 | 金额必须 100% 精准 |
| 硬件限制 | 服务器集群 | 老旧收银机、Android 平板 |
| 业务风险 | 超卖可退款 | 重复收款无法追回 |
核心痛点:
- 扫码枪连扫:收银员快速扫描商品,100ms 内可能触发 5-10 次请求
- 支付重试:POS 端网络抖动导致支付请求重复提交
- 离线同步:断网期间数据本地缓存,恢复后批量上传需防重复
- 金额精度:分位计算不能有任何累积误差
二、限流算法选型:为什么不用令牌桶?
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 场景重复提交的主要来源:
- 网络抖动:支付请求已发出,但响应丢失,POS 端自动重试
- 收银误操作:收银员误以为支付失败,手动点击"重新支付"
- 离线同步:断网期间缓存多笔交易,联网后批量上传
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 脚本保证中间状态的原子性 。
更多推荐



所有评论(0)