ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

位运算优化取模:从订单分片路由的CPU性能问题说起

位运算优化取模:从订单分片路由的CPU性能问题说起

上周四下午,我在公司订单服务的压力测试群里发了一版性能报告,业务量一冲上来,路由分片模块的 CPU 占用率就直奔 80% 往上走。排查半天,问题居然集中在几行不起眼的分片逻辑上:订单过来要先按 ID 算哈希,再取模落到不同的分片队列里。同事扫了一眼代码,很直接地说:“你这不是在拿%做路由吗?把分片数换成 2 的幂,直接用与运算,x & (n - 1)就能搞定。”

我半信半疑照着他说的改了三行,分片数从 20 调成 16,重新发压,Tps 曲线肉眼可见地往上抬了一截。说实话,我自己以前也被“位运算性能好”这种说法打过无数次鸡血,但真正在业务代码里感受到差距,这是第一次。

这篇文章就把这次改动的来龙去脉完整拆一遍,重点讲清楚几个问题:与运算到底是快在哪一步、它与取模什么时候是等价的、哪些代码适合改成这种写法、以及改成之后会遇到什么坑。内容偏后端,但原理是通用的,想给代码做性能优化、或者刚接触位运算的人,都适合看下去。

1. 同事改的这几行,到底动了什么

1.1 原代码:一个标准的分片取模路由

先看当时的简版代码。订单系统里会有大量订单同时涌进来,我们需要把它们均匀分到若干个处理分片上,每个分片对应一个独立的处理队列或者数据分桶。为了简单,我直接用订单号取哈希,再对分片数取模:

// 优化前:订单号做哈希,再按分片数取模,均匀落到 20 个分片上 public Shard route(Order order) { int hash = order.getId().hashCode(); int partition = hash % PARTITION_COUNT; // PARTITION_COUNT = 20 return partitions[partition]; }

这段代码单独看没什么问题,逻辑非常常规,任何一本编程教科书都会这么写。但它是热点路径:每次订单请求到了都要执行一次,整个服务高峰期每秒要跑几百万次。每次执行都要让 CPU 去做一次真正的除法,这就不太妙了。

当时的分片数是从配置中心拉下来的一个动态值,今天是 20,明天可能是 18 或者 24。配置灵活换来的代价是,JIT 没法在编译期假设它永远是某个常数,除法指令只能老老实实保留下来。

1.2 新代码:容量对齐到 2 的幂,取模换成与运算

同事的建议分两步走。

第一步,把分片容量约束成 2 的幂,我们用 16,它是 2 的 4 次方。第二步,把取模运算改成与运算:

// 优化后:分片数固定为 16,即 2^4 public Shard route(Order order) { int hash = order.getId().hashCode(); int partition = hash & (PARTITION_COUNT - 1); // PARTITION_COUNT = 16 return partitions[partition]; }

改动就这一处,%变成了&,20 变成了 16。第一次看这段代码的人可能会懵:hash & 15不是只取了低 4 位吗,怎么能保证分配到 16 个分片?

关键在于一个不太起眼的数学事实:对于非负整数x,x % 2^n和x & (2^n - 1)是完全等价的。因为 2 的幂次在二进制里恰好是 1 后面跟几个 0,减 1 之后就变成低位全 1,高位全 0。任何数跟这个掩码做按位与,留下的就是低 n 位,恰好等于除以 2^n 的余数。

比如 29 除以 16,余数是 13;29 的二进制是 11101,15 的二进制是 01111,做与运算得到 01101,也就是 13。这不是什么黑魔法,只是二进制最基础的性质。

2. 与运算为什么快:CPU 与编译器视角的细节

2.1 取模的背后是除法,除法是一条代价高昂的指令

很多开发者的直观理解是,取模就是取余数,很简单的数学运算。但 CPU 没有直接的取模指令,x % n在底层会变成一次除法运算,或者被编译器换算成类似除法的乘法序列。

在 x86-64 体系里,整数除法的延迟通常在 20 到 30 个周期以上,而且它不像加减法那样可以被 CPU 流水线无缝处理,需要占用专门的执行单元,吞吐量很低。相比之下,按位与属于最基础的逻辑运算,延迟通常只有 1 个周期,每个周期内可以执行多条。

放到单次调用来看,差的这几十个周期完全可以忽略,比一次内存访问的代价还低。但如果这段代码在几百万个请求里反复出现,而且每次都要算除法,那差距就会慢慢积累成肉眼可见的 CPU 热点。

生活化的类比:取模像是你要从一堆箱子里找第几号箱,每次都得先数一遍总箱数、做一轮除法和取余;与运算则像那个箱子编号本来就写在低位比特上,CPU 直接沿着几根导线把对应位拿过来,一步到位。

2.2 编译器/JIT 有没有帮你优化掉?

这话得诚实说:当除数是编译期常量、而且是 2 的幂时,现代编译器和 JVM 的即时编译器确实会做强度削减,自动把x % 16优化成掩码操作。所以如果你写的是一个字面量16,手动改成& 15几乎看不到收益,因为编译器早就帮你干了。

那为什么我这次还能看到明显提升?

因为原代码里的PARTITION_COUNT不是编译期常量,它是从配置中心动态读取的值。JIT 在运行时看到的是一个不知具体数值的变量,它不能假设这个数恰好是 2 的幂,所以无法安全地把它替换成掩码运算。结果就是,每次都真实地执行了一次高延迟除法。

同事做的关键一步,其实不是把%改成&这个动作本身,而是把动态且不一定是 2 的幂的分片数,变成了一个受约束、固定为 2 的幂次方的数值。这样程序语义就允许编译器和人一起放心地使用位运算。

注意:如果你只是机械地把% N改成& (N - 1),但 N 不是 2 的幂,那结果完全错误。位运算优化的一切前提是你先把数据结构或容量设计成 2 的幂。

2.3 除了减少除法,它还顺手解决了热点倾斜问题

原代码用 20 作为分片数,取模后的结果依赖整个哈希值的分布。但按位与只取低几位,分布特征完全由哈希的低位决定。很多人在这里会翻车:如果哈希函数低位分布很差,比如大量 ID 的低 4 位都集中在几个值上,那么& 15会严重倾斜。

我们当时的新哈希经过扰动处理,低位的随机性足够。Java 的 HashMap 在计算桶下标时也用了相同的思路,先把哈希值做一次扰动,再与容量减一做按位与,正是为了保证低位足够散列。

这也是一个很重要的设计点:改造之前,先确认你的哈希值低位分布是正常的,否则性能没提升,流量倾斜先把机器打爆。

3. 适合改写为与运算的几类代码模板

3.1 环形数组索引回绕

环形队列、循环缓冲区这类结构非常常见,典型写法是:

// 传统写法 int next = (tail + 1) % buffer.length;

当 buffer.length 设计为 2 的幂时,可以写成:

// 位运算写法 int next = (tail + 1) & (buffer.length - 1);

这个写法在 JDK 内置的 ArrayDeque 里就是标准实现。如果你自己维护一个无锁环形队列,这地方每 push 一次就少一次除法。注意前提还是队列长度固定且为 2 的幂,不然回绕逻辑直接错乱。

3.2 哈希分桶与路由选择

就像我们订单服务做的分片路由,只要分桶数量固定且是 2 的幂,hash & (bucketCount - 1)就能替代hash % bucketCount。这里要特别留神分桶数动态变化的情况。

如果分桶数可能在运行期从 16 扩容到 24,那你要么重新哈希重新分布,要么继续用取模。位运算只适合那些容量在设计阶段就已经确定的场景。

3.3 状态标志与权限位掩码

与运算不只是能替代取模,更大的价值在状态管理。一个整数可以存储 32 个布尔开关,每一位代表一个权限或状态,读取和修改都能用位运算完成。

public static final int PERM_VIEW = 1 << 0; // 0001 public static final int PERM_EDIT = 1 << 1; // 0010 public static final int PERM_DELETE = 1 << 2; // 0100 // 用户当前拥有的权限 int userFlags = PERM_VIEW | PERM_EDIT; // 判断是否有编辑权限 boolean canEdit = (userFlags & PERM_EDIT) != 0; // 判断是否同时拥有查看和编辑权限 boolean canViewAndEdit = (userFlags & (PERM_VIEW | PERM_EDIT)) == (PERM_VIEW | PERM_EDIT); // 追加一个删除权限 userFlags |= PERM_DELETE; // 收回编辑权限 userFlags &= ~PERM_EDIT;

相比建立数据库关联表或者一个布尔字段一个判断,位掩码的方案在性能、内存占用和代码简洁度上都有优势。它特别适合用在热点请求链路里的权限判断,一次按位与就能完成任意组合校验。

3.4 判断一个数是否为 2 的幂

这个技巧很多面试资料里讲过,但在真实工程里也很好用:判断n是不是 2 的幂,只需要看n & (n - 1)是否为 0。

boolean isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }

把数组长度、分片数约束在这种校验下,后续就能安全地使用掩码路由。我在代码里加一个断言,任何传入的容量不满足条件就直接报错,把问题拦在启动阶段。

4. 实操落地:把这类优化安全地复现到自己的项目里

4.1 第一步:给容量加上“2 的幂”约束

不要只在个别地方改成与运算,其他地方还用取模,这种半吊子状态最容易出问题。建议在启动阶段或配置加载阶段做一次统一的幂次校验:

private static int requirePowerOfTwo(int capacity) { if (capacity <= 0 || (capacity & (capacity - 1)) != 0) { throw new IllegalArgumentException("容量必须是 2 的幂,当前值: " + capacity); } return capacity; }

配置中心里如果允许业务方随意改分片数,那就必须有这一层兜底。你可以在测试环境里手动填 17、18、20 各跑一遍用例,确认方法确实会拦截非法值。

4.2 第二步:用基准测试拿到真实收益,而不是靠感觉

很多人的性能优化止步于“听起来更快”,这不可靠。改进前后,请用同一份数据跑一轮正常的压力测试。

以 Java 为例,如果你用 JMH 做微基准,代码大概是这种感觉:

@Benchmark @BenchmarkMode(Mode.AverageTime) public int testMod() { return value % partitionCount; // partitionCount 从参数传入,模拟非常量 } @Benchmark @BenchmarkMode(Mode.AverageTime) public int testAnd() { return value & (partitionCount - 1); // 前提:partitionCount 是 2 的幂 }

要注意别让 JIT 把整个计算循环优化掉。结果一般需要把返回值累加或打印,让计算产生可见副作用。我自己的测试里,在非常量除数的前提下,%平均每次大约几十纳秒,&大约是 0.5 到 0.6 纳秒。指令级别的差距很大,但回到真实业务里,整体吞吐提升不会达到几十倍,因为瓶颈不止这一处。我们最终压测结果是整体耗时下降了三成左右,主要来自热点路径不再排队等除法单元。

实操心得:微基准的数字只代表指令趋势,别拿它当业务性能的绝对值。真正有说服力的是压测曲线和火焰图。

4.3 第三步:给同事和未来的维护者留下注释

位运算的缺点是可读性差。hash & (capacity - 1)到底在干嘛,新手看三遍也不一定反应过来。我的习惯是每次改动都留下同一行级别的注释:

// capacity 固定为 2 的幂,这里等同于 hash % capacity,但更快 int partition = hash & (capacity - 1);

这行注释成本极低,但能省掉未来几轮无意义的 Git blame 争论。有些团队甚至会把这条规则写进代码规约:一律优先使用幂次容量加掩码。

4.4 第四步:压测时不只看吞吐,还得看分布

性能优化很容易只盯着延迟和 Tps,忽略了数据分布。如果位掩码之后分片负载不再均匀,那就应验了“省下了除法,赔上了流量”。压测时要同时采集每个分片的队列长度或处理量,确认 16 个分片上的负载方差没有变大。

我当时是让每个分片自己累加处理计数,压测结束后把 16 个数字打印出来看离散度,确认没有问题才放心上线。这一步非常关键,哈希低位分布不好的时候,有些分片可能极端空闲,有些直接热到熔断。

5. 常见问题与避坑实录

5.1 为什么我改了之后,结果全不对了

最常见的原因只有一个:容量不是 2 的幂。假设你原来有 20 个分片,直接写成hash & 19,你以为 19 在掩码,实际上 19 的二进制是 10011,按位与的结果最多只有 0 到 19 而且分布极其不均匀,有些分片永远拿不到数据。

解决方案不是把 20 硬改成 19,而是把分片数从 20 调整到 16,或者干脆扩到 32,再做对应迁移和灰度。容量必须是真正的 2 的幂,别看错数字。

5.2 负数的哈希值参与与运算会怎样

这是一个很容易被忽略的边界。

Java 里的%对负数的行为是结果符号跟随被除数,比如-7 % 4的结果是-3。而-7 & 3呢?因为整数的二进制补码表示,低两位依然参与按位与,结果是 1。也就是说,这两个运算在负数上是不等价的。

所以如果你的哈希值可能是负数,直接用hash & (n - 1)做分片,和原先的取模语义完全不同。HashMap 的源码里敢直接用(n - 1) & hash,是因为它只要求分布均匀,并不要求结果等于数学取模;而且它预先对哈希做了扰动,负数的低位也不会有明显偏差。但如果你要严格保持旧的取模语义,请先把负数映射为非负,或者继续用取模。

注意:当你发现分配结果变了但也没报错时,别想当然认为这是无所谓的。分片一旦错位,数据读取可能全部错乱,这比性能问题严重得多。

5.3 基准测试里反而测得更慢

见过几次这样的情况:同事把取模改成与运算,微基准测试成绩却不升反降。原因一般是测试代码里容量写的是字面量常量,编译器已经自动优化了原来的取模,你手写与运算等于多此一举;另一种情况是 JIT 干脆把整个死代码循环消除了,两边测的都是空气。

所以做对照测试时,建议把除数或容量通过方法参数传入,模拟运行时未知值的场景,否则测不到真实差距。这个细节很小,但决定了结论是否可信。

5.4 位运算不是银弹,IO 密集型场景慎用

如果一个方法里 99% 的时间都在等待数据库查询、下游接口响应或者磁盘读写,把一处取模换成与运算,收益几乎为零。位运算压榨的是 CPU 指令周期,而你的瓶颈根本不在 CPU。

这时候优化应该去做批量合并、减少网络往返、加缓存,而不是纠结一条除法指令。优化之前先看火焰图,找清楚热点到底在哪一层,这是一条比任何技巧都重要的经验。

5.5 与运算之外:可参考的兄弟运算

除了按位与,按位或和异或也值得纳入工具箱。比如用异或做简易哈希的扰动,用按位或快速合并多个权限位,用按位取反来清除位掩码。它们都不是为了炫技,而是服务于同一个目的:在明确的语义约束下,用最直接的指令完成计算。

6. 我后来对这类微优化的复盘体会

那次优化之后的很长一段时间,我对位运算的态度从“能不用就不用”变成了“看懂场景再用”。你要问我最大的收获是什么,我会说不是记住了& (n - 1)这个写法,而是整个思考链路:

  • 热点路径上每次取模都藏着一次真实除法,当除数是运行时变量时,编译器也无能为力。
  • 位运算快,但不是随便替换就快,它要求你先把数据结构和容量约束想清楚。
  • 优化前和优化后都要用数据说话,包括吞吐数据和分片分布数据。

后来我再看到有人写% config.getPartitionCount()这种代码,我会先问一句:这个值是幂次吗?如果不是,值得讨论一下要不要改成幂次约束。如果改成幂次,路由、存储扩容、数据迁移都要重新评估,而不是简单地换一个运算符了事。

最后再分享一个实际工作里的小技巧:如果你不敢确定改动对线上没有影响,可以先在配置里把分片数统一调整为 2 的幂,但代码继续保留取模方式,跑一段回归。确认业务数据分布、读写路径一切正常之后,再把%换成&,分两步上线,每一步都有回退空间。这样既拿到了性能收益,又能把风险压到最小。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进