MongoDB日期存储与查询、@Query、嵌套字段查询实战总结
缘由
MongoDB数据库如下:

如上截图,使用MongoDB客户端工具DataGrip,在filter过滤框输入{ 'profiles.alias': '逆天子', 'profiles.channel': '' },即可实现昵称和渠道多个嵌套字段过滤查询。
现有业务需求:用Java代码来查询指定渠道和创建日期在指定时间区间范围内的数据。
注意到creationDate是一个一级字段(方便理解),profiles字段和creationDate属于同一级,是一个数组,而profiles.channel是一个嵌套字段。
Java应用程序查询指定渠道(通过@Query注解profiles.channel)和指定日期的数据,Dao层(或叫Repository层)接口Interface代码如下:
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;@Repository
public interface AccountRepository extends MongoRepository<Account, String> {@Query("{ 'profiles.channel': ?0 }")List<Account> findByProfileChannelAndCreationDateBetween(String channel, Date start, Date end);
}
单元测试代码如下:
@Test
public void testFindByProfileChannelAndCreationDateBetween() {String time = "2024-01-21";String startTime = time + DateUtils.DAY_START;String endTime = time + DateUtils.DAY_END;Date start = new Date();Date end = new Date();try {start = DateUtils.parseThenUtc(startTime);end = DateUtils.parseThenUtc(endTime);} catch (ParseException e) {log.error("test failed: {}", e.getMessage());}List<Account> accountList = accountRepository.findByProfileChannelAndCreationDateBetween(ChannelEnum.DATONG_APP.getChannelCode(), start, end);log.info("size:{}", accountList.size());
}
输出如下:size:70829。
没有报错,但是并不能说明没有问题。根据自己对于业务的理解,数据量显然不对劲,此渠道的全量数据是这么多才差不多。
也就是说,上面的Interface接口查询方法,只有渠道条件生效,日期没有生效??
至于为什么没有生效,请继续往下看。想看结论的直接翻到文末。
排查
不生效
MongoRepository是Spring Data MongoDB提供的,继承MongoRepository之后,就可以使用IDEA的智能提示快速编写查询方法。如下图所示:

但是:上面的这种方式只能对一级字段生效。如果想要过滤查询嵌套字段,则派不上用场。
此时,需要使用一个更强大的@Query注解。
但是,@Query和JPA方式不能一起使用。也就是上面的方法findByProfileChannelAndCreationDateBetween查询方法,经过简化后只保留一级字段,然后嵌套字段使用@Query方式:
@Query("{ 'profiles.channel': ?0 }")
List<Account> findByCreationDateBetween(String channel, Date s1, Date s2);
依旧是不生效的。
版本1
基于上面的结论,有一版新的写法:
@Query("{ 'profiles.channel': ?0, 'creationDate': {$gte: ?1, $lte: ?2} }")
List<Account> findByChannelAndCreationDate(String channel, Date start, Date end);
此时输出:size:28。这个数据看起来才比较正常(虽然后面的结论证明不是正确的)。
WARN告警
如果不过滤渠道呢?查询某个日期时间段内所有渠道的全量用户数据?
两种写法都可以:
long countByCreationDateBetween(Date start, Date end);@Query("{ 'creationDate': {$gte: ?0, $lte: ?1} }")
long countByCreationDate(Date start, Date end);
等等。怎么第一种写法,IDEA给出一个WARN??

MongoDB日期
上面IDEA给出的Warning显而易见。因为MongoDB数据库字段定义是Instant类型:
@Data
@Document
public class Account {@Idprotected String key;private Instant creationDate = Instant.now();private List<Profile> profiles = new ArrayList<>();private boolean firstTimeUsage = true;
}
IDEA作为宇宙最强IDE,给出WARN自然是有道理的。
作为一个代码洁癖症患者,看到IDEA的shi黄色告警,无法忍受。假设IDEA告警没有问题(极端少数情况下,IDEA告警也有可能误报,参考记一次Kotlin Visibility Modifiers引发的问题),为了消除告警,有两种方式:
- 修改Account数据库实体类creationDate类型定义,Instant改成Date
- Repository层接口方法不使用Date类型传参,而使用Instant类型传参。
那到底应该怎么修改呢?才能屏蔽掉IDEA的shi黄色告警WARN呢??
单元测试
数据库持久化实体PO类日期字段类型定义,到底该使用Date还是Instant类型呢??
在Google搜索关键词MongoDB日期的同时,不妨写点单元测试来执行一下。(注:此时此处行文看起来思路挺清晰,但在遇到陌生的问题是真的是无头苍蝇)
在保持数据库PO实体类日期字段类型定义不变的前提下,有如下两个查询Interface方法:
long countByCreationDateBetween(Date start, Date end);@Query("{ 'creationDate': {$gte: ?0, $lte: ?1} }")
long countByCreationDate(Instant start, Instant end);
单元测试:
@Resource
private MongoTemplate mongoTemplate;
@Resource
private IAccountRepository accountRepository;@Test
public void testCompareDateAndInstant() {String time = "2024-01-21";String startTime = time + DateUtils.DAY_START;String endTime = time + DateUtils.DAY_END;Date start = new Date();Date end = new Date();try {start = DateUtils.parseThenUtc(startTime);end = DateUtils.parseThenUtc(endTime);} catch (ParseException e) {log.error("testCompareDateAndInstant failed: {}", e.getMessage());}Criteria criteria = Criteria.where("creationDate").gte(start).lte(end);long count1 = mongoTemplate.count(new Query(criteria), Account.class);// idea warnlong count2 = accountRepository.countByCreationDateBetween(start, end);long count3 = accountRepository.countByCreationDate(DateUtils.getInstantFromDateTimeString(startTime), DateUtils.getInstantFromDateTimeString(endTime));long count4 = accountRepository.countByCreationDate(DateUtils.parse(startTime).toInstant(), DateUtils.parse(endTime).toInstant());log.info("date:{},count1:{},count2:{},count3:{},count4:{}", time, count1, count2, count3, count4);
}
单元测试执行后打印输出:date:2024-01-21,count1:35,count2:35,count3:32,count4:29。
换几个不同的日期,count1和count2都是一致的。也就是说,不管是使用Template,还是Repository方式,使用Date类型日期查询MongoDB数据,结果是一样的。count3和count4使用Instant类型查询MongoDB数据,结果不一致,并且和Date类型不一致。
为啥呢??
Instant vs Date
MongoDB中的日期使用Date类型表示,在其内部实现中采用一个64位长的整数,该整数代表的是自1970年1月1日零点时刻(UTC)以来所经过的毫秒数。Date类型的数值范围非常大,可以表示上下2.9亿年的时间范围,负值则表示1970年之前的时间。
MongoDB的日期类型使用UTC(Coordinated Universal Time)进行存储,也就是+0时区的时间。我们处于+8时区(北京标准时间),因此真实时间值比ISODate(MongoDB存储时间)多8个小时。也就是说,MongoDB存储的时间比ISODate早8小时。
验证8小时
通过DataGrip查看数据库集合字段类型是ISODate:

其格式是yyyy-MM-ddTHH:mm:ss.SSSZ:

然后再看看时区问题。
同一个用户产生的数据(用户唯一ID都是65af62bee13f080008816500),在MySQL和MongoDB里都有记录。
MySQL数据如下(因为涉及敏感信息,截图截得比较小,熟悉DataGrip的同学,看到Tx: Auto,应该不难猜到就是MySQL):

而MongoDB记录的数据如下(同样也是出于截图敏感考虑,主流数据库里使用到ObjectId的应该不多吧,MongoDB是一个):

不难发现。MySQL里记录的数据比MongoDB里记录的数据晚8小时,也是一个符合实际的数据。
PS:此处的所谓符合实际,指的是符合用户习惯,我们App是一款低频App,极少有用户在半夜或凌晨使用,而MongoDB里则记录着大量凌晨的数据,实际上应该是北京时间早上的用户使用记录和数据。
从上面两个截图来看,虽然有打码处理,但依稀可以看到确实(参考下面在线加解密工具网站)是同一个用户(手机号)产生的两个不同数据库(MySQL及MongoDB)数据。
证明:MongoDB里存储的数据确实比MySQL的数据早8小时。
解决方案
PO实体类保持Instant类型不变,Repository层Interface接口方法传参Instant。平常使用的Date如何转换成Instant呢?
直接toInstant()即可,也就是上面的单元测试里面的第四种方式。方法定义:
/*** 加不加Query注解都可以。* 加注解的话,方法名随意,见名知意即可。* 不加注解的话,则需要保证查询字段是MongoDB一级字段,并且满足JPA约定大于配置规范。*/
@Query("{ 'creationDate': {$gte: ?0, $lte: ?1} }")
long countByCreationDate(Instant start, Instant end);
查询方法:
long count = accountRepository.countByCreationDate(DateUtils.parse(startTime).toInstant(), DateUtils.parse(endTime).toInstant());
源码分析
Date.toInstant()源码
private transient BaseCalendar.Date cdate;
private transient long fastTime;public Instant toInstant() {return Instant.ofEpochMilli(getTime());
}/*** Returns the number of milliseconds since January 1, 1970, 00:00:00 GMT* represented by this Date object.*/
public long getTime() {return getTimeImpl();
}private final long getTimeImpl() {if (cdate != null && !cdate.isNormalized()) {normalize();}return fastTime;
}private final BaseCalendar.Date normalize() {if (cdate == null) {BaseCalendar cal = getCalendarSystem(fastTime);cdate = (BaseCalendar.Date) cal.getCalendarDate(fastTime,TimeZone.getDefaultRef());return cdate;}// Normalize cdate with the TimeZone in cdate first. This is// required for the compatible behavior.if (!cdate.isNormalized()) {cdate = normalize(cdate);}// If the default TimeZone has changed, then recalculate the// fields with the new TimeZone.TimeZone tz = TimeZone.getDefaultRef();if (tz != cdate.getZone()) {cdate.setZone(tz);CalendarSystem cal = getCalendarSystem(cdate);cal.getCalendarDate(fastTime, cdate);}return cdate;
}
Instant.java源码:
/*** Constant for the 1970-01-01T00:00:00Z epoch instant.*/
public static final Instant EPOCH = new Instant(0, 0);public static Instant ofEpochMilli(long epochMilli) {long secs = Math.floorDiv(epochMilli, 1000);int mos = Math.floorMod(epochMilli, 1000);return create(secs, mos * 1000_000);
}
private static Instant create(long seconds, int nanoOfSecond) {if ((seconds | nanoOfSecond) == 0) {return EPOCH;}if (seconds < MIN_SECOND || seconds > MAX_SECOND) {throw new DateTimeException("Instant exceeds minimum or maximum instant");}return new Instant(seconds, nanoOfSecond);
}
附
敏感数据加解密
上面截图,MySQL表里,对手机号没有加密处理,直接明文存储;而在MongoDB数据库里,则进行ECB加密。加密工具类略,
此处,附上一个好用的在线加密工具网站,可用于加密手机号等比较敏感的数据,编码一般选择Base64,位数、模式、填充、秘钥等信息和工具类保持一致(除密钥外,一般都是默认):

工具类
DateUtils.java工具类源码如下
public static final String DAY_START = " 00:00:00";
public static final String DAY_END = " 23:59:59";
public static final String DATE_FULL_STR = "yyyy-MM-dd HH:mm:ss";/*** 使用预设格式提取字符串日期** @param date 日期字符串*/
public static Date parse(String date) {return parse(date, DATE_FULL_STR);
}/*** 不建议使用,1945-09-01 和 1945-09-02 with pattern = yyyy-MM-dd 得到不一样的时间数据,* 前者 CDT 后者 CST* 指定指定日期字符串*/
public static Date parse(String date, String pattern) {SimpleDateFormat df = new SimpleDateFormat(pattern);try {return df.parse(date);} catch (ParseException e) {log.error("parse failed", e);return new Date();}
}public static Date parseThenUtc(String date, String dateFormat) throws ParseException {SimpleDateFormat format = new SimpleDateFormat(dateFormat);Date start = format.parse(date);Calendar calendar = Calendar.getInstance();calendar.setTime(start);calendar.add(Calendar.HOUR, -8);return calendar.getTime();
}/*** 减 8 小时*/
public static Date parseThenUtc(String date) throws ParseException {return parseThenUtc(date, DATE_FULL_STR);
}
中文解析
SimpleDateFormat,作为Java开发中最常用的API之一。
你真的熟悉吗?
线程安全问题?
是否支持中文日期解析呢?
具体来说,是否支持如yyyy年MM月dd日格式的日期解析?
测试程序:
public static void main(String[] args) {log.info(getNowTime("yyyy年MM月dd日"));
}public static String getNowTime(String type) {SimpleDateFormat df = new SimpleDateFormat(type);return df.format(new Date());
}
打印输出如下:
2024年01月23日
结论:SimpleDateFormat支持对中文格式的日期进行解析。
看一下SimpleDateFormat的构造函数源码:
public SimpleDateFormat(String pattern) {this(pattern, Locale.getDefault(Locale.Category.FORMAT));
}
继续深入查看Locale.java源码:
private static Locale initDefault(Locale.Category category) {Properties props = GetPropertyAction.privilegedGetProperties();return getInstance(props.getProperty(category.languageKey,defaultLocale.getLanguage()),props.getProperty(category.scriptKey,defaultLocale.getScript()),props.getProperty(category.countryKey,defaultLocale.getCountry()),props.getProperty(category.variantKey,defaultLocale.getVariant()),getDefaultExtensions(props.getProperty(category.extensionsKey, "")).orElse(defaultLocale.getLocaleExtensions()));
}
大概得知:SimpleDateFormat对于本地化语言的支持是通过Locale国际化实现的。
ISODate
另外在使用SimpleDateFormat解析这种时间时需要对T和Z加以转义。
public static final String FULL_UTC_STR = "yyyy-MM-dd'T'HH:mm:ss'Z'";
public static final String FULL_UTC_MIL_STR = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'";public static String getBirthFromUtc(String dateStr) {SimpleDateFormat df = new SimpleDateFormat(FULL_UTC_STR);try {Date date = df.parse(dateStr);Calendar calender = Calendar.getInstance();calender.setTime(date);calender.add(Calendar.HOUR, 8);return date2Str(calender.getTime(), DATE_SMALL_STR);} catch (ParseException e) {throw new RuntimeException(e);}
}
结论
几个结论:
- JPA写法对于单表查询非常简单,借助于IDEA智能提示,可以快速写出查询Interface方法
- JPA很强,但对于关系型数据库的多表Join查询,或MongoDB的嵌套字段查询,则几乎派不上用场
- @Query通过注解的方式可以大大简化API的使用
- @Query写法和JPA写法不能混为一谈
- @Query也不是万能的。必要时,还是得使用QBE,Query By Example,或Query Criteria
参考
- MongoDB进阶与实战:微服务整合、性能优化、架构管理
相关文章:
MongoDB日期存储与查询、@Query、嵌套字段查询实战总结
缘由 MongoDB数据库如下: 如上截图,使用MongoDB客户端工具DataGrip,在filter过滤框输入{ profiles.alias: 逆天子, profiles.channel: },即可实现昵称和渠道多个嵌套字段过滤查询。 现有业务需求:用Java代码来查询…...
Windows版本Node.js常见问题及操作解决方式(小白入门必备)
npm i时ERROR:reason: certificate has expired问题 https://blog.csdn.net/m0_73360677/article/details/135774500 # 1.取消ssl验证;npm config set strict-ssl false#这个方法一般可以直接解决问题,如不能请尝试第二种方法# 2.更换npm镜像源&#x…...
09.Elasticsearch应用(九)
Elasticsearch应用(九) 1.搜索结果处理包括什么 排序分页高亮返回指定字段 2.排序 介绍 Elasticsearch支持对搜索结果排序,默认是根据相关度算分来排序 支持排序的字段 keyword数值地理坐标日期类型 排序语法 GET /[索引名称]/_sear…...
ROS2常用命令工具
ROS2常用命令工具 包管理工具ros2 pkg ros2 pkg create ros2 pkg create --build-type ament_python pkg_name rclpy std_msgs sensor_msgs –build-type : C或者C ament_cmake ,Python ament_python pkg_name :创建功能包的名字 rclpy std_msgs sens…...
Linux之快速入门
一、Linux目录结构 从Windows转到Linux最不习惯的是什么: 目录结构 Windows会分盘,想怎么放东西就怎么放东西,好处自由,缺点容易乱 Linux有自己的目录结构,不能随随便便放东西 /:根目录/bin:二进制文件&…...
C语言——操作符详解1
目录 1. 操作符的分类2. 二进制和进制转换2.1 二进制的概念2.2 二进制转十进制2.3 十进制转二进制2.4 二进制转八进制和十六进制2.4.1 二进制转八进制二进制转十六进制 3. 原码、反码和补码4. 移位操作符4.1 左移操作符4.2 右移操作符 5. 位操作符5.1 &5.2 |5.3 ^5.4 ~ 1. …...
C++学习| QT快速入门
QT简单入门 QT Creater创建QT项目选择项目类型——不同项目类型的区别输入项目名字和路径选择合适的构建系统——不同构建系统的却别选择合适的类——QT基本类之间的关系Translation File选择构建套件——MinGW和MSVC的区别 简单案例:加法器设计界面——构建加法器界…...
Android App开发-简单控件(1)——文本显示
本章介绍了App开发常见的几类简单控件的用法,主要包括:显示文字的文本视图、容纳视图的常用布局、响应点击的按钮控件、显示图片的图像视图等。然后结合本章所涉及的知识,完成一个实战项目“简单计算器”的设计与实现。 1.1 文本显示 本节介绍…...
[GYCTF2020]Ezsqli1
打开环境,下面有个提交表单 提交1,2有正确的查询结果,3以后都显示Error Occured When Fetch Result. 题目是sql,应该考察的是sql注入 简单fuzz一下 发现information_schema被过滤了,猜测是盲注了。 测试发现只要有东…...
【npm包】如何发布自己的npm包
随着Node.js的普及,npm(Node Package Manager)已成为JavaScript开发者中不可或缺的一部分。发布自己的npm包,不仅可以将自己的项目分享给更多人,还可以为社区做出贡献。本文将详细介绍如何从零开始发布自己的npm包。 …...
《WebKit技术内幕》学习之十五(2):Web前端的未来
2 嵌入式应用模式 2.1 嵌入式模式 读者可能会奇怪本章重点表达的是Web应用和Web运行平台,为什么会介绍嵌入式模式(Embedded Mode)呢?这是因为很多Web运行平台是基于嵌入式模式的接口开发出来的,所以这里先解释一下什…...
【教学类-综合练习-11】20240116 大4班 最后一次
只有图片 加了两条链接 背景需求 年终了,清理库存,各种打印的题型纸都拿出来,当个别化学习材料 教学过程: 时间:2024年1月5日下午 班级:大4班(额外带班 真正的最后一次大班) 人…...
【阻塞队列】阻塞队列的模拟实现及在生产者和消费者模型上的应用
文章目录 📄前言一. 阻塞队列初了解🍆1. 什么是阻塞队列?🍅2. 为什么使用阻塞队列?🥦3. Java标准库中阻塞队列的实现 二. 阻塞队列的模拟实现🍚1. 实现普通队列🍥2. 实现队列的阻塞功…...
Cocos Creator使用VS Code调试代码配置
创建项目 首先我们先打开cocos创建一个项目 随便添加一个Cube和脚本,然后保存场景: 添加Chrome Debug配置 在Cocos 中选择添加Chrome Debug配置 然后再VS Code中就可以看到有一个cocos launch Chrome: 然后,就可以按快捷键F…...
【投稿优惠|EI优质会议】2024年材料化学与清洁能源国际学术会议(IACMCCE 2024)
【投稿优惠|优质会议】2024年材料化学与清洁能源国际学术会议(IACMCCE 2024) 2024 International Conference Environmental Engineering and Mechatronics Integration(ICEEMI 2024) 一、【会议简介】 随着全球能源需求的不断增长,清洁能源的研究与应用成为了国际…...
ubuntu设置右键打开terminator、code
前言: 这里介绍一种直接右键打开本地目录下的terminator和vscode的方法。 一:右键打开terminator 1.安装terminator sudo apt install terminator 2.安装nautilus-actions filemanager-actions sudo apt-get install nautilus-actions filemanager…...
PHP AES加解密:用代码为数据加上保护的盾牌
在网络世界里,数据的传输和存储是一个敏感而重要的问题。为了保护数据的安全性,加密算法是一项不可或缺的技术。而在PHP中,AES(Advanced Encryption Standard)加解密算法是一种常用的选择。本篇博客将深入解析PHP中的A…...
Socket实现服务器和客户端
Socket 编程是一种用于在网络上进行通信的编程方法,以下代码可以实现在不同主机之间传输数据。 Socket 编程中服务器端和客户端的基本步骤:服务器端步骤: 1.创建 Socket: int serverSocket socket(AF_INET, SOCK_STREAM, 0);…...
智能GPT图书管理系统(SpringBoot2+Vue2)、接入GPT接口,支持AI智能图书馆
☀️技术栈介绍 ☃️前端主要技术栈 技术作用版本Vue提供前端交互2.6.14Vue-Router路由式编程导航3.5.1Element-UI模块组件库,绘制界面2.4.5Axios发送ajax请求给后端请求数据1.2.1core-js兼容性更强,浏览器适配3.8.3swiper轮播图插件(快速实…...
面试经典 150 题 ---- 合并两个有序数组
面试经典 150 题 ---- 合并两个有序数组 合并两个有序数组方法一:直接合并后排序方法二:双指针方法三:逆向双指针 合并两个有序数组 方法一:直接合并后排序 这种方法最简单,直接将 nums2 的数组放到 nums1 数组的尾部…...
web vue 项目 Docker化部署
Web 项目 Docker 化部署详细教程 目录 Web 项目 Docker 化部署概述Dockerfile 详解 构建阶段生产阶段 构建和运行 Docker 镜像 1. Web 项目 Docker 化部署概述 Docker 化部署的主要步骤分为以下几个阶段: 构建阶段(Build Stage):…...
【力扣数据库知识手册笔记】索引
索引 索引的优缺点 优点1. 通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。2. 可以加快数据的检索速度(创建索引的主要原因)。3. 可以加速表和表之间的连接,实现数据的参考完整性。4. 可以在查询过程中,…...
day52 ResNet18 CBAM
在深度学习的旅程中,我们不断探索如何提升模型的性能。今天,我将分享我在 ResNet18 模型中插入 CBAM(Convolutional Block Attention Module)模块,并采用分阶段微调策略的实践过程。通过这个过程,我不仅提升…...
江苏艾立泰跨国资源接力:废料变黄金的绿色供应链革命
在华东塑料包装行业面临限塑令深度调整的背景下,江苏艾立泰以一场跨国资源接力的创新实践,重新定义了绿色供应链的边界。 跨国回收网络:废料变黄金的全球棋局 艾立泰在欧洲、东南亚建立再生塑料回收点,将海外废弃包装箱通过标准…...
涂鸦T5AI手搓语音、emoji、otto机器人从入门到实战
“🤖手搓TuyaAI语音指令 😍秒变表情包大师,让萌系Otto机器人🔥玩出智能新花样!开整!” 🤖 Otto机器人 → 直接点明主体 手搓TuyaAI语音 → 强调 自主编程/自定义 语音控制(TuyaAI…...
pikachu靶场通关笔记22-1 SQL注入05-1-insert注入(报错法)
目录 一、SQL注入 二、insert注入 三、报错型注入 四、updatexml函数 五、源码审计 六、insert渗透实战 1、渗透准备 2、获取数据库名database 3、获取表名table 4、获取列名column 5、获取字段 本系列为通过《pikachu靶场通关笔记》的SQL注入关卡(共10关࿰…...
中医有效性探讨
文章目录 西医是如何发展到以生物化学为药理基础的现代医学?传统医学奠基期(远古 - 17 世纪)近代医学转型期(17 世纪 - 19 世纪末)现代医学成熟期(20世纪至今) 中医的源远流长和一脉相承远古至…...
HDFS分布式存储 zookeeper
hadoop介绍 狭义上hadoop是指apache的一款开源软件 用java语言实现开源框架,允许使用简单的变成模型跨计算机对大型集群进行分布式处理(1.海量的数据存储 2.海量数据的计算)Hadoop核心组件 hdfs(分布式文件存储系统)&a…...
基于 TAPD 进行项目管理
起因 自己写了个小工具,仓库用的Github。之前在用markdown进行需求管理,现在随着功能的增加,感觉有点难以管理了,所以用TAPD这个工具进行需求、Bug管理。 操作流程 注册 TAPD,需要提供一个企业名新建一个项目&#…...
IP如何挑?2025年海外专线IP如何购买?
你花了时间和预算买了IP,结果IP质量不佳,项目效率低下不说,还可能带来莫名的网络问题,是不是太闹心了?尤其是在面对海外专线IP时,到底怎么才能买到适合自己的呢?所以,挑IP绝对是个技…...
