背景
根据版位获取创意形式接口,调用(我们会请求其他服务获取数据,比较慢)耗时较长大约40s,需要优化。
要想从根上解决这个问题,需要我们的服务提供方优化接口,但沟通后需要我们自己优化,其实优化方案很简单就是加缓存。
初期加了本地缓存,但第一次调用耗时还是长,且服务重启时,存在短时间内缓存里数据不存在的情况,导致我们的业务接口调用时,会报错 。
其实解决办法也简单,在缓存和服务提供方之间增加一层数据存储,比如DB,或者直接把本地缓存换成类似Redis这种存储组件。考虑到项目已经依赖的组件的易用性、数据量、接入难易程度等因素,我采用了MySQL 存储。
但需要有定时任务刷新数据,服务有多个节点,避免重复请求服务提供方接口,需要通过分布式锁保证只有一台机器去请求。所以有了如下方案。
方案
![075bf3428b8dbb195d099c3cad0e0dfd.png](https://img-blog.csdnimg.cn/img_convert/075bf3428b8dbb195d099c3cad0e0dfd.png)
![fd9943dd1cd4afb698b787af61f496b1.png](https://img-blog.csdnimg.cn/img_convert/fd9943dd1cd4afb698b787af61f496b1.png)
业务表设计
考虑到 site_set 和 ad_creative_template_id的组合是唯一的,所以增加了唯一索引。
有根据 site_set 查询的场景,比如获取这个版位下的所有创意形式
有根据 ad_creative_template_id 查询的场景,比如获取这个创意形式对应的创意形式的名字。
建表语句如下:
DROP TABLE IF EXISTS `crm_task`.`creative_template`;
CREATE TABLE `creative_template`
(
`site_set` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '版位',
`ad_creative_template_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创意形式ID',
`ad_creative_template_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创意形式名字',
`ad_creative_template_desc` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创意模板描述',
`create_time` datetime NOT NULL COMMENT '创建时间',
`delete_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除状态',
UNIQUE INDEX `unique_site_set_ad_creative_template_id` (`site_set`, `ad_creative_template_id`),
KEY `site_set` (`site_set`),
KEY `ad_creative_template_id` (`ad_creative_template_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='创意形式表'
分布式锁
考虑到定时任务执行周期大约在 6 个小时,且这块并发不高,以及接入难易程度等因素,最后采用 MySQL 实现一个简易的分布式锁。并考虑了续期的情况
建表
CREATE TABLE `distributed_lock` (
`lock_key` varchar(64) NOT NULL,
`lock_status` int(1) NOT NULL DEFAULT '0',
`expire_time` bigint(20) DEFAULT NULL,
PRIMARY KEY (`lock_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分布式锁表'
锁代码
package ams.crm.microservice.task.dao.lock;
import ams.crm.microservice.task.service.lock.DistributedLock;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.ebean.Database;
import io.ebean.annotation.Transactional;
import javax.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 分布式锁-DB
*/
@Component
@Slf4j
public class DistributedLockDB implements DistributedLock {
public static final String DISTRIBUTED_LOCK = "distributed_lock";
@Resource(name = "eBeanServer")
private Database server;
@Resource(name = "dbCustomObjectMapper")
private ObjectMapper objectMapper;
/**
* 抢锁
*
* @param key
* @param expireTime
* @return
*/
@Override
public boolean lock(String key, long expireTime) {
int rows = server.sqlUpdate("UPDATE " + DISTRIBUTED_LOCK
+ " SET lock_status = 1, expire_time = :expireTime WHERE lock_key = :lockKey "
+ "AND (lock_status = 0 OR expire_time < :currentTime)")
.setParameter("lockKey", key)
.setParameter("expireTime", expireTime)
.setParameter("currentTime", System.currentTimeMillis())
.execute();
// 刚开始没有数据,是抢不到锁的,所以判断rows为 0 的情况,执行插入操作
if (rows == 0) {
try {
server.sqlUpdate("INSERT INTO " + DISTRIBUTED_LOCK
+ "(lock_key, lock_status, expire_time) VALUES (:lockKey, 1, :expireTime)")
.setParameter("lockKey", key)
.setParameter("expireTime", expireTime)
.execute();
} catch (Exception e) {
// 如果插入失败,说明锁被抢占,表示枷锁失败
return false;
}
}
return true;
}
/**
* 释放锁
*
* @param key
* @return
*/
@Override
public boolean unlock(String key) {
int rows = server.sqlUpdate("UPDATE " + DISTRIBUTED_LOCK + " SET lock_status = 0 WHERE lock_key = :lockKey "
+ "AND lock_status = 1")
.setParameter("lockKey", key)
.execute();
return rows > 0;
}
/**
* 续期
*
* @param key
* @param newExpireTime
* @return
*/
@Override
public boolean renew(String key, long newExpireTime) {
int rows = server.sqlUpdate("UPDATE " + DISTRIBUTED_LOCK + " SET expire_time = :newExpireTime "
+ "WHERE lock_key = :lockKey AND lock_status = 1 AND expire_time > :currentTime")
.setParameter("lockKey", key)
.setParameter("newExpireTime", newExpireTime)
.setParameter("currentTime", System.currentTimeMillis())
.execute();
return rows > 0;
}
}
加锁和释放锁这块可以进一步优化,因为可能存在进程 1释放了进程 2 加的锁,加锁时,带上一个唯一标识,比如UUID,释放锁时要进行判断锁是否是自己加的,不要把别人加的锁给释放掉。但我们的这个业务场景没有这种风险,数据是一致的,所以没加。
锁使用demo
public class Task implements Runnable {
private DistributedLock lock;
private String key;
public Task(DistributedLock lock, String key) {
this.lock = lock;
this.key = key;
}
@Override
public void run() {
// 获取锁,设置过期时间为 60 秒
if (lock.lock(key, System.currentTimeMillis() + 60000)) {
ScheduledExecutorService executor = null;
try {
// 在另一个线程中定期续期锁
executor = Executors.newSingleThreadScheduledExecutor();
executor.scheduleAtFixedRate(() -> {
// 尝试续期锁,如果失败,抛出异常
if (!lock.renew(key, System.currentTimeMillis() + 60000)) {
throw new RuntimeException("Failed to renew the lock");
}
}, 30, 30, TimeUnit.SECONDS);
// 执行需要加锁的操作
// ...
} finally {
// 释放锁
lock.unlock(key);
// 停止续期线程
if (executor != null) {
executor.shutdown();
}
}
}
}
}
锁过期时间如何设置?
统计加锁处的业务逻辑执行耗时后,过期时间设置的稍微比执行耗时大些,并配置到配置中心,可以把数据上报再调整。
最终真实用法
定时刷新数据
查接口 -> 更新DB -> 删除缓存
/**
* 定时刷新创意形式
* 项目启动后1s开始执行,每隔6小时执行一次
*/
@Scheduled(initialDelay = 1000, fixedDelay = 21600000)
public void refreshAdCreativeTemplate() {
List<SiteSet> allSiteSet = SiteSet.getAllSiteSet().stream().filter(item -> !SiteSet.UNKNOWN.equals(item))
.collect(Collectors.toList());
for (SiteSet siteSet : allSiteSet) {
try {
List<AdCreativeTemplate> adCreativeTemplates = queryAdCreativeTemplate(siteSet);
if (CollectionUtils.isEmpty(adCreativeTemplates)) {
continue;
}
List<CreativeTemplate> creativeTemplates = adCreativeTemplates.stream()
.map(adCreativeTemplate -> new CreativeTemplate(adCreativeTemplate, siteSet.toString()))
.collect(Collectors.toList());
updateDB(siteSet, creativeTemplates);
adCreativeTemplateCache.invalidate(siteSet);
} catch (Exception e) {
log.error("刷新创意形式报错, siteSet={}, e={}", siteSet, e.getMessage());
tnm2Service.agentRepStr(TNM2_FEATURE_ID, "刷新创意形式报错:" + siteSet);
}
allAdCreativeTemplateCache.invalidate(ALL_CREATIVE_TEMPLATE_CACHE_KEY);
}
}
更新 DB
/**
* 更新DB
*
* @param siteSet
* @param creativeTemplates
*/
public void updateDB(SiteSet siteSet, List<CreativeTemplate> creativeTemplates) {
// 删DB和写DB要加锁
final String lock = CREATIVE_TEMPLATE_LOCK_PRE + "_" + siteSet;
if (distributedLock.lock(lock, System.currentTimeMillis() + lockExpireTime)) {
ScheduledExecutorService executor = null;
try {
// 在另一个线程中定期续期锁
executor = Executors.newSingleThreadScheduledExecutor();
executor.scheduleAtFixedRate(() -> {
// 尝试续期锁,如果失败,抛出异常
if (!distributedLock.renew(lock, System.currentTimeMillis() + lockExpireTime)) {
log.error("Failed to renew the lock" + lock);
}
}, lockExpireTime, lockExpireTime, TimeUnit.MILLISECONDS);
// 更新DB,先删后增加,在同一个事务里
update(siteSet.toString(), creativeTemplates);
} finally {
// 停止续期线程
if (executor != null) {
executor.shutdown();
}
// 释放锁
distributedLock.unlock(lock);
}
}
}
@Transactional
public void update(String siteStr, List<CreativeTemplate> creativeTemplates) {
// 删DB
creativeTemplateDao.delete(siteStr);
// 写DB
creativeTemplateDao.add(creativeTemplates);
}
查询
先查缓存,缓存不存在先查 DB,DB存在直接返回;DB不存在调用服务提供方接口,加锁写 DB,释放锁
private List<AdCreativeTemplate> loadAdCreativeTemplate(SiteSet siteSet) {
// 1.先查DB
List<AdCreativeTemplate> adCreativeTemplates = queryAdCreativeTemplateFromDB(siteSet);
if (CollectionUtils.isNotEmpty(adCreativeTemplates)) {
return adCreativeTemplates;
}
// 2.没有查到,调用投放端接口
adCreativeTemplates = queryAdCreativeTemplate(siteSet);
if (CollectionUtils.isEmpty(adCreativeTemplates)) {
return adCreativeTemplates;
}
List<CreativeTemplate> creativeTemplates = adCreativeTemplates.stream()
.map(adCreativeTemplate -> new CreativeTemplate(adCreativeTemplate, siteSet.toString()))
.collect(Collectors.toList());
final String lock = CREATIVE_TEMPLATE_LOCK_PRE + "_" + siteSet;
// 3.抢锁,写DB,尽量讲锁范围限制小
if (distributedLock.lock(lock, System.currentTimeMillis() + lockExpireTime)) {
ScheduledExecutorService executor = null;
try {
// 在另一个线程中定期续期锁
executor = Executors.newSingleThreadScheduledExecutor();
executor.scheduleAtFixedRate(() -> {
// 尝试续期锁,如果失败,抛出异常
if (!distributedLock.renew(lock, System.currentTimeMillis() + lockExpireTime)) {
log.error("Failed to renew the lock" + lock);
}
}, lockExpireTime, lockExpireTime, TimeUnit.MILLISECONDS);
// 执行需要加锁的操作
creativeTemplateDao.add(creativeTemplates);
} finally {
// 停止续期线程
if (executor != null) {
executor.shutdown();
}
// 释放锁
distributedLock.unlock(lock);
}
}
return adCreativeTemplates;
}
效果
优化后,只需要毫秒级就能返回结果