用 MySQL实现一个简易的分布式锁

背景

根据版位获取创意形式接口,调用(我们会请求其他服务获取数据,比较慢)耗时较长大约40s,需要优化。

要想从根上解决这个问题,需要我们的服务提供方优化接口,但沟通后需要我们自己优化,其实优化方案很简单就是加缓存

初期加了本地缓存,但第一次调用耗时还是长,且服务重启时,存在短时间内缓存里数据不存在的情况,导致我们的业务接口调用时,会报错 。

其实解决办法也简单,在缓存和服务提供方之间增加一层数据存储,比如DB,或者直接把本地缓存换成类似Redis这种存储组件。考虑到项目已经依赖的组件的易用性、数据量、接入难易程度等因素,我采用了MySQL 存储。

但需要有定时任务刷新数据,服务有多个节点,避免重复请求服务提供方接口,需要通过分布式锁保证只有一台机器去请求。所以有了如下方案。

方案

075bf3428b8dbb195d099c3cad0e0dfd.png
启动/定时刷新数据流程
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;
}

效果

优化后,只需要毫秒级就能返回结果

  • 5
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
MySQL实现分布式锁可以通过以下几种方式: 1. 基于MySQL自带的行锁实现 可以通过在锁表中添加一条记录来实现分布式锁。当一个客户端想获取锁时,可以向锁表中插入一条记录,如果插入成功则说明获取锁成功,否则获取锁失败。当客户端释放锁时,可以删除锁表中对应的记录。需要注意的是,由于MySQL自带的行锁只在当前连接中有效,因此需要在每个客户端中都执行相同的加锁和解锁操作。 2. 基于MySQL的GET_LOCK和RELEASE_LOCK函数实现 MySQL提供了GET_LOCK和RELEASE_LOCK两个函数,可以用于实现分布式锁。当一个客户端想获取锁时,可以调用GET_LOCK函数,如果返回值为1则说明获取锁成功,否则获取锁失败。当客户端释放锁时,可以调用RELEASE_LOCK函数来释放锁。需要注意的是,由于GET_LOCK和RELEASE_LOCK函数是在MySQL服务器端执行的,因此可以实现跨连接的锁。 3. 基于ZooKeeper实现 可以利用ZooKeeper的临时节点来实现分布式锁。当一个客户端想获取锁时,可以在ZooKeeper上创建一个临时节点,如果创建成功则说明获取锁成功,否则获取锁失败。当客户端释放锁时,可以删除对应的临时节点。由于ZooKeeper是一个高可用的分布式协调服务,因此可以保证分布式锁的可靠性和高可用性。 以上是几种实现MySQL分布式锁的方式,需要根据具体的应用场景和实际需求选择合适的方式实现
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值