技术探索与实践的一些经验:从一个“小问题”到系统性能优化的思考
开篇:为什么要写这篇文章?

作为一位从事软件开发工作五年的工程师,我经历了从刚入门时的迷茫到如今能独立承担项目架构、技术选型、性能调优等全流程工作的成长过程。这些年中,我参与过多个中大型系统的构建和重构,也踩了不少坑、吃了很多亏。今天想借这个机会,和大家分享我在技术探索与实践中的一些真实经历和体会。
这些经验不是来自于某个教科书式的理论,而是来源于一个个具体的业务场景、一次次深夜的调试、一次次失败后的复盘。我相信,技术的本质是服务于业务,而真正的技术能力,也恰恰是在解决实际问题中不断积累出来的。
一、一次小改动带来的大麻烦:项目背景与挑战

1.1 项目背景
我们团队负责维护一套面向金融行业的风险控制系统(以下简称风控系统)。该系统的核心逻辑是接收来自前端业务系统的交易请求,通过一系列规则引擎对交易行为进行实时判定,并返回是否允许交易继续执行。整套系统采用微服务架构,基于Spring Boot + Spring Cloud + Kafka + Redis搭建,整体链路需要在200ms内完成响应。
随着业务发展,风控系统的日均处理量已经达到上百万次/天,QPS高峰期可达到每秒万级请求。为了支撑这样的高并发场景,我们在多个层面上做了优化,包括数据库读写分离、Redis缓存加速、异步化处理、限流降级机制等。
但就在一次看似简单的功能迭代中,整个系统却出现了严重的性能问题。
1.2 遇到的问题
那次需求是要在原有的风控规则中新增一条规则:判断用户最近30天内的高频交易行为是否超过一定阈值。开发同学很快完成了实现:引入了一个新的Redis结构来记录用户的交易流水ID,使用Redis的List结构保存最近N条交易记录,通过Lua脚本原子性地更新并查询时间范围。
上线后,我们并没有做太严格的压测(当时觉得只是加了一条规则而已),也没有发现任何明显异常。但过了几天后,监控告警突然大量出现,Kafka积压严重,部分机器的CPU飙到了95%以上,响应时间普遍延长到了3秒甚至更久。
一开始我们以为是外部依赖问题,比如数据库或者网络抖动,但排查一圈之后发现,问题出在Redis这一侧。进一步分析慢查询日志,发现问题就出在那条新加入的Redis List操作脚本上!
二、技术方案与实现思路:从定位问题到逐步优化

2.1 问题分析
我们首先抓取了Redis的慢查询日志:
127.0.0.1:6379> SLOWLOG GET 10
1) 1) (integer) 2231
2) (integer) 1680001234
3) (integer) 50000
4) 1) "EVAL"
2) "local key = KEYS[1]; local now = tonumber(ARGV[1]); ...很长的一段Lua脚本..."
3) "trade_history_list:user_12345"
4) "1717020000"

这条Eval语句竟然耗时超过了50毫秒!虽然单次请求可能影响不大,但在每秒万级请求下,Redis逐渐成为瓶颈,导致整个链路延时飙升。
我们还注意到,很多请求都在等待Redis的响应,形成了恶性循环。
2.2 初步尝试:更换数据结构?
既然List结构的访问效率不高,我们开始思考是否可以通过其他数据结构来优化存储方式。于是我们考虑将交易记录改为Sorted Set,用时间戳作为score。这样每次插入的时候只需要add,查询的时候可以通过ZRANGEBYSCORE来进行范围查询。
调整后的Lua脚本大致如下:
-- 插入交易ID,score为当前时间戳
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
-- 删除早于指定时间的数据
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[3])
-- 获取剩余数量
local count = redis.call('ZCARD', KEYS[1])
return count
这种方案看起来更高效,因为ZADD和ZREMRANGEBYSCORE都是O(log N)的操作,理论上应该比List快很多。
但是上线后却发现:CPU反而更高了?!
原来是因为 ZREMRANGEBYSCORE 在某些情况下会触发大量删除操作,尤其是在大量冷数据的情况下,导致Redis主线程卡顿。而且我们的TTL设置是30天,所以每天都有大量历史数据被清理,这正好造成了这个问题。
2.3 第二次尝试:延迟清理 + 拆分存储粒度
为了解决这个问题,我们又做了两个层面的调整:
(1)延迟清理策略
我们将每日清理改为异步任务处理:使用后台定时任务定期扫描每个用户的交易记录集合,清理超出30天的历史数据。这样可以避免频繁的ZREMRANGEBYSCORE操作。
同时,为了避免任务本身对系统造成压力,我们将其拆分成多个批次,结合消息队列消费机制:
- 使用Kafka广播事件通知清理任务
- 每个清理节点只处理一部分用户ID
- 清理过程中限制每次扫描的数量
(2)拆分存储粒度
我们发现有些用户的交易频率极高,有的用户一天内交易上千笔。而如果将这些记录都保存在一个sorted set里,即使有了延迟清理,仍然存在单点热点的风险。
于是我们决定将按用户ID存储的方式改为按时间段分桶,比如每小时或每天一个bucket:
trade_history_set:{user_id}:{YYYYMMDDHH} => 记录该小时内的交易ID
查询时,遍历近30天内的各个时间段对应的key,进行合并统计:
local total = 0
for i = 0, 30 do
local key = string.format(KEYS[1], os.date("%Y%m%d", now - i * 86400))
total = total + redis.call('ZCOUNT', key, startTime, endTime)
end
return total
这种方法虽然增加了key的数量,但也有效降低了单key的数据量,同时也减少了每次清理的成本。
三、最终效果与收益:不止性能提升了,系统也更健壮了

经过上述几个版本的优化,我们的系统响应时间从原来的平均3s下降到了80ms以内,CPU负载恢复到了正常水平,Redis再也没有出现慢查询的问题。
更重要的是,这次改造让我们意识到几个关键点:
- 即使是小小的改动也可能带来系统级的影响;
- 不要低估高并发场景下的底层组件负载;
- 数据结构的选择必须匹配实际业务模式和访问特性;
- 异步化和分片设计,是高可用系统的基石。
这次经验也在后续的多个项目中发挥了作用,比如我们在另一个画像系统中采用了类似的分桶+延迟清理策略,极大地提升了系统的稳定性。
四、几点经验和建议:给同行朋友的一些建议
4.1 多看监控,多埋点,别靠猜
在没有足够监控的前提下做性能优化,就像蒙着眼睛开车。我建议大家无论做什么项目,都要优先做好监控埋点:接口响应时间、SQL执行耗时、缓存命中率、线程阻塞情况、GC信息等等。
我们当时要是有完善的Redis命令耗时采集机制,就能更早发现问题所在。
4.2 不要迷信某种技术或数据结构
Redis的List看似很简单,但在大数据量下性能并不理想;Sorted Set虽然支持范围查询,但如果使用不当也会反噬。技术选型要根据具体场景去做决策,而不是人云亦云。
比如后来我们在另一个模块中选择了HyperLogLog来统计唯一访问量,既节省内存又满足精度要求,这就是根据业务特点做的合理选择。
4.3 性能优化是一个长期的过程,不是改完就完了
很多同学做完一次优化就认为万事大吉了,其实不然。系统是不断演化的,业务需求在变,流量增长在变,环境配置也可能在变。我们需要持续关注监控指标、做定期回归测试、设置预警机制。
我们现在的做法是每月做一次系统健康检查,重点关注核心服务的延迟波动、资源利用率变化等趋势。
4.4 小步快跑,灰度发布很关键
那次上线我们之所以没及时发现问题,就是因为它是一个看似微不足道的小改动。所以我们后来制定了严格的灰度发布流程:
- 先发灰度环境验证
- 再发内部少数用户组试用
- 监控稳定后再全量上线
这样哪怕出了问题也能快速回滚,不至于影响生产。
五、总结与展望:技术的成长在于不断地探索与总结
回顾这次经历,我深刻体会到技术的价值在于解决实际问题,而不是追求某种“高大上”的技术名词。很多时候,真正考验我们能力的,不是能否写出漂亮的代码,而是能否在高压、高并发、资源有限的环境下做出合理的设计与权衡。
如今,在AI、AIGC、Serverless等新技术层出不穷的背景下,我们更要保持对底层原理的理解和对业务场景的敏感。技术探索永远在路上,希望这篇分享能给正在一线奋斗的你一点启发。
如果你也有类似的经历,欢迎留言交流。愿我们在技术的道路上越走越远,越走越稳。
📌 附:一些参考资料
- Redis 官方文档
- 《高可用系统设计》——李智慧 著
- 《大规模分布式存储系统》——杨传辉 著
- 《程序员的自我修养》——俞甲子 等著
🔚

评论 0