数据结构选型:list vs set vs dict 性能对比

做风控系统,说白了就是跟时间赛跑。每笔交易进来,你只有几十毫秒去判断它是不是有问题。这时候选错一个数据结构,可能就直接把系统拖垮了。

我记得刚接手一个风控项目时,发现一个规则引擎跑一次要200多毫秒。排查下来,问题出在一个简单的「用户是否在黑名单」判断上——用的竟然是list。换成set之后,直接降到2毫秒。嗯,100倍的差距,就是这么夸张。

list、set、dict 到底差在哪?

先看一张对比表,心里有个底:

操作 list set dict
查找元素 O(n) O(1) O(1)
插入元素 O(1) 尾部 / O(n) 中间 O(1) O(1)
删除元素 O(n) O(1) O(1)
内存占用
有序性 3.7+ 有序

你想想看,风控里最常见的操作是什么?「这个用户ID在不在黑名单里?」、「这个设备指纹出现过多少次?」——全是查找操作。这时候用list,就是拿大炮打蚊子,还打不准。

核心原则:需要频繁查找、去重、集合运算的场景,优先考虑set或dict。list只适合顺序遍历或小规模数据。

实战中的选择策略

我个人的习惯是这么分的:

  • 黑名单/白名单校验 → set。因为只需要判断「在不在」。
  • 用户画像缓存 → dict。key是用户ID,value是画像对象。
  • 规则链执行顺序 → list。因为规则有优先级,需要按序执行。
  • 设备指纹去重 → set。天然去重,省得你手动写if判断。

我曾经在一个风控网关里看到有人用list存了10万个IP黑名单,每次请求都遍历一遍。你猜怎么着?QPS一上来,CPU直接飙到90%。换成set之后,CPU降到15%。这就是选型失误的代价。

collections模块:风控场景的瑞士军刀

Python标准库里的collections模块,说实话,很多人低估了它的价值。我几乎每个风控项目都会用到它。

deque:双端队列,专治高频入队出队

风控系统里经常要维护一个「最近N次操作」的滑动窗口。比如记录用户最近10次登录的IP,或者最近100笔交易的时间戳。

用list做这个事?每次从头部插入数据,后面的元素都得往后挪,O(n)的复杂度。数据量一大就扛不住。

from collections import deque

# 维护最近100笔交易的时间戳
recent_trades = deque(maxlen=100)

def on_trade(trade_time):
    recent_trades.append(trade_time)
    # 自动淘汰最旧的数据
    # 判断是否在短时间内高频交易
    if len(recent_trades) == 100:
        time_span = recent_trades[-1] - recent_trades[0]
        if time_span < 60:  # 100笔交易在60秒内
            trigger_risk_alert()

deque的左右两端插入和删除都是O(1)。maxlen参数更是神器——队列满了自动从另一端弹出,省得你手动维护长度。

我的经验:在实时风控中,deque非常适合做「时间窗口计数器」。配合maxlen使用,代码简洁又高效。

defaultdict:再也不用写if key in dict了

风控里经常要统计各种聚合数据:每个用户的失败次数、每个IP的访问次数、每个卡号的尝试次数。用普通dict,你得先判断key存不存在,再决定是初始化还是累加。

defaultdict直接帮你省掉这一步:

from collections import defaultdict

# 统计每个IP的失败登录次数
fail_counts = defaultdict(int)

def record_failed_login(ip):
    fail_counts[ip] += 1
    if fail_counts[ip] > 5:
        block_ip(ip)

# 统计每个用户的设备列表
user_devices = defaultdict(set)

def record_user_device(user_id, device_id):
    user_devices[user_id].add(device_id)
    if len(user_devices[user_id]) > 3:
        mark_as_risk(user_id)

你看,代码是不是清爽多了?defaultdict(int)自动初始化为0,defaultdict(set)自动初始化为空集合。我曾经在一个项目里重构了200多行if判断,用defaultdict缩到80行,而且性能还提升了。

Counter:一行代码搞定频率统计

「这个IP在最近5分钟内出现了多少次?」、「哪个错误码出现得最频繁?」——Counter就是为这种场景量身定做的。

from collections import Counter

# 统计最近1000次请求的错误码分布
error_counter = Counter()

def on_request(error_code):
    error_counter[error_code] += 1
    # 获取最常见的3个错误码
    top_3 = error_counter.most_common(3)
    # 如果某个错误码占比过高,触发告警
    total = sum(error_counter.values())
    for code, count in top_3:
        if count / total > 0.5:
            alert(f"错误码 {code} 占比异常: {count/total:.1%}")

most_common方法直接返回排序后的结果,省得你自己写排序逻辑。而且Counter底层也是哈希表,性能完全在线。

注意:Counter的most_common在数据量极大时(百万级以上)会有性能开销,因为内部做了排序。如果只需要top N,可以考虑用heapq替代。

数组 vs 哈希表:什么时候该用哪个?

这个问题其实没那么复杂。我总结了一个简单的判断标准:

  • 用数组(list)的场景:数据量小(几百以内)、需要顺序访问、内存敏感、需要索引定位。
  • 用哈希表(set/dict)的场景:数据量大、需要快速查找、需要去重、需要关联关系。

但有一个坑,我必须要提醒你:哈希表的内存开销比数组大得多。一个dict对象,光是存储结构本身就要占用不少内存。如果你存的是几百万个整数,用list可能只要几十MB,用dict可能直接飙到几百MB。

我曾经在内存受限的容器环境里部署风控服务,就因为用了dict存黑名单,结果OOM了。后来换成bloom filter(布隆过滤器),内存降了90%,虽然有一点点误判率,但在风控场景里完全可接受。

避坑指南:我曾经在内存受限的容器环境里部署风控服务,就因为用了dict存黑名单,结果OOM了。后来换成bloom filter,内存降了90%。

知识体系总览

下面这张图,帮你把本章的核心逻辑串起来:

数据结构选型决策树 数据结构选型 需要快速查找? 需要顺序访问? 仅判断存在? 需要关联值? set dict 数据量小? list 高频操作:查找用set/dict,遍历用list,滑动窗口用deque,频率统计用Counter

嗯,这张图基本把选型逻辑讲清楚了。你只要记住:先问自己「我要做什么操作」,再选数据结构,而不是反过来。

最后说一句,数据结构选型没有银弹。我见过有人为了追求极致性能,把所有list都换成set,结果内存爆了。也见过有人死守list,结果查找慢得离谱。关键还是看场景——数据量多大?读写比例多少?内存够不够?想清楚这些,选型自然就对了。