分表、分区与查询优化
先确定要查的业务事实和日期,再选择数据库、表或索引,最后使用匹配的字段条件。查询分区表尽量携带分区字段,查询时序表必须设置时间范围。
按业务选择数据
| 要查什么 | 数据对象 | 首先限定的范围 |
|---|---|---|
| 设备档案及大批量筛选 | MySQL nebula_ids.base_device、ES base_device | MAC+CPU,或明确渠道、型号和激活日期;ES 是检索副本,可能有同步延迟 |
| 某设备某月装了什么 | MySQL ik_app_install.app_install_deviceYYYYMM、ES 同名月索引 | 先选月,再选设备;每月最近快照不能还原任意历史时点 |
| 某应用装在多少设备 | ik_app_install.app_install_count_package | 月份、包名、版本;离线汇总有更新时间差 |
| 应用每天运行多久、打开几次 | TDengine nebula_ids/app_runtime_archive.flow_app_runtime | 冷热库、运行日期、包名及设备;不是 ES 安装快照 |
| APP 活跃趋势 | TDengine app_activity_day/month/year.app_activity_detail | 周期粒度、包名、周期起点 |
| 应用活跃原始上报 | MySQL ik_thirdparty.app_activity_log_* | 应用、服务端接收日、设备分区值 |
| 设备日志文件 | ik_yudao_device.device_log_file | MAC、CPUID、创建时间;查询元数据,不读取 所有日志正文 |
| 黑名单卸载或杀进程反馈 | MySQL flow_blacklisted_device、TDengine app_kill_record | 策略、设备、业务日期或接收时间;两种时间不要混用 |
| 广告播放和执行结果 | TDengine launcher_ad_play*、launcher_exec | 时间、包名、广告位置/素材、反馈类型;播放次数不等于去重设备数 |
MySQL:先选表,再选分区和索引
按月或按日分表决定访问哪些表;LIST(partition_index) 决定表内设备数据分布;联合索引决定如何定位候选记录。查询时先限定日期表,再尽量携带分区字段与联合索引的前导列。按设备哈希分区的 LIST 表,需要显式提供 partition_index。MySQL 分区裁剪说明
| 对象 | 实际组织方式 | 使用时注意 |
|---|---|---|
| 安装快照月表(202606—202609) | 每表 150 个 LIST(partition_index),值 1—150 | 先选月份,再使用与写入一致的设备分区值;MAC/CPU 条件不自动变成 partition_index |
| APP 活跃原始日表(25 张) | 每表 1024 个 LIST(partition_index),值 0—1023 | 先按应用与接收日选表;哈希计算得到分区值,不表示物理方式为 HASH |
rule_mac_item、rule_mac_resource_item | 每表 300 个 LIST(partition_index) | 使用规则或资源包 ID、MAC 与分区值;不能拿安装表的分区算法通用代替 |
device_log_file | 32 个 KEY(mac,cpuid) | 同时提供设备两个标识;同时限定create_time范围 |
| APK 推送历史主表及两个旁支表 | 每表 16 个 KEY(mac,cpuid) | 设备维度分区,同时限定推送时间;两个旁支表保留为未接入的历史结构 |
遗留 app_install_device/app_install_package | 分别 150/100 个 LIST 分区 | 与独立安装库现行月表区分,不直接跨库合并统计 |
37张表采用分区设计,分区字段和取值范围见对应库页的“分区设计”。
分区字段如何计算
查询分区表时,尽量带上分区字段。下列三组 LIST 分区由业务计算 partition_index 后写入;查询同一设备时必须使用相同的输入格式和算法。先按日期选表,再把分区值和设备条件一起放入 WHERE。
| 表族 | 分表规则 | partition_index 计算 | 分区名 |
|---|---|---|---|
安装快照 app_install_deviceYYYYMM | 服务端接收月份,后缀不带下划线 | MAC+CPU 的 Java 字符串哈希,对150取余后取绝对值,再加1 | p1—p150 |
活跃原始日志 app_activity_log_包名_YYYYMMDD | 包名中的点替换为下划线,日期取服务端接收日 | 非空白MAC+非空白CPU的 Java 字符串哈希,对1024取余后取绝对值 | P0—P1023 |
rule_mac_item | 固定表 | 规则ID哈希分成10组,MAC哈希分成30组,组合成分区值 | p1010—p1039、p1110—p1139,依次至p1910—p1939 |
rule_mac_resource_item | 固定表 | 与上一行相同,ID改为资源包ID | 同上 |
MAC 与 CPU 直接拼接,不加分隔符;分区计算不做 MAC 去冒号、大小写转换或修剪。查询应使用保存时的设备标识。安装快照把空白 CPU 视为空串;活跃日志分别跳过空白 MAC 和 CPU。规则表使用完整 MAC,ID采用 Java Long.hashCode,不能把ID转成字符串后计算。
Java
以下方法放在工具类中使用。StrUtil 按 Hutool 5.8.35 的空白字符规则处理输入。
import cn.hutool.core.util.StrUtil;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.stream.IntStream;
static int installPartition(String mac, String cpu) {
String key = mac + (StrUtil.isBlank(cpu) ? "" : cpu);
return Math.abs(key.hashCode() % 150) + 1;
}
static int activityPartition(String mac, String cpu) {
String key = (StrUtil.isBlank(mac) ? "" : mac)
+ (StrUtil.isBlank(cpu) ? "" : cpu);
return Math.abs(key.hashCode() % 1024);
}
static String installTable(LocalDate receivedDay) {
return "app_install_device" + receivedDay.format(DateTimeFormatter.ofPattern("yyyyMM"));
}
static String activityTable(String packageName, LocalDate receivedDay) {
return "app_activity_log_" + packageName.replace(".", "_") + "_"
+ receivedDay.format(DateTimeFormatter.BASIC_ISO_DATE);
}
static int rulePartition(long ruleOrResourceId, String mac) {
int high = Math.abs(Long.hashCode(ruleOrResourceId) % 10) + 10;
int low = Math.abs(mac.hashCode() % 30) + 10;
return high * 100 + low;
}
// 只知道规则ID或资源包ID时,查询这30个分区。
static int[] rulePartitionsById(long id) {
int high = Math.abs(Long.hashCode(id) % 10) + 10;
return IntStream.range(10, 40).map(low -> high * 100 + low).toArray();
}
// 只知道MAC时,反查其所属规则/资源包,查询这10个分区。
static int[] rulePartitionsByMac(String mac) {
int low = Math.abs(mac.hashCode() % 30) + 10;
return IntStream.range(10, 20).map(high -> high * 100 + low).toArray();
}
Python
Python 内置 hash() 与 Java 字符串哈希不同;负数取余的规则也不同。以下 Python 3.10+ 示例保留 Java 的 UTF-16 字符单元、32位溢出和余数符号,得到相同的分区值。日期参数采用与写入端一致的业务时区。
from datetime import date
BLANK_CHARS = (
set(range(0x0009, 0x000E)) | set(range(0x001C, 0x0020))
| set(range(0x2000, 0x200B))
| {0x0020, 0x00A0, 0x1680, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000,
0xFEFF, 0x202A, 0x0000, 0x3164, 0x2800, 0x180E}
)
def blank(value: str | None) -> bool:
return value is None or all(ord(c) in BLANK_CHARS for c in value)
def signed32(value: int) -> int:
value &= 0xFFFFFFFF
return value if value < 0x80000000 else value - 0x100000000
def java_hash(value: str) -> int:
raw = value.encode("utf-16-be", errors="surrogatepass")
h = 0
for i in range(0, len(raw), 2):
h = signed32(31 * h + (raw[i] << 8) + raw[i + 1])
return h
def java_long_hash(value: int) -> int:
value &= 0xFFFFFFFFFFFFFFFF
return signed32(value ^ (value >> 32))
def java_remainder(value: int, divisor: int) -> int:
result = abs(value) % divisor
return -result if value < 0 else result
def install_partition(mac: str, cpu: str | None) -> int:
key = mac + ("" if blank(cpu) else cpu)
return abs(java_remainder(java_hash(key), 150)) + 1
def activity_partition(mac: str | None, cpu: str | None) -> int:
key = ("" if blank(mac) else mac) + ("" if blank(cpu) else cpu)
return abs(java_remainder(java_hash(key), 1024))
def install_table(received_day: date) -> str:
return "app_install_device" + received_day.strftime("%Y%m")
def activity_table(package_name: str, received_day: date) -> str:
return "app_activity_log_" + package_name.replace(".", "_") + "_" + received_day.strftime("%Y%m%d")
def rule_partition(rule_or_resource_id: int, mac: str) -> int:
high = abs(java_remainder(java_long_hash(rule_or_resource_id), 10)) + 10
low = abs(java_remainder(java_hash(mac), 30)) + 10
return high * 100 + low
def rule_partitions_by_id(value: int) -> list[int]:
high = abs(java_remainder(java_long_hash(value), 10)) + 10
return [high * 100 + low for low in range(10, 40)]
def rule_partitions_by_mac(mac: str) -> list[int]:
low = abs(java_remainder(java_hash(mac), 30)) + 10
return [high * 100 + low for high in range(10, 20)]
把分区值放入查询条件
查询某月某设备的安装快照,先计算150分区值,再同时提供 mac、cpu_id 和 partition_index,对应联合唯一索引 mac_cpu(mac,cpu_id,partition_index):
SELECT mac, cpu_id, apps
FROM ik_app_install.app_install_device202609
WHERE mac = :mac AND cpu_id <=> :cpu AND partition_index = :partition_index;
CPU查询参数保留原存储值。分区算法把空白CPU用于哈希时视为空串,不会改变表中保存的CPU;<=> 同时支持 NULL 与非NULL值匹配。
查规则内的某台设备,使用ID+MAC计算的单个分区值;只按规则ID导出设备时,使用该ID对应的30个分区值,避免遍历全部300个分区。资源包明细同理,将 liteflow_chain_id 换成 mac_resource_id。
SELECT mac
FROM ik_rule.rule_mac_item
WHERE liteflow_chain_id = :rule_id
AND partition_index IN (:p1, :p2, :p3 /* 展开完整30个分区值 */);
活跃原始日志先确定包名和服务端接收日,再带 partition_index、MAC、CPU及所需时间条件。日期跨天时逐日选择目标表,不能把设备上报时间直接当成接收日选表。
活跃日志分区读取还使用 PARTITION(pN) 显式选择分区,N 为上述1024分区算法的结果。例如计算结果为42时,查询目标日表的 PARTITION(p42)。表名和分区名由固定规则生成;设备值和时间值使用参数绑定。
KEY 分区:由 MySQL 按设备字段分配
device_log_file 使用 KEY(mac,cpuid) 32分区,apk_push_history 使用同一分区字段的16分区。查询尽量同时提供 mac、cpuid,并限定业务时间范围。KEY 的哈希由 MySQL 计算,应用传入设备字段即可。MySQL KEY 分区说明
设备日志查询使用 idx_mac_cpuid_create_time(mac,cpuid,create_time):
String sql = "SELECT id, create_time FROM device_log_file "
+ "WHERE mac = ? AND cpuid = ? AND create_time >= ? AND create_time < ?";
try (var statement = connection.prepareStatement(sql)) {
statement.setString(1, mac);
statement.setString(2, cpu);
statement.setTimestamp(3, fromTime);
statement.setTimestamp(4, toTime);
try (var rows = statement.executeQuery()) {
while (rows.next()) {
long id = rows.getLong("id");
// 处理当前范围内的日志文件元数据。
}
}
}
sql = (
"SELECT id, create_time FROM device_log_file "
"WHERE mac = %s AND cpuid = %s AND create_time >= %s AND create_time < %s"
)
cursor.execute(sql, (mac, cpu, from_time, to_time))
安装包明细的包名分区规则
安装统计任务还定义 app_install_packageYYYYMM 月表,按包名分成100个 LIST 分区。当前库表清单中的独立安装库未列出该月表族;单体遗留库的 app_install_package 为历史结构,查询历史数据时以其保存的 partition_index 为准。
月表规则先查下面的固定包名映射;未命中时计算 abs(Java String.hashCode(packageName) % 59) + 1。包名保留大小写和原始字符。负值分区名为 p0 加绝对值,例如 -1 对应 p01;正值为 p 加数值。查询已生成的月表时,包名条件与分区值一起提供。
包名与固定分区值清单(100项)
| 包名 | 分区值 | 分区名 |
|---|---|---|
| com.android.chrome | -1 | p01 |
| com.google.android.youtube.tv | -2 | p02 |
| com.valor.mfc.droid.tvapp.generic | -3 | p03 |
| com.mm.droid.livetv.tve | -4 | p04 |
| com.netflix.mediaclient | -5 | p05 |
| com.global.unitviptv | -6 | p06 |
| com.droidlogic.mboxlauncher | -7 | p07 |
| com.simple.appstore.flymarket | -8 | p08 |
| com.world.youcinetv | -9 | p09 |
| org.xbmc.kodi | -10 | p010 |
| com.ionitech.airscreen | -11 | p011 |
| com.android.calculator2 | -12 | p012 |
| com.android.rockchip | -13 | p013 |
| com.rockchips.mediacenter | -14 | p014 |
| com.rockchip.wfd | -15 | p015 |
| com.android.apkinstaller | -16 | p016 |
| com.unitvnet.tvod | -17 | p017 |
| org.chromium.webview_shell | -18 | p018 |
| com.nathnetwork.xciptv | -19 | p019 |
| com.ktcp.osvideo | -20 | p020 |
| com.cloudinfinitegroup.skit | -21 | p021 |
| com.droidlogic.mediacenter | -22 | p022 |
| com.droidlogic.mboxlauncher.rk | -23 | p023 |
| com.iron.allapp | -24 | p024 |
| com.netflix.ninja | -25 | p025 |
| com.home.cast.dlna.renderer | -26 | p026 |
| com.mm.droid.livetv.tvees | -27 | p027 |
| com.google.android.googlequicksearchbox | -28 | p028 |
| com.google.android.videos | -29 | p029 |
| com.amazon.avod.thirdpartyclient | -30 | p030 |
| com.esaba.downloader | -31 | p031 |
| com.android.mgstv | -32 | p032 |
| com.cloudinfinitegroup.xinfoapp | -33 | p033 |
| com.google.android.youtube | -34 | p034 |
| com.cloudinfinitegroup.xgamesapp | -35 | p035 |
| com.charon.rocketfly | -36 | p036 |
| com.facebook.katana | -37 | p037 |
| link.ntdev.ntdw | -38 | p038 |
| com.okshopping.video | -39 | p039 |
| com.disney.disneyplus | -40 | p040 |
| com.youku.intl.tv | -41 | p041 |
| com.twitter.android | 1 | p1 |
| com.spotify.tv.android | 2 | p2 |
| com.skype.raider | 3 | p3 |
| com.uv.droid.launcher.mxqlauncher | 4 | p4 |
| com.mm.droid.livetv.bluetv | 5 | p5 |
| com.tiktok.tv | 6 | p6 |
| tv.pluto.android | 7 | p7 |
| cm.aptoidetv.pt | 8 | p8 |
| com.android.mgandroid | 9 | p9 |
| com.spotify.music | 10 | p10 |
| cm.aptoide.pt | 11 | p11 |
| com.yablio.sendfilestotv | 12 | p12 |
| com.android.tv | 13 | p13 |
| com.mxtech.videoplayer.ad | 14 | p14 |
| com.integration.unitviptv | 15 | p15 |
| com.hbo.hbonow | 16 | p16 |
| com.bladetv.android | 17 | p17 |
| com.mm.droid.livetv.redplaybox | 18 | p18 |
| com.nst.iptvsmarterstvbox | 19 | p19 |
| com.google.android.youtube.tvkids | 20 | p20 |
| com.devcoder.iptvxtreamplayer | 21 | p21 |
| com.deep.cast.dlna.renderer | 22 | p22 |
| com.bbqbar.browser | 23 | p23 |
| com.globo.globotv | 24 | p24 |
| com.cbs.ca | 25 | p25 |
| com.dots.color.connect.puzzle.neon.line | 26 | p26 |
| com.digitalseva.iptvplayer | 27 | p27 |
| com.ak.allapp | 28 | p28 |
| com.plexapp.android | 29 | p29 |
| org.videolan.vlc | 30 | p30 |
| com.mm.droid.livetv.wakatv | 31 | p31 |
| com.iptvBlinkPlayer | 32 | p32 |
| com.banglalink.toffeetv | 33 | p33 |
| tv.twitch.android.app | 34 | p34 |
| com.whatsapp | 35 | p35 |
| com.rmdigital.tvs62 | 36 | p36 |
| com.sea.movie.ad | 37 | p37 |
| com.google.android.gm | 38 | p38 |
| io.wareztv.android.one | 39 | p39 |
| com.nst.smartersplayer | 40 | p40 |
| com.blade.phx5 | 41 | p41 |
| com.wbd.stream | 42 | p42 |
| com.google.android.apps.youtube.kids | 43 | p43 |
| com.alphainventor.filemanager | 44 | p44 |
| com.cetusplay.remoteservice | 45 | p45 |
| com.tvs.phx5 | 46 | p46 |
| com.newbraz.p2p | 47 | p47 |
| zank.remote | 48 | p48 |
| com.droidlogic.mboxlauncher.wetv | 49 | p49 |
| com.p2elite.brtv2 | 50 | p50 |
| com.batanga.vixtv | 51 | p51 |
| com.teamsmart.videomanager.tv | 52 | p52 |
| com.beesp2p.phx5.lite | 53 | p53 |
| com.bongo.bioscope | 54 | p54 |
| com.divergentftb.xtreamplayeranddownloader | 55 | p55 |
| com.instantbits.cast.receiver | 56 | p56 |
| updata.com.ik.updataversion | 57 | p57 |
| com.instagram.android | 58 | p58 |
| com.centralp2p.plus | 59 | p59 |
Java:fixedPartitions 按上表初始化;取值优先于哈希回退。
static int packagePartition(String packageName, java.util.Map<String, Integer> fixedPartitions) {
Integer fixed = fixedPartitions.get(packageName);
return fixed != null ? fixed : Math.abs(packageName.hashCode() % 59) + 1;
}
static String packagePartitionName(int partition) {
return partition < 0 ? "p0" + Math.abs(partition) : "p" + partition;
}
Python:复用前面的 java_hash、java_remainder,fixed_partitions 按上表初始化。
def package_partition(package_name: str, fixed_partitions: dict[str, int]) -> int:
if package_name in fixed_partitions:
return fixed_partitions[package_name]
return abs(java_remainder(java_hash(package_name), 59)) + 1
def package_partition_name(partition: int) -> str:
return "p0" + str(abs(partition)) if partition < 0 else "p" + str(partition)
MySQL:具体查询对应哪个索引
| 查询场景 | 实际索引及列顺序 | 边界 |
|---|---|---|
| 精确找设备 | base_device.mac_cpu(mac,cpu),唯一 | 单 MAC 不一定唯一;渠道、型号、激活时间另外建有单独索引 |
| 某月某设备的安装快照 | mac_cpu(mac,cpu_id,partition_index),唯一 | 先筛设备再展开 apps;apps 未定义包名 JSON 路径索引 |
| 设备日志时间线 | idx_mac_cpuid_create_time(mac,cpuid,create_time) | MAC、CPUID 等值后限制时间;只按时间另有 idx_create_time(create_time) |
| 某任务的推送成功记录 | flow_task_device.idx_ftd_task_id_push_success(task_id,is_push_success) | “推送成功”不等于安装运行成功 |
| 某设备某任务的记录 | flow_task_device.mac(mac,task_id),唯一 | 设备条件在前,与按任务聚合是不同查询方向 |
| 应用运行排行榜 | stats_app_runtime_top.key(date,package_name,type,version_code),唯一;另有 date(date) | 只有日期+类型不能当作前两列连续定位;包名包含匹配不能当等值 |
| 报表数据源编码 | uk_tenant_code(tenant_id,code),唯一 | 不含 deleted,逻辑删除不会释放编码 |
| 黑名单卸载明细 | blacklisted_id(blacklisted_id,mac,cpu,event_id,device_time),唯一 | create_time 不在索引内;末尾可空字段也影响去重语义 |
| APK 推送历史 | uk_mac_cpuid_package_version(mac,cpuid,package_name,version_code_key,push_task_time_key),唯一 | 用两个生成列归一空值;主表没有 PRIMARY,不把名为 id 的唯一索引写成主键 |
联合索引查询优先使用左侧连续列,等值条件之后再安排范围条件。新增索引应结合查询频率、扫描范围和写入成本。MySQL 多列索引说明
原始活跃日表仅 PRIMARY(id,partition_index)。即使已选对日表和分区,分区内按 MAC、CPU、接收时间筛选排序仍缺少对应二级索引。Task 独立库的 8 张表、BPM 的 9 张业务表也均只有主键,非主键筛选应限制查询日期与结果范围,大数据量场景应配置匹配业务条件的索引。
TDengine:查询必须限定时间范围
| 对象 | 首时间列 | 主要标签 | 范围与业务口径 |
|---|---|---|---|
flow_app_runtime | record_time | package_name、day | 设备上报的运行时间;当月和上月查热库,更早查归档;跨冷热边界拆开查询,日期差不超过两个月 |
app_activity_detail | ts | package_name、record_time | 日/月/年分别选库;月首、年首对齐。当前业务窗口:日最多30天、月730天、年3650天 |
device_activity_detail | ts | record_time、region_id | 区分上报来源和周期,同设备跨来源不能直接累加为去重设备数 |
device_runtime_detail | start_time | mac、cpu | 查设备运行区间,考虑补报;不是接收时间范围 |
launcher_ad_play_detail | begin_time | 包名、版本、广告位置、周期及分类等 | 明确业务时区和埋点含义,不把所有位置状态都当播放成功 |
launcher_ad_play_device_count | record_time | 包名、版本、位置、time_tag、分类 | time_tag 日/月/年分别为 yyyyMMdd/yyyyMM/yyyy;选汇总粒度后再查询 |
launcher_exec_record | ts | ad_resource_id、type | ts 为 执行时间,d_time 为设备上报时间,create_time 为服务接收时间 |
app_kill_record | ts | black_list_id | 按策略标签、日期及设备条件筛选;SUM(frequency) 与 COUNT(*) 含义不同 |
server_monitor | ts | server_name | 单台服务器、明确时间窗口,累计指标和瞬时指标分开解释 |
业务查询窗口按表中限制设置。以 2026-09-18 为例,APP运行的8—9月走热库,7月及以前走归档库;冷热范围按自然月划分。
建议对首时间列使用明确起止范围,再加实际存在的标签条件;周期汇总优先查对应日/月/年结构。LIMIT 只约束返回结果,不替代扫描和聚合前的范围限制。标签过滤与时间过滤的基本用途见 TDengine 数据查询。
Elasticsearch:选对月份和字段类型
设备大批量筛选使用 base_device;安装列表使用 app_install_deviceYYYYMM。它们承担检索,MySQL保留档案或快照,不能默认两端任何时刻都完全同步。APP运行明细使用TDengine,安装与运行是两类数据。
精确MAC、CPU或包名过滤,应使用该索引实际存在的 keyword 或 keyword 子字段;text 的分词匹配不是完整值相等。keyword 子字段存在 ignore_above 长度限制,不能默认所有长字符串都能精确检索。ES keyword 字段说明
各月安装 mapping 不完全相同:部分月份时间与CPU字段由 date/keyword 变为 text+keyword,跨月查询前先核对对应字段。apps 是JSON字符串,不存在 apps.packageName nested 查询路径;整串匹配不能代替“安装了某包名”的结构化筛选。
ee_default_alias 混有非安装对象且没有覆盖所有月份,不能代表完整安装历史。23个业务索引均为1个主分片。深分页或大批量导出应另设计稳定排序和分批范围,不能仅把页大小调大。