写在前面:经过一个月的实习,我逐渐觉得沟通能力和理解能力远比代码能力重要。不论是在科研组会上的哑口无言,还是在工作上对接的七嘴八舌,都暴露出了我语言能力的欠缺。我从初中开始就知道这是我的短板,但没想到有这么短。这也再次给我敲响了警钟,不管你是什么学术或者技术大师,学说话和听说话是最基本的。

需求7:【出海项目】ip限流

注解+aop+lua+redis+滑动窗口限流算法

注解和aop

注解

根据多个时间单位配置限流器,定义了时间枚举类,按照毫秒存储持续时间(因为注解只能用枚举,不能用正常类)。

正常是要传3个值的:次数,时间单位,超时时间(主要是给后面对redis设置expireTime)。这里为了方便超时时间与时间单位一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//使用示例:1分钟限制10次,1小时限制500次
@IpRateLimit(limit = {10,500}, timeUnit = {TimeUnit.MINUTE,TimeUnit.HOUR})


//注解定义
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface IpRateLimit {
int[] limit(); // 限流值
TimeUnit[] timeUnit(); // 时间单位
}

@Getter
@AllArgsConstructor
public enum TimeUnit {
YEAR(1, "YEAR", 1000L * 60 * 60 * 24 * 30 * 365),
MONTH(2, "MONTH", 1000L * 60 * 60 * 24 * 30),
DAY(3, "DAY", 1000 * 60 * 60 * 24),
HOUR(4, "HOUR", 1000 * 60 * 60),
MINUTE(5, "MINUTE", 1000 * 60),
SECOND(6, "SECOND", 1000),
;
private final int code;
private final String name;
//单位是毫秒
private final long periodInMills;
}

aop

校验注解里两个数组长度必须相同,对每一个配置new一个限流器来执行redis脚本。(我在想这里每次限流就new一个开销会不会有点大,但是不能用@service,因为有状态的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Slf4j
@Aspect
@Component
public class IpRateLimiterAspect {

private RedisLuaRateLimiter redisLuaRateLimiter;

@Pointcut("@annotation(com.netease.cowork.sirius.it.data.overseas.server.frame.anno.IpRateLimit)")
public void rateLimiter() {}

@Before("rateLimiter() && @annotation(ipLimiter)")
public void doBefore(JoinPoint joinPoint, IpRateLimit ipLimiter) throws Throwable {
int[] limit = ipLimiter.limit();
TimeUnit[] timeUnits = ipLimiter.timeUnit();
if (limit.length != timeUnits.length){
throw new RuntimeException();
}
// 获取用户ip
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
assert attributes != null;
HttpServletRequest request = attributes.getRequest();
String ip = request.getRemoteAddr();
for (int i = 0; i < limit.length; i++) {
long periodInMills = timeUnits[i].getPeriodInMills();
redisLuaRateLimiter = new RedisLuaRateLimiter(limit[i], periodInMills, periodInMills,timeUnits[i].getName());
if (!redisLuaRateLimiter.tryAcquired(ip)){
throw new RuntimeException("操作频繁,请稍后重试");
}
}
}
}

redis+lua实现滑动窗口限流算法

lua脚本在这里最大的作用就是解决并发问题,且保证原子性。分别说这个几个参数:

  • KEYS[1]:String key = prefix + ip。业务名称+ip。因为可能有多个业务都需要限流。
  • ARGV[1]:now-periodInMills。时间校验的分界线,往后为需要计算的流量。
  • ARGV[2]:now
  • ARGV[3]:UUID.randomUUID()真正的value值,随便写一个
  • ARGV[4]:expireInMills
  • ARGV[5]:limit

基本原理是zset的score存时间戳,ZREMRANGEBYSCORE这个方法会去除0到now-periodInMills的所有元素,剩下的就是要统计的元素。ZCARD用来统计当前元素数量,如果比count小就没有达到限流,ZADD设置value,score和超时时间。如果限流就返回特殊数字。
最后只用判断lua脚本执行是否是1来判断限流。

其他的代码都是java操作lua的方法,核心如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//限流
private static final String REDIS_LIMIT_SCRIPT = "local removedCount = redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])\n" +
"local currentCount = redis.call('ZCARD', KEYS[1])\n" +
"if currentCount < tonumber(ARGV[5]) then\n" +
" redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])\n" +
" redis.call('PEXPIRE', KEYS[1], ARGV[4])\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
//统计rate
private static final String REDIS_LIMIT_COUNT_SCRIPT = "local removedCount = redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])\n" +
"local currentCount = redis.call('ZCARD', KEYS[1])\n" +
"return currentCount";

结论是对比海关服务的限流,这种写lua的方法能快三倍,限流速度50ms,他们的150ms。而且配置也很简单。

需求8:【出海项目】sitemap与seo优化

什么是seo?

搜索引擎优化,指的是通过合理手段和一些小技巧提升在谷歌上面的排名。这个出海项目最终要达到的效果是,在谷歌上查询一个公司,我们的界面要比这个公司靠前,从而获取自然流量,有了自然流量就可以变现了。
类似天眼查,每次去查询一个公司的官网,天眼查总能比官网靠前(其实也有可能是百度自家特殊关照)。

什么是sitemap?

sitemap.xml是一个站点地图,相当于给谷歌的爬虫一个指引,告诉他我的网站都有哪些路径需要你来访问的,这个地图一级只能有5w条,而我们的业务规模有6kw,所以采取两级索引,并采用gz进行压缩。
而我做的工作是定时更新这个sitemap并放到前端服务器上给爬虫来爬。

sitemap自底向上构建

给了两张表,底层存公司,顶层存索引。我最初的想法是自底向上构建,每次更新去删除顶层,然后底层分页进行压缩,最后构建顶层。这样会有几个问题:

  1. 深度分页问题:前面说了数据量最终大于6kw,如此大的数据量到了后面肯定更新不动的。想了半天索引优化最后mentor给否决了。
  2. 删表问题:数据库不允许传空参数直接删表,而且开销很大。
  3. seo问题:虽然密集化了表但是可能会造成波动,例如某个公司的前几个失效了,这个公司变动到别的索引之下,可能会对seo产生影响,最好还是固定不变。

sitemap自顶向下构建

自顶向下构建就是直接算好id范围,数据库between一下就可以找到,把索引当作槽来用,填满了就放下一个。优点:

  1. 没有深度分页问题,直接根据有索引的id查询,效率肯定是比limit高的。
  2. 一级索引满了就开辟一个新的,效率也高。

缺点:

  1. 依赖顺序性,稍微中间跨度大了就会有问题。例如首先存了1-100,再从40000-40100。每页100条,此时来了10000-10100就不能保证一级索引的顺序了。
  2. 一级索引的起始和终点会显得没有意义,中间有大量失效的,且不一定有序。

最终采用了这种方式,es中的数据是不区分新旧的,所以得做一个唯一性校验。除此之外还有一个当前循环的唯一性校验。

  • 首先查询当前最大公司的id,通过AtomicInteger解决线程安全问题,从es来的新增数据加入,同时要校验是否是英文公司,并取出首字母。(这里写了一大堆字符串工具)
  • 这样做了重复性校验的话即使是失败了也能保证不会重复。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
public void getOpenCompanyIndex(){
try {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
boolQuery.must(QueryBuilders.existsQuery("summary.formatDomains"));
boolQuery.must(QueryBuilders.rangeQuery("emailCount")
.gt(1));
boolQuery.must(QueryBuilders.existsQuery("detail.productList"));
boolQuery.must(QueryBuilders.existsQuery("overviewDescription"));
boolQuery.must(QueryBuilders.existsQuery("detail.sic"));
boolQuery.must(QueryBuilders.existsQuery("detail.naics"));
boolQuery.must(QueryBuilders.existsQuery("customsItems"));

boolQuery.mustNot(QueryBuilders.termQuery("disable", true));
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.query(boolQuery) // 查询参数
.fetchSource(new String[]{"name","companyId"},
null)
.size(1000);
if (openCompanyIndex == null) {
log.error("openCompanyIndex is null");
}

SiteMapCompanyEntity maxCompany = siteMapCompanyService.getMaxDataId();
long maxDataId = maxCompany == null ? 1 : maxCompany.getDataId();
Set<String> strings = new HashSet<>();
AtomicInteger count = new AtomicInteger();
openCompanyIndex.scrollSearch(sourceBuilder, searchHits -> {
List<SiteMapCompanyEntity> addList = new ArrayList<>();
for (SearchHit searchHit : searchHits) {
Map<String, Object> result = searchHit.getSourceAsMap();
SiteMapCompanyEntity siteMapCompanyEntity = new SiteMapCompanyEntity();
String name = result.get("name").toString();
String companyId = result.get("companyId").toString();
//本轮去重
if (strings.contains(companyId)) {
continue;
}
strings.add(companyId);
//全局去重
LambdaQueryWrapper<SiteMapCompanyEntity> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(SiteMapCompanyEntity::getCompanyId, companyId);
if (siteMapCompanyService.count(lambdaQueryWrapper) > 0){
continue;
}
siteMapCompanyEntity.setCompanyId(companyId);
//保留原始名称
siteMapCompanyEntity.setCompanyName(name);
siteMapCompanyEntity.setChangeFreq(ChangeFreqEnum.WEEKLY.getCode());
siteMapCompanyEntity.setDataPriority(1f);
siteMapCompanyEntity.setCreateTime(new Date());
siteMapCompanyEntity.setUpdateTime(new Date());
siteMapCompanyEntity.setDataId(maxDataId + count.longValue());
//非英语公司
if (!StringUtil.checkEnglishChar(name)){
siteMapCompanyEntity.setNavLetter(null);
}
else {
String temp = StringUtil.removeAllPattern(name);
siteMapCompanyEntity.setNavLetter(temp.substring(0, 1).toUpperCase());
}
count.getAndIncrement();
addList.add(siteMapCompanyEntity);
}
siteMapCompanyService.saveBatch(addList);
return false;
});
log.info(String.valueOf(count));
}
catch (Throwable e){
log.error(String.valueOf(e));
}
}

那怎么做生成的,根据公司的最大值最小值/页面大小得到偏移量。根据这个偏移量去更新和查询,只关注最后一个索引块,如果没满就从这里开始加,更新最大值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public void generate() {
preprocess();
SiteMapCompanyEntity maxCompany = siteMapCompanyService.getMaxDataId();
SiteMapCompanyEntity minCompany = siteMapCompanyService.getMinDataId();
if (maxCompany == null || minCompany == null) {
return;
}
List<CompletableFuture<Void>> futures = new ArrayList<>();
Map<Long,String> siteMap = new HashMap<>();
long minDataId = minCompany.getDataId();
long maxDataId = maxCompany.getDataId();
long pageLength = (long) Math.ceil((double) (maxDataId - minDataId + 1) / PAGE_SIZE);
for (long i = 1; i <= pageLength; i++) {
long startDataId = minDataId + (i - 1) * PAGE_SIZE;
long endDataId = minDataId + i * PAGE_SIZE - 1;
long page = i;
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
String gzip = xmlCompanyMapGenerator.generateCompanyMap(startDataId, endDataId,page);
if (!StringUtils.isEmpty(gzip)) {
siteMap.put(page,gzip);
}
}, EXECUTOR);
futures.add(future);
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
generateXML(siteMap);
long count = siteMapTopService.count();
List<SiteMapTopEntity> insertList = new ArrayList<>();
for (long i = Math.max(1,count); i <= pageLength; i++) {
long startDataId = minDataId + (i - 1) * PAGE_SIZE;
long endDataId = Math.min(minDataId + i * PAGE_SIZE - 1, maxDataId);
SiteMapTopEntity siteMapTopEntity = new SiteMapTopEntity();
siteMapTopEntity.setStartDataId(startDataId);
siteMapTopEntity.setEndDataId(endDataId);

siteMapTopEntity.setCreateTime(new Date());
siteMapTopEntity.setUpdateTime(new Date());
if (count != 0 && i == count){
LambdaUpdateWrapper<SiteMapTopEntity> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(SiteMapTopEntity::getName,siteMap.get(i));
siteMapTopService.update(siteMapTopEntity,updateWrapper);
continue;
}
siteMapTopEntity.setName(siteMap.get(i));
insertList.add(siteMapTopEntity);
}
siteMapTopService.saveBatch(insertList);
}

第一周首先上了3w数据,未来数据量大了肯定要分库分表的,以后的事情以后再说。

需求9:【出海项目】一些零碎的小需求

【接口】根据首字母和页码返回数据

就是简单的分页问题,但是有一些字符串处理。我写了一大堆字符串工具。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public PageResp<CompanyUrlResp> getCompanyUrl(String navLetter, Integer pageNum) {
LambdaQueryWrapper<SiteMapCompanyEntity> queryWrapper = new LambdaQueryWrapper<>();
if (StringUtils.isNotEmpty(navLetter)) {
//非英文国家
if (navLetter.equals("Other")) {
queryWrapper.isNull(SiteMapCompanyEntity::getNavLetter);
} else if (!navLetter.matches("^[a-zA-Z]$")) {
throw new BizException(BizError.PARAM_ERR.getName(), BizError.PARAM_ERR.getCode());
} else {
queryWrapper.eq(SiteMapCompanyEntity::getNavLetter, navLetter.toUpperCase());
}
} else {
throw new BizException(BizError.PARAM_ERR.getName(), BizError.PARAM_ERR.getCode());
}
queryWrapper.eq(SiteMapCompanyEntity::getStatus, CompanyStatus.ACTIVE.getCode());

IPage<SiteMapCompanyEntity> page = new Page<>(pageNum, COMPANY_URL_PAGE_SIZE);
IPage<SiteMapCompanyEntity> userPage = siteMapCompanyService.page(page, queryWrapper);
List<SiteMapCompanyEntity> userList = userPage.getRecords();
//组装返回结果
PageResp<CompanyUrlResp> pageResp = new PageResp<>();
pageResp.setPageNo(pageNum);
pageResp.setPageSize(COMPANY_URL_PAGE_SIZE);
pageResp.setTotalPage(userPage.getPages());
pageResp.setTotalSize(userPage.getTotal());
pageResp.setContent(userList.stream().map(item -> {
CompanyUrlResp companyUrlResp = new CompanyUrlResp();
companyUrlResp.setCompanyId(item.getCompanyId());
companyUrlResp.setCompanyName(StringUtil.firstLetterToUpperCaseByBlank(item.getCompanyName()));
if (!StringUtil.checkEnglishChar(item.getCompanyName())){
companyUrlResp.setCompanyFormatName("Non-English-Company");
}
else {
String temp = StringUtil.formatCompanyNameToUrl(item.getCompanyName());
companyUrlResp.setCompanyFormatName(temp);
}
return companyUrlResp;
}).collect(Collectors.toList()));
return pageResp;
}

【接口】公司详情获取

修改了mentor的一点逻辑,做了一点校验:

  • 因为查的是全球搜的数据,有可能未公开,校验只有开启的公司才能查询。
  • 如果没有登录,domain隐蔽,这里看threadlocal有没有东西就好了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public CompanyBaseVO base(String companyId) {
if (!siteMapCompanyService.isOpeningCompany(companyId)) {
throw new BizException(BizError.NON_OPENING_COMPANY.getName(), BizError.NON_OPENING_COMPANY.getCode());
}
try{
String url = host+"/api/auth/global/overseas/base";
Map<String, String> map = new HashMap<>();
map.put("companyId", companyId);
map.put("appKey", appKey);
map.put("appSecret", appSecret);
map.put("timestamp", String.valueOf(System.currentTimeMillis()));
map = OpenApiUtil.packageSign(map, appKey, appSecret);
String resut = OkHttpUtil.get(url, map);
if (StringUtils.isNotBlank(resut)) {
// 图片链接转换
resut = ImagePathConvert.convert(resut);
JSONObject jsonObject = JSON.parseObject(resut);
String dataResult = jsonObject.getString("data");
return loginAuth(JSON.parseObject(dataResult, CompanyBaseVO.class));
}
return null;
}catch (Exception e){
log.info("demo error", e);
return null;
}
}
public CompanyBaseVO loginAuth(CompanyBaseVO companyBaseVO){
UserInfoToken userInfoToken = UserContext.getContent().getUserInfoToken();
//未登录
if (userInfoToken == null) {
companyBaseVO.setDomain("*****");
}
return companyBaseVO;
}