做电商最头疼的,大概就是大促刚开始冲量,后台突然冒出一堆“秒杀”订单,GMV 数据好看得离谱。等你兴奋完一查,全是刷单团伙搞的鬼。更要命的是,这帮人学精了,账号分散、设备不同、时间也错开,表面看跟正常用户没啥两样。可只要你把每个账号的下单时间间隔拉出来,再跟 IP 地址做一次聚合分析,很多羊毛党的“马甲”就藏不住了——问题在于,你怎么用 PHP 把这些特征捞出来、算清楚、打上标记。

前阵子我们平台就挨了一记闷棍。一款新品刚上架,半小时内涌进两百多单,运营还在那高兴呢,结果第二天退货率拉到 70%。一查日志,那群账号的下单节奏简直像被编程了一样——每单间隔正好 3 秒,一个不多一个不少。真人能这么整齐?我是不信的。

后来我花了两周时间,基于 ThinkPHP 和 Laravel 搭了一套自动识别系统。核心就干两件事:抓时间间隔的规律性,抓 IP 的聚集程度。效果还行,后来类似的团伙基本没再进来过。

下单快得像复制粘贴,时间戳一秒都不差

真实用户的购买路径通常七拐八绕:先比价、再看评价、犹豫半天才下单。而刷单脚本可没这耐心,典型表现是多个账号几乎在同一秒内完成“浏览→加购→支付”全流程,时间间隔方差极小。拿 ThinkPHP 的中间件在 Order 模型的事件里随手记一笔时间戳,就能看到这种“齐步走”的规律。

<?php
// app/listener/OrderCreatedListener.php (Laravel)
namespace App\Listener;
use Illuminate\Contracts\Queue\ShouldQueue;
class OrderCreatedListener implements ShouldQueue
{
    public function handle($event)
    {
        $gap = now() - $event->order->created_at;
        if ($gap < 2) { // 小于2秒视为可疑
            log_suspicious($event->order);
        }
    }
}

但光靠这个不够。很多公司还在用“单 IP 一天下单超过 N 次就封”的老办法,结果客服天天被误判的用户投诉。现在的刷单团队会把几百个账号塞进几条宽带里,通过 NAT 共享同一个公网 IP。如果你把 Nginx 的真实 IP 解析逻辑换成 $_SERVER['HTTP_X_FORWARDED_FOR'],还能发现更多“连号 C 段”的小尾巴。把这些信息丢进一个 Redis set,每天凌晨跑一次聚合脚本,很容易画出几团高密区。

Database table design for user behavior log in PHP

日志采集不是无脑插表

聊到日志采集,很多人第一反应是“不就是写个表,插几条记录吗”。真动手就发现,刷单团伙最擅长的,就是让你数据对不上。时间戳差几毫秒,IP被网关吞掉,订单状态跳变——等你从数据库捞出来分析,已经错过最佳拦截窗口了。

我踩过一个坑:早期用 Laravel 的 Activitylog 扩展包,直接往 表里塞 JSON 格式的属性。结果查 IP 聚合时,每次都要做 ,几百万行数据就把 MySQL 干崩了。后来老老实实拆了一张专用的 表:

CREATE TABLE `user_behavior_log` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL COMMENT '用户ID',
  `action_type` tinyint(4) NOT NULL COMMENT '1=浏览 2=加购 3=下单 4=支付',
  `target_id` int(11) DEFAULT NULL COMMENT '商品ID或订单ID',
  `ip_address` varchar(45) NOT NULL COMMENT 'IPv4/IPv6',
  `user_agent` text COMMENT '设备指纹用',
  `created_at` datetime(3) NOT NULL COMMENT '毫秒精度',
  PRIMARY KEY (`id`),
  KEY `idx_user_action` (`user_id`, `action_type`, `created_at`),
  KEY `idx_ip_time` (`ip_address`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意那个 datetime(3),不是 datetime。羊毛脚本经常在 100 毫秒内完成多个动作,如果你按秒记录,看到的全是同一秒,没法算方差。另外 target_id 一定要保留,不然你查“同一商品在 3 秒内被哪些账号下单”时,得去关联订单表,性能直接掉一个数量级。

拿 ThinkPHP 来说,我试过在 Order 模型的 afterInsert 钩子里写日志。看起来优雅,但有个致命问题:如果下单后事务回滚了,日志已经插进去了,数据就不一致了。后来换成了中间件方案,在 onRequest 阶段先记一笔“请求到达”,等 onResponse 时再根据响应状态补全结果。这样即使订单失败,也能知道这个 IP 在哪个时间点尝试了下单行为——刷单测试通常也会触发大量失败记录。

// app/middleware/BehaviorLog.php (ThinkPHP 6+)
namespace app\middleware;

class BehaviorLog
{
    public function handle($request, \Closure $next)
    {
        $start = microtime(true);
        
        // 先记录请求快照
        $context = [
            'user_id'    => $request->userId ?? 0,
            'ip'         => $request->ip(),
            'user_agent' => $request->header('User-Agent'),
            'url'        => $request->url(),
            'start_at'   => date('Y-m-d H:i:s.v'),
        ];
        
        $response = $next($request);
        
        // 响应完成后补全日志
        $context['end_at']     = date('Y-m-d H:i:s.v');
        $context['duration']   = (microtime(true) - $start) * 1000;
        $context['status']     = $response->getCode();
        $context['action']     = $this->resolveAction($request);
        
        // 推入队列
        \think\facade\Queue::push('app\job\WriteBehaviorLog', $context);
        
        return $response;
    }
}

Laravel 的话,用 terminate 中间件更合适——响应已经发给客户端了,才去写日志,不影响用户体验。Yii2 的 afterAction 事件同理。

高峰期每秒几千次下单,直接 INSERT 一张表,MySQL 的写锁马上就报警。我用的是 Laravel 的 RedisQueue 配合 ShouldQueue 接口,把行为日志先塞进 Redis 的 list 里,每分钟跑一个定时任务批量 RPOPLPUSH 出来,攒够 500 条再一次性写入 MySQL。

// app/jobs/WriteBehaviorLog.php (Laravel)
namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\DB;

class WriteBehaviorLog implements ShouldQueue
{
    public $timeout = 120;
    
    public function handle()
    {
        $logs = [];
        for ($i = 0; $i < 500; $i++) {
            $data = \Redis::lpop('behavior_log_queue');
            if (!$data) break;
            $logs[] = json_decode($data, true);
        }
        
        if (!empty($logs)) {
            DB::table('user_behavior_log')->insert($logs);
        }
    }
}

这里有个细节:ShouldQueue 默认是同步的,必须配 QUEUE_CONNECTION=redis 才异步。我见过有人配了 redis 但没改 .env,结果队列还跑在 sync 上,流量一来直接超时。

再说个更隐蔽的:IP 字段别只记 $_SERVER['REMOTE_ADDR']。如果你用了 Nginx 反向代理,得解析 X-Real-IP 或者 (Cloudflare)。刷单人最喜欢在代理层做手脚,有时候你查出来的是 CDN 节点 IP,根本不是真实出口。

最后提一嘴 Yii2 的实现,它的 有个 afterSave 事件,配合 Queue 组件也能达到类似效果。但我发现 Yii 的队列插件(比如 yii2-queue)默认的 file 驱动在并发场景下会丢任务,必须换 redisbeanstalk

数据喂进去只是第一步,下一章我们聊聊怎么把这些时间戳和 IP 堆成聚类图,直接用 PHP 的数组操作把羊毛党圈出来——别急着上图数据库,有时候一个 foreach 就够了。

PHP code calculating time intervals between orders

时间间隔:标准差接近零,基本实锤

数据落库了,但光有原始日志没用。得让数据自己开口说话。

羊毛党最典型的特征不是快,而是太规律了。真人买东西,哪怕是一键下单,时间戳也带点随机抖动——等个支付回调、犹豫一下凑单、不小心输错密码。但脚本不是。脚本的每一秒都是有成本的,能跑多快跑多快,能多整齐就多整齐。

我之前在跑一个促销活动的时候,后台日志里有个 user_id=18723 的用户,一小时内在三个不同 IP 下了 47 单。当时开发同事说“这人手速真快”,我说不对,咱们算算时间差。

// 按用户分组,提取下单时间戳(假设已从日志表查出该用户的所有下单时间)
$timestamps = [
    strtotime('2025-06-01 10:00:01'),
    strtotime('2025-06-01 10:00:04'),
    strtotime('2025-06-01 10:00:07'),
    strtotime('2025-06-01 10:00:10'),
    strtotime('2025-06-01 10:00:13'),
];

$intervals = [];
for ($i = 1; $i < count($timestamps); $i++) {
    $intervals[] = $timestamps[$i] - $timestamps[$i - 1];
}

$mean = array_sum($intervals) / count($intervals);
$variance = 0;
foreach ($intervals as $interval) {
    $variance += pow($interval - $mean, 2);
}
$variance /= count($intervals);
$stdDev = sqrt($variance);

echo "平均间隔: {$mean} 秒, 标准差: {$stdDev} 秒";
// 输出: 平均间隔: 3 秒, 标准差: 0 秒

标准差的 0 不是我编的。实际的间隔序列是 [3, 3, 3, 3],一秒都不差。真人做得到吗?做得到,但连续几十次都这样,基本就是定时任务了。

这个逻辑在代码里就叫“间隔方差检测”。我一般设两个阈值:平均间隔低于 10 秒 且 标准差低于平均值的 20%,直接扔进待审名单。阈值得调,根据你业务正常用户的平均下单间隔来——比如你卖的是 9 块 9 秒杀,正常用户也可能 5 秒下一单,但他们的标准差会飘到 2~5 秒,因为网络延迟、页面加载、犹豫时间都不一样。

MySQL 8.0 及以上可以用窗口函数直接在 SQL 层算,省得拉全量数据到 PHP 内存里搞:

SELECT
    user_id,
    AVG(diff) AS avg_interval,
    STDDEV(diff) AS std_interval
FROM (
    SELECT
        user_id,
        TIMESTAMPDIFF(SECOND,
            LAG(created_at) OVER (PARTITION BY user_id ORDER BY created_at),
            created_at
        ) AS diff
    FROM behavior_logs
    WHERE created_at BETWEEN '2025-06-01 00:00:00' AND '2025-06-01 23:59:59'
) t
WHERE diff IS NOT NULL
GROUP BY user_id
HAVING avg_interval < 10 AND std_interval < avg_interval * 0.2
ORDER BY std_interval ASC;

这里 LAG 函数取上一条记录的时间,减完就是间隔。STDDEV 是 MySQL 内置的总体标准差函数,别自己拿 PHP 算,数据量大起来内存会炸。我当时线上 20 万用户的行为表,用这个 SQL 跑完也就 0.4 秒。

但有个坑:别只算一次就下结论。有的羊毛党会故意在正常订单里混几个大间隔,把平均值拉高。我做法是连续跑三天,每天跑一次这个检测,把三天都命中的人标记为“高置信刷单”,只命中一两天的标成“疑似”。配合下章的 IP 聚合检测,基本不会误伤。

说到误伤,ThinkPHP 用户有个省事的办法:用模型事件在订单创建后异步计算间隔,写到 user_risk 表的 interval_score 字段里,后续规则引擎直接读分数。Laravel 那边可以扔个 job 到队列,只要队列不堵塞,实时性比定时任务好很多。

最后说个血泪教训:别在生产环境直接跑这个 SQL 全表扫,加个索引 INDEX idx_user_created (user_id, created_at),否则 DBA 会来找你喝茶。

间隔算完了,下一章该把 IP 拿出来拼个图了——那些分布在三个省份的订单,IP 段却紧挨着,你猜怎么着?

IP聚合:C段里藏着一窝“老乡”

前文把时间间隔算到用户头上,但单个用户的间隔再完美,也抓不住批量操作的团伙。羊毛党最致命的破绽,其实是IP——他们再小心,也不会给每个账号拉一条独立宽带。

我2024年处理过一个典型案例:某美妆类目一天内下了300多单,收货地址分布在广东、江苏、四川,乍看是正常分布。但把IP拿过来一聚类——全是183.6.x.x这个C段,而且集中在183.6.112.0到183.6.112.255这256个地址里。这300多单背后,真正活跃的IP只有12个。

所以IP聚合检测的核心思路很简单:把用户按IP的C段或B段分组,统计每个IP段下聚集了多少个用户ID。一旦某个C段下的用户数超过阈值,这批账号就该拉出来仔细查了。

MySQL上的基础聚类:GROUP BY + HAVING

最直接的做法,在behavior_logs表里按IP字段分组统计:

SELECT
    SUBSTRING_INDEX(ip, '.', 3) AS ip_c_segment,
    COUNT(DISTINCT user_id) AS user_count,
    GROUP_CONCAT(DISTINCT user_id ORDER BY user_id SEPARATOR ',') AS user_ids
FROM behavior_logs
WHERE created_at BETWEEN '2025-06-01 00:00:00' AND '2025-06-07 23:59:59'
GROUP BY ip_c_segment
HAVING user_count > 5
ORDER BY user_count DESC;

SUBSTRING_INDEX取到第三个点前面,把C段算出来。HAVING user_count > 5意思是至少6个用户共用一个C段——正常电商场景下,一个公司或者学校宿舍可能会有几个人共用一个C段,但超过10个就非常可疑了。

但这里有个坑:别只看C段,有些刷单团队会用B段。比如183.6.0.0到183.6.255.255这个B段里,如果用户分布特别散,C段统计可能每个都只有两三个。我的做法是同时跑C段和B段的聚类,哪个先触阈值就标哪个。

线上跑的时候注意一下性能。如果日志表每天几百万行,GROUP_CONCAT会把内存撑爆。这时候可以分段查,或者干脆不要user_ids这个字段,只统计数量,具体用户ID后续单独拉。

Redis集合做实时聚合:比MySQL快一个数量级

MySQL的聚类适合定时任务,比如每小时跑一次。但有些场景需要实时判断——用户下单时就要知道这个IP是不是已经聚了一堆人。这时候Redis就派上用场了。

我用Redis的SET结构存IP到用户ID的映射关系:

// Laravel 里用 Redis Facade
$ip = request()->ip();
$ipC = implode('.', array_slice(explode('.', $ip), 0, 3));
$key = 'ip_cluster:c:' . $ipC;

// 把当前用户ID加进去
Redis::sadd($key, $userId);

// 查这个IP段下有多少人
$count = Redis::scard($key);

if ($count >= 10) {
    // 标记当前用户属于高风险IP簇
    Redis::sadd('risk_ip_clusters', $key);
}

注意key的设计:前缀表示C段,B段就用。这样做的好处是,用SCARD可以极快地拿到聚合数量,单次操作基本在1毫秒以内。

但Redis聚合有个问题:数据会一直积累。如果不设过期时间,三个月前的IP也一直算在里面。我的做法是用EXPIRE给每个key设48小时有效期,同时每天跑一次离线任务,把Redis里的聚合结果刷到MySQL的risk_ip_cluster表做持久化。

辅助特征:别让IP单一特征背锅

只靠IP聚类误伤率很高。我自己踩过这个坑:某大学校园网出口IP只有一个C段,全校几千个学生共用一个IP——按我的阈值,全标成羊毛党?那不得被投诉死。

所以必须结合注册时间、设备指纹这些辅助特征来降噪。

我一般在Redis聚合的同时,把每个用户的注册时间戳也存进去:

$userInfo = Redis::hgetall('user:meta:' . $userId);
$regTime = $userInfo['reg_time'] ?? null;
$deviceId = $userInfo['device_id'] ?? null;

// 如果同一个IP段下,所有用户注册时间集中在24小时内,概率极高
// 如果设备ID也相同,基本实锤

另外还可以加一个“活跃天数”的判断。正常用户注册后不会天天下单,羊毛党往往是注册当天就密集下单。如果某个IP下80%的用户注册时间都在最近3天,那几乎可以确认是刷单团伙了。

具体阈值怎么设?我自己调参的经验:

  • C段下用户数≥10 且 近3天注册占比≥60% → 高风险
  • C段下用户数≥5 且 设备指纹重复率≥50% → 中风险
  • B段下用户数≥50 且 地域分布集中在2个以内城市 → 需人工复核

这些阈值没有标准答案,得根据你平台的实际用户密度慢慢调。但基本原则是:宁可漏检10个正常用户,也别误伤1个真实买家。误伤带来的客诉成本可比刷单损失高得多。

IP聚合做完了,下一章该把前面所有维度的分数拼起来,搞个完整的决策树——到底怎么一步步标记成“羊毛党”?阈值怎么联动?那章才是我踩坑最多的地方。

自动化标记:把分数拼成一条红线

前面几章把特征都拆完了——时间间隔、IP聚合、设备指纹,每个维度都算出了风险分。但真正让我头疼的不是怎么算分,而是怎么把这些散落的分数拼成一个能自动下判断的系统。你总不能每天都手动查数据库看谁分高吧?

所以我用Laravel的Artisan命令搞了个定时任务,每五分钟扫一次日志表,把所有用户的风险评分汇总一遍。命令名叫 ,内部流程其实不复杂:先拉取最近15分钟内下单超过3笔的用户列表,然后对每个用户依次查询Redis里的时间间隔哈希、IP聚合分数、设备指纹重复率,最后喂进一个决策函数。

// app/Console/Commands/CheckFraudClusters.php
public function handle() {
    $recentUsers = DB::table('orders')
        ->select('user_id', DB::raw('count(*) as order_count'))
        ->where('created_at', '>=', now()->subMinutes(15))
        ->groupBy('user_id')
        ->having('order_count', '>=', 3)
        ->pluck('user_id');

    foreach ($recentUsers as $userId) {
        $score = $this->calculateRiskScore($userId);
        if ($score >= 75) {  // 阈值调了两个月才定下来
            $this->markAsFraud($userId, $score);
        }
    }
}

这个 函数内部就是前面几章所有特征的加权求和。我自己的权重分配是这样的:时间间隔离散度占30%,IP聚合度占35%,设备指纹重复率占25%,剩下10%给注册时间异常度(注册不到24小时就大量下单的)。每个维度归一化到0-100分,75分是红线。

标记之后的事——限制下单权限才是目的

分数到了,不能只打标签。得让羊毛党真正下不了单才行。我在 User 模型上加了个 is_flagged 字段,默认0,被标记后变成1。然后在下单的 里加了个中间件:

// app/Http/Middleware/CheckFraudFlag.php
public function handle($request, Closure $next) {
    if ($request->user() && $request->user()->is_flagged) {
        return response()->json(['error' => '账号异常,请联系客服'], 403);
    }
    return $next($request);
}

但误判这事儿,真踩过坑。有个用户用公司 WiFi 下单,整个 C 段直接被标成高风险,她下不了单,直接打电话骂客服。标记要是写死了,就等着接投诉吧——得留点弹性。

我搞了个 UserFlagged 事件监听器。当用户被自动标记时,系统会发通知给运营群(我用的是钉钉机器人),同时给该用户生成一条复核记录。运营人员点了复核通过后,is_flagged改回0,并且24小时内不会被同一套规则二次触发。冤案申诉通道必须留,不然羊毛党没来,客服先累死了。