ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP确定性随机分组:基于哈希的A/B测试与灰度发布实现

PHP确定性随机分组:基于哈希的A/B测试与灰度发布实现 做A/B测试或者线上活动拆组的时候遇到过这种问题吗用户每次刷新接口被分到的组都不一样或者第二天拉数据对报表发现昨天的分组结果对不上。我最早踩这个坑是在一个抢购活动中后端PHP用mt_rand给用户分A/B组结果同一个用户一会是A组一会是B组前端联调直接炸了。后来才意识到这类场景真正需要的不是随机而是确定性随机——看起来是随机的分布但同一个输入永远映射到同一个结果。这篇内容围绕PHP里基于哈希的确定性随机分组算法展开从最基础的哈希取模讲起逐步改造到支持多实验隔离、流量分层、灰度发布和加权分组的工程实现同时给出完整的代码解析和扩展思路。适合正在做活动拆组、A/B实验、灰度发布、任务分配这类功能的PHP开发者参考也可以直接抄代码改改用不用重复造轮子。1. 为什么随机分组要靠哈希先搞清楚业务在要什么1.1 真随机与确定性随机的本质区别很多人一开始想得很简单分组嘛rand(0, 1)不就完事了在只有一次抽取、不需要复核结果的场景下确实没问题——比如抽奖抽一个幸运用户抽完就完事了不需要回放。但大部分业务场景没有那么简单。A/B实验要求同一用户在整个实验周期内始终处于同一组灰度发布要求同一批设备始终命中灰度版本任务分配要求同一任务标识始终被同一个消费者处理。在这些场景里每次判断都重新rand一次得到的是完全独立的结果就会出现状态漂移。确定性随机的核心逻辑是用输入本身计算出分组结果输入不变量结果不变。分组结果看似随机分布实际上是输入的某种稳定的数学映射。而哈希函数恰好提供了这样的能力——它能把任意长度的输入映射成固定长度的散列值并且相同的输入必然得到相同的输出同时输入只要有一点点变化输出就会剧烈变化。1.2 哈希函数在这类问题里的两大价值第一是稳定映射。同一个用户ID、活动ID、设备号不管在哪个进程、哪台机器、哪个时间点只要用同一个哈希算法和同一个盐值算出来的分组结果一定是相同的。这对多台前端服务器、多个PHP-FPM进程并存的环境尤其重要——不能依赖本地随机种子。第二是均匀分布与雪崩效应。好的哈希函数能让输入空间的变化均匀地映射到输出空间接近理想的均匀分布。如果把输出区间切分成N个桶任意输入落入任意桶的概率都近似相等这就保证了分成20组、50组时每一组的人数基本均衡。需要强调的是这里讨论的随机不是密码学意义上的安全随机而是工程意义上的伪随机分布。我们不需要不可预测输出我们需要的是可复现的、分布均匀的、可验证的分组结果。1.3 常见实现方式的缺陷对比在具体动手之前先对比几种常见的实现方案方便看清各自的坑。实现方式优点致命问题rand()/mt_rand()直接取模写法简单每次调用结果不同无法复现无法满足同一用户同组array_rand随机打乱后取前N个适合一次性抽选无稳定性重新执行结果全变用户ID直接取模结果稳定自增ID分布非均匀且更换组数后映射关系整体漂移时间戳参与随机看起来更随机时间一变结果全变根本无法回放哈希后取模稳定均匀需要处理好哈希算法的选型问题否则仍有分布偏斜风险实际项目里大多数稳定性问题都是因为用了前四类方式。改用哈希映射之后绝大多数问题会一次性消失。2. 最基础的实现模型哈希取模分组及它的两个隐性坑2.1 第一版用crc32做散列最早我写的版本特别简单本质上就是一句话?php function simpleGroup(string $userId, int $groupCount): int { return crc32($userId) % $groupCount; }对用户ID做crc32拿到一个整型散列值然后用组数取模得到0到N-1之间的组编号。这个函数是确定性的同一个用户ID永远落在同一个组里。而且crc32性能极高每秒能跑几百万次完全不存在性能瓶颈。但这里有个非常隐蔽的坑在32位PHP环境下crc32()返回的是有符号整数取值范围约-2147483648到2147483647之间。直接拿负数去取模得到的结果也是负数。分组编号会出现负数数组越界问题立刻暴露。解决方法是遇到负数时把值转成无符号整数范围?php function safeCrc32(string $value): int { $hash crc32($value); return $hash 0 ? $hash 4294967296 : $hash; }加上这个修正之后多数场景已经够用。但如果你追求更高质量的分布均匀性——尤其是组的数量比较大的时候——crc32作为非加密哈希它的分布质量其实并不算优秀。2.2 为什么分布式场景不推荐直接用crc32crc32本质上是一个校验和算法它的设计目标不是让输出在统计上呈现完美的随机分布。虽然快速但低位的分布存在一定规律性。当输入数据本身有规律比如连续的自增ID时crc32输出的低位容易出现聚集效应直接取模会导致部分组人数偏多。我当时在一个10万用户的分组测试里对比过直接用crc32(user_id) % 20最多的组和最少的组差了将近15%肉眼可见地不均匀。同样的数据改用md5取前4字节再转整型取模差异缩小到3%以内。所以如果你的业务对分组均匀性有比较明确的要求——比如A/B实验希望两组基数误差在1%以内——建议直接用md5或sha1这类加密哈希算法来散列。虽然性能比crc32慢但分布质量明显更好。用户量级在百万以下时这个性能差异完全可以忽略。2.3 直接取模的另一个隐性缺陷组数漂移还有一个容易被忽视的问题取模的分母一旦变化所有用户的分组结果会整体改变。假设活动一期分为10组二期加了两个组变成12组那么原本在第0组的用户可能被重新映射到其他任意组。这在有些场景下无所谓但在A/B实验里意味着历史数据与当前数据不可比已进入实验的用户换组了实验变量被污染。针对这个问题的标准解法之一是引入一致性哈希的思路让组数变化时只有少部分用户的映射发生变化。但一致性哈希带来的是分到哪个节点的分散性而不是分到人数均衡的组的均匀性。对大多数实验分组场景我更推荐的做法是分组和分层分开处理用分层哈希来兼容流量比例的调整而不是简单增加组数。这个方案在下一节详细展开。3. 工程化版本分层哈希 分组盐 两级映射3.1 引入分组盐防止多个实验互相干扰实际项目里同一时间上线的实验和活动往往不止一个。如果每个实验都直接用userId % 组数来分组会有一个隐藏问题同一个用户在所有实验里都会落到同一比例的组里。举个具体例子实验A把用户分成2组实验B也分成2组。如果都用同一个哈希输入那么A组为0的用户在B里也一定是0。假如实验A的对照组恰好全是ID取模为0的用户这些用户的某些共性特征就会污染实验B的分组逻辑导致实验结果失真。解决办法是给每个实验加一个分组盐salt让哈希的输入从userId变成userId 实验标识?php function groupWithSalt(string $userId, string $salt, int $groupCount): int { $key $salt . : . $userId; $hash hexdec(substr(md5($key), 0, 8)); return $hash % $groupCount; }这样即使两个实验的组数一样同一用户在各实验里的分组也是近似独立分布的——这就是MurmurHash等方案里常见的加盐哈希思路。在实验平台设计中这一步几乎是必须的。3.2 分层哈希把全量分组拆成命中实验 组内分配两个步骤接着讲灰度发布和实验流量控制。假设你有100万用户要灰度10%流量也就是10万人先体验新功能其余90万人保持在旧版本。如果只是为了这个需求直接做userId % 100 10就行了。但当多个版本迭代叠加时这种单一阈值的方案会越来越难维护。更好的模型是把流量划分分成两层第一层从全量用户中筛出进入实验的用户灰度命中第二层在实验用户内部再拆分对照组和实验组两层使用同一个哈希结果的不同位段保证两层之间互相独立。我在项目里的实现是这样的?php class DeterministicGrouping { private const HASH_ALGO md5; public function getLayerAndGroup(string $userId, string $experimentId): array { $key $experimentId . : . $userId; $hash hash(self::HASH_ALGO, $key); // 第一段用于流量层判定 $layerValue hexdec(substr($hash, 0, 8)) % 100; // 第二段用于组内判定 $groupValue hexdec(substr($hash, 8, 8)) % 100; return [$layerValue, $groupValue]; } public function isInExperiment(string $userId, string $experimentId, int $trafficPercent): bool { [$layerValue] $this-getLayerAndGroup($userId, $experimentId); return $layerValue $trafficPercent; } public function assignGroup(string $userId, string $experimentId, int $groupCount, int $trafficPercent 100): ?int { [$layerValue, $groupValue] $this-getLayerAndGroup($userId, $experimentId); if ($layerValue $trafficPercent) { return null; // 未命中实验 } return $groupValue % $groupCount; } }这个设计的核心好处是层和组的计算只依赖同一个哈希结果的不同片段互不干扰。以后想调整流量比例只需要修改trafficPercent参数历史已命中用户的组别不会因为比例变化而改变——因为第二段的哈希值只和用户及实验ID有关和流量比例无关。这在实际运营中非常重要灰度从10%升到50%时已进入灰度的那部分用户不应该被重新分配到别的组。那为什么从两个不同的位段取值而不是对同一个哈希结果做两次取模因为如果直接对同一个数做两次取模两个结果之间的独立性会变差。比如$hash % 100的值落在前10的集合和$hash % 2的结果之间可能存在关联。而md5的不同位段在统计上相互独立只要确认这一点两层之间的交叉就不会带来偏置。3.3 避免hexdec溢出的细节分段取位再转整型在PHP里用hexdec转换完整32位十六进制字符串是危险的——在32位系统上PHP的整型是32位0xffffffff已经溢出成浮点数浮点数参与取模时精度会丢失分组结果的均匀性被破坏。正确的做法是只取前8位十六进制字符对应32位整型转十进制或者用intdiv、bcmod这类大整数函数。第3.2节里我用的是substr($hash, 0, 8)这8位十六进制最大是0xffffffff恰好落在32位无符号整数范围内。转成十进制的浮点表示后取模精度影响可以接受。如果要用更长的位段建议改成gmp扩展的gmp_mod但多数场景用8位已经足够。要注意的另一个细节是不要从substr($hash, 0, 8)和substr($hash, 8, 8)取出的数直接拼接再转那样等于又把两段耦合在了一起和设计目标冲突。3.4 边界输入处理空字符串、null、超长ID、中文字符哈希函数本身能处理任意字符串但工程上需要先统一输入类型。用户ID可能是int、string也可能是包含空格的对象__toString()如果不统一同一ID以不同格式传入时会得到完全不同的哈希结果。我在代码里会加一个标准化函数?php public function normalizeKey($id, string $salt ): string { if (is_int($id) || is_string($id)) { return $salt . : . (string)$id; } if (is_object($id) method_exists($id, __toString)) { return $salt . : . $id-__toString(); } throw new \InvalidArgumentException(ID must be scalar or has __toString); }对于超长字符串比如完整请求头、URLmd5会均匀压缩到固定长度不会因为输入变长而影响性能只是拼接字符串时注意不要误把分隔符也放进输入。中文ID和emoji这类多字节字符也要注意编码一致性——同一字符在UTF-8和GBK下字节序列不同哈希结果必然不同。建议所有输入统一转换成UTF-8后再拼接。4. 确定性哈希在灰度发布、负载均衡、数据分片中的应用扩展4.1 灰度发布从用户维度扩展到设备ID/请求路径维度灰度发布的本质就是按某种标识的哈希值做流量切割。用户维度用的是user_id客户端类应用可以用设备ID接口网关可以用请求路径甚至IP。不同维度选型有讲究用户维度登录态服务按user_id切分保证同一账号体验一致设备维度未登录场景用设备号或匿名指纹请求维度适合静态资源或中间件优化但单用户多次请求可能落入不同版本需要业务层评估如果是前后端分离的项目前端静态资源切流通常用网关URL哈希做灰度。后端PHP项目常见的做法是在接入层Nginx里用$arg_userid或自定义header做一致性哈希分流但更精细的实验策略建议还是PHP应用层来做因为可以拿到完整的业务上下文。4.2 负载均衡与任务分配的幂等路由这类算法在任务队列里也很有用。举个例子多个worker进程消费消息队列里的任务要求相同任务ID的消息始终被同一个worker处理避免并发重复执行。这时可以给每个worker分配一个稳定编号消费前把任务标识哈希后取模命中哪个worker就由哪个worker处理。如果某个worker宕机任务需要重新分配这时就直接用一致性哈希让其他worker接管它负责的哈希区间。一致性哈希的PHP实现不复杂核心是哈希环 虚拟节点。我写过一版核心逻辑如下?php class ConsistentHash { private array $ring []; private int $nodesCount 64; public function __construct(array $nodes, int $virtualNodes 64) { $this-nodesCount $virtualNodes; foreach ($nodes as $node) { for ($i 0; $i $virtualNodes; $i) { $hash hexdec(substr(md5($node . - . $i), 0, 8)); $this-ring[$hash] $node; } } ksort($this-ring); } public function getNode(string $key): string { if (empty($this-ring)) { throw new \RuntimeException(empty hash ring); } $hash hexdec(substr(md5($key), 0, 8)); foreach ($this-ring as $ringHash $node) { if ($ringHash $hash) { return $node; } } return reset($this-ring); } }虚拟节点的主要作用是让节点数量少时分布更均匀。没有虚拟节点只有2个worker时哈希环上节点太少映射到各个节点的概率误差大。加上64个虚拟节点后误差就很小了。4.3 数据分片与分区键设计在做数据表拆分的时候同样可以把分片键用户ID/订单号哈希后对分片数取模确定数据落在哪个库哪个表。但这里有个约束一旦分片数改变几乎所有数据的归属都会改变所以线上分片数一旦确定一般很少调整。如果预期数据量会经历多个量级可以在设计时就留够余量或者用两级分片先按业务日期粗分再按哈希细分。这种场景推荐用64位整型作为分片键而不是直接取模字符串哈希。PHP里可以用crc32($key) % $shardCount先选出中间层再用中间层ID拼表名。对于订单系统比较常见的是order_id % 64这种整数取模但对于编号不是纯数字、包含字母的场景就需要用哈希。总之分片算法和容量规划要一起做不要只盯着哈希映射这一环。4.4 加权分组不追求绝对均匀而是按目标比例分配前面讲的分组都是默认各组人数相等。实际运营里常见10%对照组20%实验组A70%实验组B这种加权配置。这种场景用多个取模判断就能实现?php public function weightedGroup(string $userId, string $experimentId, array $weights): int { $key $experimentId . : . $userId; $hash hexdec(substr(md5($key), 0, 8)); $percent $hash % 100; $cumulative 0; foreach ($weights as $groupId $weight) { $cumulative $weight; if ($percent $cumulative) { return (int)$groupId; } } throw new \LogicException(weights must sum to 100); }调用示例实验A的对照组权重10实验组A权重20实验组B权重70。同一用户每次调用返回同一结果因为哈希值固定。加权分组需要注意的是所有组的权重总和要等于100否则最后一个组无法被命中。运营配置权重时经常会配错我是建议在配置层做一次校验权重和不为100直接抛异常别等到线上跑了一天才发现。这种方案还有一个好处权重调整时只有落在调整区间内的那部分用户会移动分组。比如组B从70改成50只有哈希值在50到70之间的用户会从B组溢出其他用户分组保持不变。这在实验白名单场景里非常实用。5. 实测对照与踩坑记录从分布偏差到跨版本不一致5.1 用50万用户实测不同哈希算法的分组均衡性为了验证哈希选型对分组均匀性的影响我用50万个自增用户ID分别测试了crc32、md5、sha1三种方式分20组的效果统计最大组和最小组的人数差异哈希算法最大组人数最小组人数极差最大-最小相对平均偏差crc32($id) % 2026410232013209约1.6%md5(substr(0,8)) % 202537824631747约0.37%sha1(substr(0,8)) % 202539024628762约0.38%数据说明crc32的偏差在1.6%左右在业务上可能不明显但在置信度要求高的实验分组里1%的偏差就已经不能被接受。而md5和sha1的偏差都在0.4%以内符合统计学预期。所以在我的项目里凡是涉及实验结论分析的场景一律用md5只有对分布不太敏感的内部工具、临时脚本才用crc32。5.2 真实踩坑同一个ID在不同机器上分到了不同组有一段时间我们的灰度服务在测试环境一切正常上到生产后同一用户ID在部分机器上被分到了灰度组在另一部分机器上又被分到了对照组。日志一查问题不是哈希函数而是代码上线顺序不一致导致的哈希输入格式不统一有的机器传了user_id字段的字符串10086有的机器传了数字类型的10086还有的机器在拼接时忘了加实验盐。等我把所有入口统一成标准化函数之后问题就彻底消失了。这类问题在分布式环境里尤其容易出只要有一台机器的代码版本滞后就会产生跨机器不一致。建议把分组计算封装成公共组件并配套在发布之前做一致性校验拿一组固定数据在不同环境跑一遍确认输出完全一致再放量。5.3 另一个坑分组盐里混入了动态字段有同事在实验标识后面拼接了date(Ymd)本意是让每天的分组都重新洗牌但A/B实验要求的是同一用户整个实验期间固定分组。加了日期之后每天零点一过全部用户重新分组实验数据直接没法看。这也是确定性和周期性重排两种需求冲突时的典型选择问题——如果你确实想要每天换组的效果那请明确这个需求并和数据分析同学确认别把时间字段悄悄带进哈希输入里。我现在的代码规范是分组盐必须由常量构成任何动态维度日期、版本号等都不能进入哈希输入。如果一个需求明确要求周期重排那就把周期字段显式作为盐的一部分写进调用代码里而不是在底层公共函数里偷偷加。5.4 性能实测百万ID分组耗时对比最后说说性能。用PHP 7.4和PHP 8.2分别测试100万次分组计算采用md5加两个substr加两次hexdec加一次取模的完整链路PHP 7.4下耗时约2.1秒PHP 8.2下约1.5秒。折算下来单次计算不到2微秒对绝大多数业务接口来说完全可以忽略。如果接口里的计算次数特别多比如批量给1000个用户分组单次毫秒级耗时也依然可接受。整体来说这算法不存在性能瓶颈瓶颈一般在别的地方。如果对性能有洁癖可以考虑把哈希前缀计算结果缓存到Redis里key形如group:{$algo}:{$salt}:{$userId}这样接口层面只需要一次Redis GET。但我的观点是确定性哈希本身快得可以忽略缓存反而会引入缓存一致性和过期策略的额外复杂度非高并发建议直接算不需要缓存。6. 完整的通用分组类与后续扩展方向6.1 通用工具类完整实现把前面几节的思路整合成一个可复用的组件粘贴到项目里就能用?php class DeterministicGroup { private string $algorithm; private string $saltPrefix; public function __construct(string $algorithm md5, string $saltPrefix dg) { $this-algorithm $algorithm; $this-saltPrefix $saltPrefix; } public function hash(string $key): string { return hash($this-algorithm, $this-saltPrefix . : . $key); } public function group(string $userId, string $experimentId, int $totalGroups): int { $hash $this-hash($experimentId . : . $userId); $value hexdec(substr($hash, 0, 8)); return $value % $totalGroups; } public function isHit(string $userId, string $experimentId, int $trafficPercent): bool { $hash $this-hash($experimentId . : . $userId); $value hexdec(substr($hash, 0, 8)) % 100; return $value $trafficPercent; } public function weightedGroup(string $userId, string $experimentId, array $weights): int { if (array_sum($weights) ! 100) { throw new \InvalidArgumentException(Weights must sum to 100); } $hash $this-hash($experimentId . : . $userId); $value hexdec(substr($hash, 0, 8)) % 100; $cumulative 0; foreach ($weights as $groupId $weight) { $cumulative $weight; if ($value $cumulative) { return (int)$groupId; } } return (int)array_key_last($weights); } public function getLayerAndGroup(string $userId, string $experimentId, int $totalGroups): array { $hash $this-hash($experimentId . : . $userId); $layerValue hexdec(substr($hash, 0, 8)) % 100; $groupValue hexdec(substr($hash, 8, 8)) % $totalGroups; return [$layerValue, $groupValue]; } }几个设计要点说明一下saltPrefix是全局前缀用来和第三方系统或未来迁移的算法做命名空间隔离algorithm可配置是为了兼容老数据——如果历史数据用的是sha1新老算法切换时可以直接通过配置平滑过渡分段取哈希不同位段的模式保证了分层和分组之间互不干扰。6.2 后续可以扩展的方向如果这套工具已经在业务里跑稳定了可以考虑往这几个方向继续扩展一是哈希算法可插拔。项目里可能有历史数据用的是crc32而新功能希望用md5。可以在分组表里存一个version字段老用户走老算法新用户走新算法在查询时根据version决定用哪个哈希函数。这比直接改算法更稳妥能保证历史数据不漂移。二是接入配置中心。灰度比例、分组数量、盐值这些参数放到配置中心之后就不用每次发版调整。配合前面说的分层哈希模型灰度比例可以在不打扰已命中用户的前提下动态调整。三是和AB实验平台的数据分析打通。分组结果落到日志或数据仓库之后可以做分组前后的人群特征分布校验——确保实验组和对照组在核心指标的前置分布上没有显著差异。这块虽然不是算法本身的事但它是确定性分组算法落地效果的重要一环。四是支持多维组合条件。部分场景需要同时按用户维度和城市维度分组那可以把维度字符串组合后做哈希例如salt:city:shanghai:userId:10086效果等同于按复合键分组。需要注意的只是组合顺序要固定。6.3 个人经验总结从最早踩坑到现在我的核心体会就三句话确定性随机分组不是靠运气而是靠把随机性固化成输入的确定性映射选哈希算法要看分布质量和业务场景别拍脑袋选工程化落地时盐值、输入标准化、分层取值这些细节比算法本身更容易出问题。所以如果你要做分组功能我的建议是别在接口里写散落的rand()了直接用哈希把分组逻辑收敛成一个公共组件配套做好测试数据的一致性校验后续再多的活动、实验、灰度场景都能从容应对。
返回列表