幂等、分库分表、canal 学习!面试!

74 阅读8分钟
  • 你的幂等是如何设计的

    幂等是指,对于同一个操作或请求,在任意时刻执行一次和执行多次的效果是一致的。 也就是说,重复执行多次不会产生任何副作用和变化。 在Web开发中,幂等主要适用于一些用户操作,比如表单提交、支付、退款、接口请求等。 由于在实际应用中,可能会出现因为网络、系统等问题,导致同一笔操作或请求被多次执行的情况,而幂等机制可以保证系统数据及状态的一致性和可靠性,避免重复执行造成的错误和风险。

    实现幂等的具体方式会根据不同的业务场景产生差异,但通常可以从以下几个方面入手:

  • 根据业务场景进行设计

    在实现幂等的过程中,需要先根据具体的业务场景进行设计。需要明确哪些操作需要进行幂等处理,以及通过哪些属性来判断是否为相同的操作。只有对业务场景有充分的了解,才能在实现幂等逻辑时避免出现一些漏洞或者不必要的幂等判断。

  • 确定幂等键

幂等处理的核心就是通过某些属性来判断是否为相同的请求或操作,这些属性所组成的组合就是幂等键。比如,对于支付接口,可以使用订单号作为幂等键,判断其是否已经完成过相应的支付操作。需要保证幂等键的唯一性和正确性,才能正确判断幂等。

  • 使用唯一标识符

    在实现幂等逻辑时,需要为每一个请求或操作生成一个唯一的标识符,并记录到缓存或者数据库中。可以使用UUID、Snowflake等算法生成字符串,或者使用时间戳等标识符。

  • 利用缓存实现幂等

    可以使用 Redis、Memcache 等缓存工具来记录幂等的标识符,并将其与幂等键一起存储,实现幂等的检查。一旦请求被处理完成,需要从缓存中删除相应的标识符。

  • 使用分布式锁

    在高并发的场景下,使用缓存记录幂等信息可能会存在一些问题。可以使用分布式锁来解决这个问题,保证多线程或分布式环境下幂等操作的正确性。使用分布式锁需要注意锁的粒度和使用方式以及对性能造成的影响。

需要注意的是,幂等操作通常需要在业务操作和数据状态处理两个方面同时进行考虑,以确保系统稳定和正确性。

@Controller
public class SampleController {

    private static final String UNIQUE_ID_KEY = "unique_id_"; //Redis中保存幂等ID的前缀

    private static final String ORDER_ID = "orderId"; //幂等键

    @Autowired
    private OrderService orderService; // 模拟的订单服务

    @Autowired
    private RedisTemplate<String, String> redisTemplate; // Redis模板

    /**
     * 下单接口,使用 Redis 实现幂等控制
     *
     * @param orderId 订单号
     * @return 下单结果
     */
    @RequestMapping(value = "/order", method = RequestMethod.POST)
    @ResponseBody
    public String order(@RequestParam(value = "orderId") String orderId) {
        // 生成唯一的幂等ID
        String uniqueId = UUID.randomUUID().toString();
        // 将幂等ID保存到 Redis 中,设置过期时间为 60 秒
        redisTemplate.opsForValue().set(UNIQUE_ID_KEY + uniqueId, orderId, 60, TimeUnit.SECONDS);
        // 查询订单是否已经存在
        Order order = orderService.getOrderById(orderId);
        // 如果订单不存在,则创建新订单
        if (order == null) {
            orderService.createNewOrder(orderId);
            // 订单创建成功后,需要将幂等ID从 Redis 删除
            redisTemplate.delete(UNIQUE_ID_KEY + uniqueId);
            return "下单成功";
        } else {
            // 如果订单已经存在,则判断是否是幂等操作
            String existedUniqueId = redisTemplate.opsForValue().get(UNIQUE_ID_KEY + orderId);
            if (existedUniqueId == null) {
                return "重复下单";
            } else if (existedUniqueId.equals(uniqueId)) {
                // 已经创建过相同订单,直接返回成功即可
                return "下单成功";
            } else {
                // 如果是不同的幂等ID执行相同的下单请求,需要返回下单接口正在处理中的信息。
                return "订单创建中,请勿重复操作";
            }
        }
    }
}
  • 分库分表数据源如何切换

    在分库分表场景中,由于数据量过大,一般将数据散布在多个不同的数据库或表中,同时,这些数据库或表也可能会被部署在不同的服务器上。而应用程序需要一种机制来自动路由查询(或者更新)请求到对应的数据源上。

    这种机制既可以是基于代码的实现方式,也可以是调用框架或中间件的实现方式,但通常都是将数据源切换的逻辑封装到相应的工具类或管理类中。以下是两种实现方式:


//在纯 Java 代码中实现基于代码的数据源切换,一般都会使用到 Spring 框架的动态数据源功能,包括:

//定义数据源
//Spring 框架提供了一个抽象的数据源接口 javax.sql.DataSource,我们需要根据业务需求实现此接口,并对不同的数据源进行封装。例如:

public class MyDataSource1 implements DataSource {

    // ... 具体的数据源配置与实现

}

public class MyDataSource2 implements DataSource {

    // ... 具体的数据源配置与实现

}
//配置数据源管理器
//在配置文件中,需要进行数据源的配置,并将多个数据源实现注册到一个数据源管理器中。例如:

<bean id="dataSource1" class="com.test.MyDataSource1">
    <!-- ... 数据源的具体配置参数 -->
</bean>

<bean id="dataSource2" class="com.test.MyDataSource2">
    <!-- ... 数据源的具体配置参数 -->
</bean>

<bean id="dataSourceManager" class="com.test.DataSourceManager">
    <property name="targetDataSources">
        <map>
            <!-- 注册数据源1 -->
            <entry key="dataSource1" value-ref="dataSource1" />
            <!-- 注册数据源2 -->
            <entry key="dataSource2" value-ref="dataSource2" />
        </map>
    </property>
    <property name="defaultTargetDataSource" ref="dataSource1" /> <!-- 默认使用的数据源 -->
</bean>
//实现数据源切换逻辑
//在实现代码中,需要使用 ThreadLocal 等机制,维护当前线程需要使用的数据源名称,从而实现数据源的动态切换。例如:

public class DataSourceHolder {
    private static final ThreadLocal<String> dataSource = new ThreadLocal<>();

    public static void setDataSource(String name) {
        dataSource.set(name);
    }

    public static String getDataSource() {
        return dataSource.get();
    }

    public static void clear() {
        dataSource.remove();
    }
}
//定义数据源切换切面
//最后,需要定义一个基于 AOP 的数据源切换切面,拦截到业务 SQL 方法的调用,动态切换当前线程要使用的数据源名称。例如:

@Aspect
public class DataSourceAspect {

    @Pointcut("execution(* com.test.mapper.*.*(..))") // 拦截 Mapper 接口的所有方法
    public void pointcut() {}

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        MethodSignature signature = (MethodSignature) point.getSignature();
        String methodName = signature.getMethod().getName();
        if (methodName.startsWith("get")) {
            // 如果是查询方法,则使用数据源1
            DataSourceHolder.setDataSource("dataSource1");
        } else {
            // 如果是更新方法,则使用数据源2
            DataSourceHolder.setDataSource("dataSource2");
        }
        try {
            // 调用被拦截到的方法
            return point.proceed();
        } finally {
            // 清空当前线程要使用的数据源名称
            DataSourceHolder.clear();
        }
    }
}
//这样,通过在业务 SQL 方法调用前动态切换数据源,就可以根据实际业务需求,动态地选择使用不同的数据源来处理 SQL 语句的执行了。
//注解方式实现数据源切换可以避免对业务代码做太多的调整,一般需要配合 Spring 框架或其他的 ORM 框架使用。具体实现步骤如下:

//定义数据源
//与基于代码的实现方式相同,我们需要定义两个或更多的数据源,并封装到 java.sql.DataSource 接口的实现类中。例如:

public class DataSource1 implements DataSource {
    // ... 数据源 1 的具体实现
}

public class DataSource2 implements DataSource {
    // ... 数据源 2 的具体实现
}


<bean id="dataSource1" class="com.example.DataSource1" />
<bean id="dataSource2" class="com.example.DataSource2" />
//这里假设在 DataSource1 中配置了数据库 URL、用户名和密码,而 DataSource2 中配置了另一组不同的数据库 //URL、用户名和密码等连接信息。

//配置数据源切换器
//为了动态切换数据源,需要定义一个数据源切换器,在 Spring 的配置文件中,为其配置需要使用的所有数据源:

<bean id="dynamicDataSource" class="com.example.DynamicDataSource">
  <property name="targetDataSources">
      <map key-type="String">
          <entry key="dataSource1" value-ref="dataSource1" />
          <entry key="dataSource2" value-ref="dataSource2" />
      </map>
  </property>
  <property name="defaultTargetDataSource" ref="dataSource1" />
</bean>
//上述配置中,DynamicDataSource 是一个实现 javax.sql.DataSource 接口的类,用于管理多个数据源,且需要在 targetDataSources 属性中列出所有数据源。其中,defaultTargetDataSource 属性用于指定默认数据源。

//定义注解
//我们需要定义一个注解,用于标注数据源的切换。例如:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataSourceSwitch {
    String value() default "dataSource1";
}
其中,DataSourceSwitch 是一个标注在方法上的注解,用于指定该方法要使用的数据源名称(即键值,对应动态数据源中的键值)。如果不指定,则表示使用默认数据源。

//配置切面
//需要编写一个基于 AOP 的切面,拦截使用了 DataSourceSwitch 注解的方法,并根据注解指定的数据源名称,动态切换数据源:

@Aspect
@Component
public class DataSourceAspect {
    @Around("@annotation(dataSourceSwitch)")
    public Object around(ProceedingJoinPoint point, DataSourceSwitch dataSourceSwitch) throws Throwable {
        String dsName = dataSourceSwitch.value();
        DynamicDataSource.setDataSourceKey(dsName);

        try {
            return point.proceed();
        } finally {
            DynamicDataSource.clearDataSourceKey();
        }
    }
}

//DynamicDataSource.setDataSourceKey() 和 DynamicDataSource.clearDataSourceKey() 分别设置并清除当前线程使用的数据源名称。

//使用注解的方式时,只需要在需要切换数据源的方法上增加 @DataSourceSwitch 注解即可实现切换数据源。例如:

@Service
public class UserServiceImpl implements UserService {
    //...
    @DataSourceSwitch("dataSource2") // 使用 dataSource2 数据源
    @Override
    public List<User> getAllUsers() {
        return userDao.selectAllUsers();
    }
    //...
}
//上述代码中,getAllUsers() 方法使用了 dataSource2 数据源,在不使用 @DataSourceSwitch 注解的情况下,默认将使用 dataSource1 数据源。

基于中间件的实现方式实现数据源的切换 :

spring:
  shardingsphere:
    # 自动化分片
    sharding:
      database-strategy: inline
      # 用户表分片规则
      tables:
        user:
          actual-data-nodes: ds${0..1}.user_${0..1}
          table-strategy:
            inline:
              sharding-column: id
              algorithm-expression: user_${id % 2}
          key-generator:
            column: id
            type: SNOWFLAKE
      # 默认数据源为 ds0
      default-data-source-name: ds0
    datasource:
      names: ds0, ds1
      # 各数据源配置
      ds0:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3306/test?serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&useSSL=false
        username: root
        password: 123456
        hikari:
          maximum-pool-size: 30
      ds1:
        type: com.zaxxer.hikari.HikariDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        jdbc-url: jdbc:mysql://localhost:3306/test?serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&useSSL=false
        username: root
        password: 123456
        hikari:
          maximum-pool-size: 30


@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public void addUser(User user) {
        userMapper.insert(user);
    }

    @Override
    public List<User> listUsers() {
        return userMapper.selectList(null);
    }

    @Override
    public List<User> listUsersByDs(int dsIndex) {
        // 切换数据源
        DynamicDataSourceContextHolder.setDataSourceType("ds" + dsIndex);

        List<User> userList = userMapper.selectList(null);

        DynamicDataSourceContextHolder.clearDataSourceType();

        return userList;
    }
}

具体实现可以使用 DynamicDataSourceContextHolder 来进行数据源的切换,该工具类使用 ThreadLocal 存储当前线程使用的数据源名称,从而实现动态切换数据源的功能。

注:DynamicDataSourceContextHolder 需要自定义实现。也可以使用 ShardingSphere 提供的 DynamicDataSource 模块,该模块为 Spring Boot 提供了自动化配置支持,可参考官方文档进行配置。

  • 分库分表后的数据如何查询
//通过分片键查询
//在进行分库分表之后,可能为了提高查询效率,需要在数据表中添加分片键,用来标识数据属于哪个分片。因此可以通过对分片键的查询来获取对应的数据,可以避免在所有数据源中查询数据。例如在 ShardingSphere 中,可以通过以下方式查询分片数据:

public List<User> listUsersBySharding(Long id) {
    QueryWrapper<User> wrapper = new QueryWrapper<>();
    // 根据分片键查询
    wrapper.eq("id", id);
    return userMapper.selectList(wrapper);
}

//查询所有数据源然后合并结果
//对于一些小规模的系统,可以在查询时遍历多个数据源,查询所有的数据,然后将结果合并。这种方式的缺点是在数据量较大的情况下可能会影响查询效率,而且查询速

@Service
public class UserServiceImpl implements UserService {
    @Autuwired
    private UserMapper userMapper;

    @GlobalTransactional
    @Override
    public void addUserAndQuery(Long id, String name) {
        // 分布式事务 ID
        String xid = RootContext.getXID();

        // 获取所有分片数据源的连接
        Map<String, ConnectionProxy> proxies = TransactionContextHelper.getConnections(null);

        try {
            // 逐个查询所有分片数据
            for (Map.Entry<String, ConnectionProxy> entry : proxies.entrySet()) {
                Connection connection = entry.getValue().getConnection();
                PreparedStatement statement = connection.prepareStatement("select * from user where id=?");
                statement.setLong(1, id);
                ResultSet resultSet = statement.executeQuery();
                while (resultSet.next()) {
                    // 获取查询结果
                    long userId = resultSet.getLong("id");
                    String userName = resultSet.getString("name");
                    log.info("user id:{}, name:{}", userId, userName);
                }
            }

            // 插入数据到主数据源
            User user = new User(id, name);
            userMapper.insert(user);

        } catch (Exception e) {
            e.printStackTrace();
            // 事务回滚
            throw new RuntimeException("error occurred in distributed transaction", e);
        }
    }

}
@Service
public class UserServiceImpl implements UserService {

    @Autuwired
    private UserMapper userMapper;

    @GlobalTransactional
    @Override
    public void addUserAndQuery(Long id, String name) {
        // 分布式事务 ID
        String xid = RootContext.getXID();

        // 获取所有分片数据源的连接
        Map<String, ConnectionProxy> proxies = TransactionContextHelper.getConnections(null);

        try {
            // 逐个查询所有分片数据
            for (Map.Entry<String, ConnectionProxy> entry : proxies.entrySet()) {
                Connection connection = entry.getValue().getConnection();
                PreparedStatement statement = connection.prepareStatement("select * from user where id=?");
                statement.setLong(1, id);
                ResultSet resultSet = statement.executeQuery();
                while (resultSet.next()) {
                    // 获取查询结果
                    long userId = resultSet.getLong("id");
                    String userName = resultSet.getString("name");
                    log.info("user id:{}, name:{}", userId, userName);
                }
            }

            // 插入数据到主数据源
            User user = new User(id, name);
            userMapper.insert(user);

        } catch (Exception e) {
            e.printStackTrace();
            // 事务回滚
            throw new RuntimeException("error occurred in distributed transaction", e);
        }
    }



```\Seata的示例配置文件:
transport:
  type: TCP
  server: 127.0.0.1:8091

register:
  type: NACOS
  nacos:
    serverAddr: 127.0.0.1:8848
    namespace: seata-dev
    cluster: default

config:
  type: nacos
  nacos:
    serverAddr: 127.0.0.1:8848
    namespace: seata-dev
    group: SEATA_GROUP
    dataId: file.conf

service:
  vgroupMapping:
    my_test_tx_group: default
  default:
    group: my_test_tx_group

pvc:
  disk: /seata/data


定义数据源名称ShardingSphere 的示例配置文件


spring:
  datasource:
    names: ds0, ds1

  shardingsphere:
    proxy:
      data-sources:
        ds0:
          ...
        ds1:
          ...
      rules:
        # 定义分片规则
        shardingRule:
          tables:
            user:
              actualDataNodes: ds0.user_${0..1},ds1.user_${0..1}
              tableStrategy:
                inline:
                  shardingColumn: id
                  algorithmExpression: user_${id % 2}
              keyGenerators:
                snowflake:
                  type: SNOWFLAKE
                  column: id
          # 定义默认数据源名称和分布式事务 ID 生成方式
          defaultDataSourceName: ds0
          transaction:
            ...

Seata 进行查询的示例代码:

@Service
public class UserServiceImpl implements UserService {

    @Autuwired
    private UserMapper userMapper;

    @GlobalTransactional
    @Override
    public void addUserAndQuery(Long id, String name) {
        // 分布式事务 ID
        String xid = RootContext.getXID();

        // 获取所有分片数据源的连接
        Map<String, ConnectionProxy> proxies = TransactionContextHelper.getConnections(null);

        try {
            // 逐个查询所有分片数据
            for (Map.Entry<String, ConnectionProxy> entry : proxies.entrySet()) {
                Connection connection = entry.getValue().getConnection();
                PreparedStatement statement = connection.prepareStatement("select * from user where id=?");
                statement.setLong(1, id);
                ResultSet resultSet = statement.executeQuery();
                while (resultSet.next()) {
                    // 获取查询结果
                    long userId = resultSet.getLong("id");
                    String userName = resultSet.getString("name");
                    log.info("user id:{}, name:{}", userId, userName);
                }
            }

            // 插入数据到主数据源
            User user = new User(id, name);
            userMapper.insert(user);

        } catch (Exception e) {
            e.printStackTrace();
            // 事务回滚
            throw new RuntimeException("error occurred in distributed transaction", e);
        }
    }
}

  • canal 是做什么用的

    Canal是一个开源的、基于MySQL协议的数据增量订阅&消费系统,可以在不修改源数据库的情况下,将MySQL数据库的变更事件流转到消息队列,支持Kafka、RabbitMQ、RocketMQ等多种消息中间件。Canal可以应用于数据同步、数据备份、数据实时分析等场景,可以通过Canal提供的API或SDK,实现对业务系统的快速响应和实时监控。

    简单的Canal+Kafka实现MySQL增量订阅的示例代码:

  • Canal客户端的配置文件 canal.properties:

canal.instance.master.address=your.mysql.host:3306
canal.instance.master.journal.name=mysql-bin.000001
canal.instance.master.position=4
canal.instance.rds.accesskey=xxx
canal.instance.rds.secretkey=xxx
canal.instance.rds.instanceId=xxx
canal.instance.mysql.slaveId=1234



  • Canal客户端的配置文件 canal.properties:
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;

String destination = "example";
CanalConnector connector = CanalConnectors.newSingleConnector(
    new InetSocketAddress("your.canal.host", 11111), destination, "", "");

try {
    connector.connect();
    connector.subscribe(".*\\..*");
    connector.rollback();

    while (true) {
        Message message = connector.getWithoutAck(batchSize, timeout, TimeUnit.SECONDS);
        long batchId = message.getId();

        try {
            List<CanalEntry.Entry> entries = message.getEntries();
            for (CanalEntry.Entry entry : entries) {
                if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) {
                    CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
                    String tableName = entry.getHeader().getTableName();
                    EventType eventType = rowChange.getEventType();
                    List<CanalEntry.RowData> rowDatas = rowChange.getRowDatasList();
                    // do something with row datas
                }
            }
            connector.ack(batchId);
        } catch (Exception e) {
            connector.rollback(batchId);
        }
    }
} finally {
    connector.disconnect();
}

  • Kafka生产者的代码

import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerRecord;

Properties props = new Properties();
props.put("bootstrap.servers", "your.kafka.host:9092");
props.put("acks", "all");
props.put("retries", 0);
props.put("batch.size", 16384);
props.put("linger.ms", 1);
props.put("buffer.memory", 33554432);
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");

Producer<String, String> producer = new KafkaProducer<>(props);

// for each row change in Canal, produce a Kafka message
ProducerRecord<String, String> record = new ProducerRecord<>("my-topic", key, value);
producer.send(record);

producer.close();