10亿用户权限标记内存炸了?Python从位运算到混合布尔数组的踩坑实录

「部分情节为虚构演绎,仅供参考」

做后端的同行都知道,权限系统里最经典的设计就是位掩码(bitmask)。每个权限占一个bit:第0位是读、第1位是写、第2位是删除、第3位是管理员……一个32位整数就能存32个权限。Linux文件权限就是这么干的(rwx = 111 = 7)。

READ = 1 << 0
WRITE = 1 << 1
DELETE = 1 << 2
ADMIN = 1 << 3

def has_permission(perm, bit):
    return bool(perm & bit)

def grant(perm, bit):
    return perm | bit

小规模的时候,一个用户一个int,存数据库里一个字段,完美。然后我们平台用户量涨到了10亿。产品说要做一个「全平台权限扫描」——找出所有有删除权限但没有管理员权限的用户,给他们发安全提醒。这就需要把10亿用户的权限位全加载到内存里,然后批量做按位与运算。一个int4字节,10亿用户就是4GB。但我们需要存的不是一个权限组合,而是每个权限位一个布尔数组——比如「谁有删除权限」就是一个10亿位的布尔数组。「权限扫描」变成了「权限炸内存」

各种存法,10亿规模全翻车

方案一:dict 存用户权限

permissions = {}
permissions[user_id] = READ | WRITE | DELETE

dict的开销是出了名的大。10亿个key-value对,每个entry约100字节(哈希值+指针+int对象),总内存轻松超过10GB。而且dict的 in 和遍历在10亿规模下慢得令人发指。

方案二:list[int] 位掩码

perms = [0] * 1_000_000_000
perms[user_id] |= DELETE

list存10亿个int,每个int在Python里是PyObject(28字节),加上list的8字节指针,每个元素36字节,10亿个就是36GB。直接OOM。

方案三:array('I') 紧凑存int

from array import array
perms = array('I', [0]) * 1_000_000_000  # 32位无符号int,4字节/元素

4字节/元素,10亿个就是4GB。比list省多了,但4GB对于内存数据库来说还是太大——我们要同时存多个权限维度的数组。而且array模块的按位运算不支持向量化,你得写Python for循环遍历10亿个元素,速度感人。

方案四:list[bool] 每个权限位数组

换个思路:每个权限位单独一个布尔数组。比如「删除权限」就是一个10亿布尔数组:

can_delete = [False] * 1_000_000_000
can_delete[user_id] = True

list[bool] 10亿个元素,每个8字节指针,就是8GB。如果有8个权限位,就是64GB。不可能。

方案五:bytearray 每权限位1字节

can_delete = bytearray(1_000_000_000)  # 1字节/用户,10亿字节约930MB

930MB一个权限位数组,8个权限位就是7.4GB。还是太大。

方案六:numpy 布尔数组

import numpy as np
can_delete = np.zeros(1_000_000_000, dtype=np.bool_)  # 930MB

# 向量化运算,找出有删除权限的用户
delete_users = np.where(can_delete)[0]

numpy的向量化运算快,930MB一个数组。但8个权限位就是7.4GB,而且numpy数组定长不支持动态扩展。更关键的是:大部分普通用户只有读权限,删除/管理员权限的用户极少(稀疏),numpy不管稀疏密集都930MB,纯浪费。

方案七:自己手搓位运算,1bit/用户

def set_bit(arr, i):
    arr[i >> 5] |= (1 << (i & 31))

def get_bit(arr, i):
    return (arr[i >> 5] >> (i & 31)) & 1

1bit/用户,10亿个就是约116MB一个权限位数组。8个权限位不到1GB。但写起来极其痛苦:

  • 位运算优先级坑多,&| 容易和比较运算符搞混;
  • 批量按位与(找同时有A权限和B权限的用户)要自己写循环遍历每个字;
  • 向量化没了,速度比numpy慢一个数量级;
  • 代码可读性极差。

我搓了半天,跑出来结果对了,但批量查询速度比numpy慢10倍。

小结

方案 10亿用户单权限内存 向量化运算 动态扩展 稀疏优化
dict ~10GB 支持
list[int] ~36GB
array('I') ~4GB
list[bool] ~8GB
bytearray ~930MB
numpy ~930MB
手搓位运算 ~116MB
bitarray ~116MB ⚠️ ⚠️

从36GB到116MB,内存在降,但始终有个问题:大部分权限位是稀疏的(只有少数用户有高级权限),但所有方案都为每个用户分配固定空间。

破局思路:权限位为什么不能「看菜下饭」

内存墙:省内存不是为了省钱,是为了快

做后端的同学习惯把内存当「资源」看——内存不够就加机器。但在高性能场景下,内存和速度是绑定的。CPU缓存层级:L1(32KB,~1ns)→ L2(256KB,~4ns)→ L3(几十MB,~12ns)→ 主存(~100ns)→ 磁盘(~10μs)。每差一级,速度差10倍甚至1000倍。930MB的numpy数组放不进L3,CPU每次查权限都得去主存取,延迟100ns。116MB的位数组能塞进L3,延迟12ns,快8倍。如果是稀疏场景只存几MB,那可能直接进L2,更快。这就是内存墙。省内存的本质不是省钱,是让数据离CPU更近。时间和空间不守恒——省内存不会自动变快,但省内存让数据进入更快的存储层级,这才是变快的原因。

「自动变速箱」构想

我盯着对比表想:权限位天然是稀疏的(大部分用户只有读权限),为什么不能自动选择存储方式?

  • 读权限这种密集的(几乎所有用户都有),用位图紧凑存,1bit/用户;
  • 删除/管理员这种稀疏的(不到1%用户有),只记录有权限的用户ID;
  • 密度变了就自动「换挡」。
flowchart LR A["权限布尔数组"] --> B{"权限密度判断"} B -->|"密集(如读权限)"| C["位图模式:1bit/用户"] B -->|"稀疏(如管理员)"| D["稀疏模式:只存有权限的ID"] C --> E["统一数组API"] D --> E E --> F["arr[i] / 按位运算 / 批量查询"]

关键设计:换挡只在创建数组和调用 optimize() 时发生。权限查询是高频操作(每秒可能查几万次),如果每次查询都检查密度并可能触发换挡,性能就完了。所以查询时待在当前挡位,等批量导入权限或你主动调 optimize() 时再换挡。我觉得这个设计太合理了,当晚就开写。

自己造轮子,十二天踩坑日记

  • 第一天:写了个BoolPerm类,密集用bytearray,稀疏用array('Q')存用户ID,能跑。
  • 第二天:换挡阈值50%,结果权限分布在阈值附近波动时来回切,性能比不切还差。
  • 第三天:加了滞回区间防抖动,但密集区和稀疏区数据对不上,查权限返回错误结果。
  • 第四天:稀疏区用array('Q')存64位ID,但用户ID超过2^64时溢出(虽然不太可能,但边界没处理)。
  • 第五天:想支持按位与(同时有A和B权限的用户),两个稀疏区的ID表求交集,和密集区的位图完全对不上。
  • 第六天:按位取反(没有某权限的用户)写出来了,但取反后稀疏区和密集区的语义搞反了。
  • 第七天:in 操作支持了,但密集区直接查位,稀疏区二分查找,两条路径返回值不一致。
  • 第八天:缓存了有权限的用户数,批量授权后缓存没更新,数字忽大忽小。
  • 第九天:optimize() 写好了,但10亿数据一换挡就卡好几秒,期间权限服务全阻塞。
  • 第十天:pickle序列化存快照,读回来内部结构全乱。
  • 第十一天:写了 rindex(找最后一个有权限的用户),稀疏区返回的是下标表位置,不是用户ID。
  • 第十二天:发现还要处理多维数组(用户×权限矩阵)、逻辑运算广播、内存对齐……心态崩了。

第十二天晚上,我意识到一个人写一个生产级的混合布尔数组不是十二天能搞定的。去社区发帖。

转机:发帖求助,评论区集体推荐

帖子标题:

「10亿用户权限标记,dict内存炸、numpy不支持稀疏、手搓位运算太慢,怎么办?」

第一条高赞直接点醒我:

「你要的就是 bool-hybrid-array。它换挡只在创建和 optimize() 时发生,平时查询不换挡,所以不会抖。你之前的问题就是把换挡做成了高频操作——换挡是低频的,别跟查询混在一起。」

后面全是推荐:

  • pip install bool-hybrid-array,权限系统天生适合。」
  • 「稀疏权限(管理员)只存几MB,密集权限(读)自动用位图,两种场景都最优。」
  • memory_usage(detail=True) 看真实内存。」
  • 「密集区底层是numpy,向量化运算直接复用numpy的速度。」
  • 「支持多维数组,用户×权限矩阵直接用。」
  • 「月下载过万,不是玩具。」
  • np.array(arr) 直接转numpy,接你现有分析代码。」
  • 「MIT协议,商用随便。」
  • 「Python 3.9到3.14全支持,PyPy也行。」
  • findrindex返回真实位置,不是下标表位置。」

我直接跑代码验:

from bool_hybrid_array import BoolHybridArr

# 10亿用户,删除权限(稀疏,不到1%用户有)
can_delete = BoolHybridArr(False for _ in range(1_000_000_000))

# 给一些用户授权删除
for uid in [12345, 67890, 111111]:
    can_delete[uid] = True

can_delete.optimize()
print(can_delete.memory_usage(detail=True))

# 读权限(密集,几乎所有用户都有)
can_read = BoolHybridArr(True for _ in range(1_000_000_000))
can_read[0] = can_read[1] = False  # 封禁用户
can_read.optimize()
print(can_read.memory_usage(detail=True))

跑出来的结果:稀疏的删除权限只占几MB,密集的读权限自动用位图约116MB。我用 tracemalloc 独立验证,数字对得上。memory_usage(detail=True) 是库自己算的。 我用 tracemalloc 测出来一致,但「一致」不等于「永远一致」。别信我,别信它,信你自己的测量。

同类方案横向对比

RoaringBitmap:权限集合的工业标准

权限系统本质上就是「有权限的用户ID集合」,RoaringBitmap是这个领域的王者:

from roaringbitmap import RoaringBitmap

admins = RoaringBitmap([12345, 67890])
print(12345 in admins)

# 求同时有删除和写权限的用户
both = can_delete & can_write

它的优势:稀疏场景内存极省,集合运算(并交差)极快,Lucene/Spark/Redis都在用。但它的局限:不是数组。没有 perm[i] = True 这种按位置赋值的数组语义,不支持多维数组,不支持 reshape。权限系统如果只需要集合运算,RoaringBitmap完美;但如果你需要数组语义(比如用户×权限的二维矩阵、按用户ID范围批量查询),用起来就别扭。

完整对比表

方案 稀疏权限内存(1%) 密集权限内存 数组语义 向量化运算 多维数组 权限系统适配
dict ~10GB ~10GB 内存炸
numpy ~930MB ~930MB 稀疏浪费
bitarray ~116MB ~116MB ⚠️ 固定开销
RoaringBitmap ~3MB ~930MB ❌ 集合 集合运算 集合场景最佳
bool-hybrid-array ~3MB ~116MB 数组+稀疏自适应

中立Benchmark

指标 numpy bitarray RoaringBitmap bool-hybrid-array
稀疏(1%)内存 930MB 116MB ~3MB ~3MB
密集(99%)内存 930MB 116MB ~116MB ~116MB
单次权限查询 O(1) O(1) O(1) O(1)~O(log n)
批量按位与(10万次) ~0.001s ~0.01s ~0.001s ~0.002s
遍历有权限用户 O(n)全扫 O(n)全扫 O(k) O(k)只扫稀疏区
支持多维数组

怎么读:稀疏场景 bool-hybrid-array 内存和RoaringBitmap一样省(~3MB),但保留了数组语义和多维数组支持;密集场景自动切位图,速度和numpy一样。

反向稀疏:如果场景反过来(99%用户有权限,1%被封禁),它会只记那1%的False下标,内存同样省。这在读权限场景(几乎所有人都有读权限,只记被封禁的)特别有用。

注意:均匀分布(50/50)是它和numpy打平的场景。但权限系统天然是极端分布的(要么几乎全有,要么几乎全没有),所以优势极大。

缺点与适用边界

第一,optimize() 是低频操作。 批量导入权限后调一次就行,别在每次授权/撤权时调。频繁调等于频繁全量重建。

第二,换挡瞬间O(n)。 10亿数据从稀疏切位图要遍历整个数组,可能几秒。但权限系统只在初始化和批量变更后调,可接受。

第三,非线程安全。 权限服务多线程并发读写要加锁。

第四,生态年轻。 文档和社区不如numpy成熟,冷门问题可能得看源码。

第五,均匀分布打平。 50/50场景和numpy内存差不多。但权限系统不存在均匀分布——要么几乎全有,要么几乎全没有。

第六,memory_usage 是自报数据。 我用 tracemalloc 验证过,但生产环境请自己测。

适用场景:稀疏/密集混合的布尔标记 + 需要数组语义 + 向量化运算 + 多维数组。权限系统、用户标签、特征工程布尔列、A/B实验分桶标记。

不适用场景:纯集合运算不需要数组语义(用RoaringBitmap)、均匀分布定长密集数组(用numpy)、多线程高并发无锁场景。

写在最后

bool-hybrid-array 重写权限扫描后,10亿用户的8个权限位数组总内存不到200MB(稀疏权限几MB,密集权限116MB),向量化运算速度和numpy一样。批量查询「有删除权限但没有管理员权限的用户」从几分钟降到几秒。

安装就一行:

pip install bool-hybrid-array

项目在Gitee和GitHub上都有(搜 bool-hybrid-array),MIT协议。核心类 BoolHybridArr,API和numpy高度兼容,支持多维数组,np.array(arr) 无缝接入现有分析代码。作者承诺no removal policy,现有公开接口不会删。但行为细节可能随版本变化,上生产前务必在你自己的数据上跑一遍

别信我,信你自己的测量。

posted @ 2026-08-19 18:10  贝壳bkshell  阅读(2)  评论(0)    收藏  举报