返回博客

数据同步里的自动打标签:8 个让数据悄悄出错的坑

某个后台管理系统需要把来自外部 POS 系统的会员数据同步到本地,并根据年龄段、消费频次、生日月份等属性自动为每个会员打上分类标签。逻辑听起来直接:拉数据、判断属性、写标签。但实际上线后发现标签数不对、有人被打了两个同类标签、批量跑了 15 小时还没跑完……

这篇文章是这条 AutoTagService 从零到稳定的完整踩坑记录,8 个坑,每个都有可迁移的根因。

现象

同步任务执行完后:

  • 部分会员在同一分类下同时存在两条活跃标签(互斥约束失效)
  • label_table.number_of_people 计数持续漂移,越跑越不准
  • 批量任务跑了一整晚,进度条几乎没动
  • 在 admin 后台修改标签定义后,正在运行的批量任务对新定义视而不见

坑 1:find() 只拿第一条,脏数据永远清不干净

根因

为了保证"每人每分类只有一个活跃标签",我在替换旧标签前先查了一下是否已有同类标签。用的是 find()——在 ThinkPHP 里它只返回第一条记录。

问题在于:历史脏数据(或并发写入)可能已经给同一个用户在同一分类写了多条活跃记录。find() 只软删第一条,剩下的全部残留。

坑:find() 只处理第一条,脏数据永远清不完

find() 检查"是否存在旧标签"并删除,如果存在多条活跃记录(因为历史脏数据或并发),只有第一条被删,其余全部残留,互斥约束形同虚设。

正解

// 错误:find() 只返回第一条
$existing = $db->name('fans_tag')
    ->where('openid', $openid)
    ->where('secondary_sort', $sortId)
    ->where('del_flag', 0)
    ->find();
 
// 正确:select() 返回所有匹配记录
$existings = $db->name('fans_tag')
    ->where('openid', $sortId)
    ->where('secondary_sort', $sortId)
    ->where('del_flag', 0)
    ->select();
 
// 只有恰好一条且就是目标标签时才幂等跳过
if (count($existings) === 1 && $existings[0]['user_tag_id'] === $newTagId) {
    return;
}
 
// 软删所有旧记录
foreach ($existings as $existing) {
    $db->name('fans_tag')->where('id', $existing['id'])
        ->update(['del_flag' => 1, 'update_at' => date('Y-m-d H:i:s')]);
    $db->name('label_table')->where('id', $existing['user_tag_id'])
        ->where('number_of_people', '>', 0)->setDec('number_of_people');
}
正解:强制互斥用 select(),逐条清除

执行"每人每类只留一标签"的互斥约束,必须用 select() 取全部记录,逐条软删,不能假设只有一条。


坑 2:无事务保护,标签在半途丢失

根因

替换标签是多步操作:软删旧标签 → 旧标签计数 -1 → 插入新标签 → 新标签计数 +1。如果插入新标签时发生 PK 冲突或连接超时,旧标签已被删除、计数已被扣减,但新标签永远不会出现——用户在这个分类下从此没有任何标签。

坑:多步写操作无事务,中途失败导致数据半残

删旧 → 扣计数 → 插新 → 加计数,任何一步失败都会留下半残状态。不包事务,就不能保证"全成功或全回滚"。

正解

$db->startTrans();
try {
    $existings = $db->name('fans_tag')->where(...)->select();
    foreach ($existings as $existing) { /* 软删 + 扣计数 */ }
    $db->name('fans_tag')->insert([...]);
    $db->name('label_table')->where('id', $newTagId)->setInc('number_of_people');
    $db->commit();
} catch (\Exception $e) {
    $db->rollback();
    throw $e;
}

坑 3:双钩触发,同一个人被打标两次

根因

tagMember() 被挂在了两个层级:低层的 writebackToLocal()(数据落库时触发)和高层的 memberFindOne()(查询返回后触发)。调用链路是:

memberFindOne() → posRequest() → writebackToLocal() → tagMember()  ← 钩子 A
       ↓
    返回数据
       ↓
tagMember()  ← 钩子 B

每次请求触发两次打标,计数加两次,脏数据从这里稳定产出。

坑:多层架构里在两个层级都挂了同一个副作用

在多层调用链里,低层和高层都挂了同一个副作用,就会在一次请求里执行两次。画出所有调用路径再挂钩子。

正解

只在一个层级挂钩,优先挂高层(最靠近调用方),原因是高层能覆盖所有路径(包括缓存命中不走低层的情况),且执行时机更可预期。


坑 4:CLI 批量任务里静态缓存无 TTL,标签定义改了也不生效

根因

private static $tagCache = null;
 
private static function loadTags() {
    if (self::$tagCache !== null) {
        return self::$tagCache; // 永远不过期
    }
    // ...
}

在 PHP-FPM 里,静态变量随请求结束销毁,天然无缓存问题。但在跑了数小时的 CLI 批量进程里,$tagCache 一旦初始化就贯穿整个进程生命周期。中途在 admin 后台修改标签定义,批量任务完全看不到变化。

坑:PHP 静态缓存在 CLI 进程里永不过期

PHP-FPM 里每个请求都是全新进程,静态变量不会跨请求存活。CLI 长进程完全相反——静态变量和进程同寿。任何在 HTTP/CLI 两种上下文共用的静态缓存,都必须加 TTL。

正解

private static $tagCache = null;
private static $tagCacheTime = 0;
const CACHE_TTL = 300; // 5 分钟
 
private static function loadTags() {
    if (self::$tagCache !== null
        && (time() - self::$tagCacheTime) < self::CACHE_TTL) {
        return self::$tagCache;
    }
    // 重新从 DB 加载...
    self::$tagCacheTime = time();
    self::$tagCache = $result;
    return $result;
}

坑 5:遍历错了表,百万行里 95% 是无效迭代

根因

tagMember($memberCode) 内部需要先根据 member_code 找到对应的 openid(因为标签关联的是微信身份)。如果这个会员没有绑定微信,函数直接 early return,什么都不做。

批量任务最初从 POS 会员总表迭代,共 127 万条记录。其中约 95% 的会员从未绑定微信,全部是无效迭代。真正有效的交集(同时存在于 POS 和微信绑定表)只有约 7.5 万条。

坑:从大表迭代,95% 是空跑,批量跑了 15 小时

批量任务需要 A∩B 的数据,却从 A(大表)迭代再对每条记录查 B。正确做法是直接迭代 B(小表,已经是交集)。127 万次迭代 → 7.5 万次,耗时 15 小时 → 15 分钟。

正解

// 错误:迭代 POS 全量会员(127 万),绝大多数没有微信绑定
$members = $db->name('pos_member_info')
    ->where('member_code', '<>', '')
    ->select();
 
// 正确:直接迭代已绑定微信的会员(7.5 万),全部有效
$members = $db->name('fans')
    ->where('member_code', '<>', '')
    ->where('del_flag', 0)
    ->field('member_code')
    ->select();

实测数据:迭代次数 1,270,302 → 75,641,耗时 15 小时 → 15 分钟,成功率 ~5% → 100%。


坑 6:跨域关联列没有索引,全表扫描拖垮批量

根因

打标签时需要用 member_code 在微信粉丝表里查 openid,这是一次跨域关联(POS 会员 → 微信身份)。粉丝表有 29 万行,member_code 列没有索引,每次 tagMember() 调用都是全表扫描。

正解

ALTER TABLE fans ADD INDEX idx_member_code (member_code);
-- EXPLAIN 扫描行数:138,408 → 1
正解:跨域关联的 join 列两端都要加索引

列存在不等于列被索引。凡是用来连接两个数据域的 key(如 POS 会员号 → 微信 openid),两张表的对应列都需要确认有索引,而不是只看"字段有没有"。


坑 7:年龄按年份差计算,近边界用户有 50% 的概率差一岁

根因

// 错误:只减出生年,不考虑今年生日是否已过
$age = (int)date('Y') - (int)$birthYear;
// 例:生日 2008-12 的人,在 2026-03 查询,2026-2008=18,但实际还是 17 岁

年龄分段标签(18 岁以下、18-25、25-30……)的边界判断全部基于这个错误的年龄值,导致约 50% 的边界附近用户被归入错误的分段。

正解

$age = (int)date('Y') - (int)$birthYear;
if (!empty($birthDay) && strlen($birthDay) >= 4) {
    // $birthDay 格式 "MMDD",当今年生日还没到时减 1
    if ($birthDay > date('md')) {
        $age--;
    }
}

坑 8:7 个调用方各自 try-catch,错误处理七嘴八舌

根因

打标签是非关键副作用,不能让它打断主流程,所以每个调用方都加了 try-catch。7 个调用方,7 种写法:有的记日志,有的静默吞掉,有的重新抛出。维护这 7 份分散的错误处理逻辑成本极高。

正解

把错误处理内化到服务本身,对外只暴露一个"永不抛出"的安全入口:

public static function tagMember(string $memberCode): void {
    if (empty($memberCode)) return;
    try {
        self::doTagMember($memberCode);
    } catch (\Exception $e) {
        Log::warning('AutoTag failed: ' . $memberCode . ': ' . $e->getMessage());
    }
}
 
// 调用方不需要任何 try-catch
AutoTagService::tagMember($memberCode);

一句话外卖

批量数据同步里的每个"小细节"——用哪个查询方法、有没有事务、钩子挂哪一层、缓存有没有 TTL、从哪张表迭代——单独看都无关紧要,叠在一起就是 15 小时跑不完、数据越算越错。