1. 风控场景下的频次计算挑战
在电商和金融领域,风控系统每天需要处理数以亿计的用户行为事件。我曾参与过一个电商平台的实时风控系统改造,高峰期每秒需要处理超过50万次用户行为判断。其中最关键的就是频次计算服务——它需要在毫秒级别回答"这个用户在过去5分钟是否已经发送了超过3次验证码?"这类问题。
传统的关系型数据库在这种场景下完全无法胜任。我们最初尝试用MySQL计数器实现,结果在流量高峰时出现了严重的性能瓶颈,查询延迟从20ms飙升到2秒以上,直接导致风控规则失效。这促使我们转向了基于内存的分布式计算方案。
2. 超高并发架构设计核心要点
2.1 数据分片策略
我们采用了一致性哈希算法将用户ID分散到128个分片上。每个分片由独立的Redis实例处理,这样单个用户的所有请求都会路由到同一个节点。这种设计带来了两个关键优势:
- 避免了跨节点的事务操作
- 天然支持了数据局部性,提高了缓存命中率
实际部署时,我们为每个分片配置了主从复制。测试数据显示,在16核32G的服务器上,单个Redis节点可以稳定处理3.5万QPS的INCR操作,平均延迟控制在1.2ms以内。
2.2 时间窗口实现方案
频次计算的核心是滑动时间窗口的实现。我们对比了三种方案:
| 方案 | 实现方式 | 内存占用 | 计算复杂度 | 适用场景 |
|---|---|---|---|---|
| 有序集合 | ZADD+ZREMRANGEBYSCORE | 高 | O(logN) | 精确计数 |
| 令牌桶 | INCR+EXPIRE | 低 | O(1) | 宽松限制 |
| 布隆过滤器 | BF.ADD | 最低 | O(k) | 存在性判断 |
最终选择了混合方案:对关键业务(如支付验证)使用有序集合保证精确性,对次要场景(如活动参与)采用令牌桶降低开销。例如防刷规则可以这样实现:
public boolean checkFrequency(String userId, String action, int maxCount, Duration window) { String key = "freq:" + userId + ":" + action; long now = System.currentTimeMillis(); long windowMs = window.toMillis(); // Redis原子操作 return redisTemplate.execute(new RedisCallback<Boolean>() { @Override public Boolean doInRedis(RedisConnection connection) { connection.zAdd(key.getBytes(), now, String.valueOf(now).getBytes()); connection.zRemRangeByScore(key.getBytes(), 0, now - windowMs); connection.expire(key.getBytes(), window.getSeconds()); return connection.zCard(key.getBytes()) <= maxCount; } }); }3. Java工程化实践
3.1 热点key处理
在618大促期间,我们发现某些热门商品详情页的访问计数器成为了热点key。通过Arthas工具追踪,确认单个key的QPS峰值达到了8万次/秒。解决方案是采用本地缓存+随机过期的组合策略:
- 在Java应用层使用Caffeine做一级缓存
- 设置5-10秒的随机过期时间避免集中失效
- 最终一致性通过Redis的PUB/SUB保证
关键代码实现:
LoadingCache<String, AtomicLong> localCache = Caffeine.newBuilder() .expireAfterWrite(5 + ThreadLocalRandom.current().nextInt(5), TimeUnit.SECONDS) .build(key -> { Long value = redisTemplate.opsForValue().get(key); return new AtomicLong(value != null ? value : 0); }); public long incrementWithLocalCache(String key) { AtomicLong counter = localCache.get(key); long newValue = counter.incrementAndGet(); // 异步更新Redis executor.submit(() -> { redisTemplate.opsForValue().increment(key); }); return newValue; }3.2 JVM调优经验
由于需要处理大量短生命周期的对象,我们调整了JVM参数:
- 使用G1垃圾收集器替代CMS
- 设置MaxGCPauseMillis为50ms
- 新生代比例调整到40%
- 禁用显式GC调用(-XX:+DisableExplicitGC)
这些调整使得GC停顿时间从原来的200ms降低到30ms以内,YGC频率从每分钟15次降到5次左右。
4. Redis高级特性应用
4.1 Lua脚本优化
对于复杂的计数规则,我们使用Lua脚本保证原子性。例如实现"最近N次事件中至少有M次符合条件"的规则:
local key = KEYS[1] local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local threshold = tonumber(ARGV[3]) redis.call('ZREMRANGEBYSCORE', key, 0, now - window) local count = redis.call('ZCARD', key) redis.call('ZADD', key, now, now) redis.call('EXPIRE', key, window) return count >= threshold and 1 or 0测试表明,相比多次网络往返的客户端实现,Lua脚本可以将延迟降低60%以上。
4.2 内存优化技巧
我们发现原始的Redis数据结构占用了过多内存。通过以下优化节省了70%内存:
- 使用Hash类型替代String存储计数器集合
- 对数值型value采用二进制编码
- 启用ziplist压缩配置:
hash-max-ziplist-entries 512 hash-max-ziplist-value 645. 监控与降级方案
5.1 指标埋点体系
我们建立了完整的监控指标:
- 每个计数器的95线/99线延迟
- Redis节点的内存/CPU/网络使用率
- 分片负载均衡情况
- 异常请求比例
通过Prometheus+Grafana实现可视化,并设置多级告警阈值。当Redis延迟超过10ms时触发初级告警,超过50ms时自动启用本地降级模式。
5.2 熔断降级策略
在极端情况下,系统需要具备优雅降级能力。我们的策略包括:
- 静态规则兜底:当Redis不可用时,启用预先配置的宽松规则
- 本地计数模式:在应用内存中维护关键计数器
- 请求采样:当负载过高时,只对部分请求进行完整检查
降级决策通过配置中心动态下发,可以在30秒内完成全集群切换。实际演练表明,降级状态下系统可以维持80%的核心风控能力。