一文讲清楚分布式事务+分布式锁实现及各技能知识要点

42 篇文章 0 订阅
20 篇文章 0 订阅

1 分布式锁

1.1 问题分析

上面抢单过程实现了,但其实还是有问题,会发生超卖问题,如下图:

​ 在多线程执行的情况下,上面的抢单流程会发生超卖问题,比如只剩下1个商品,多线程同时判断是否有库存的时候,会同时判断有库存,最终导致1个商品多个订单的问题发生。

1.2 redisson分布式锁

1.2.1 分布式锁介绍

​ 解决上面超卖问题,我们可以采用分布式锁来控制,分布式锁的原理很简单。

​ 分布式锁主要是实现在分布式场景下保证数据的最终一致性。在单进程的系统中,存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码块做同步(lock—synchronized),使其在修改这种变量时能够线性执行消除并发修改变量。但分布式系统是多部署、多进程的,开发语言提供的并发处理API在此场景下就无能为力了。

目前市面上分布式锁常见的实现方式有三种:


1.基于数据库实现分布式锁; 
2.基于缓存(Redis等)实现分布式锁; 
3.基于Zookeeper实现分布式锁;
1.2.2 Redisson介绍

​ 大部分网站使用的分布式锁是基于缓存的,有更好的性能,而缓存一般是以集群方式部署,保证了高可用性。而Redis分布式锁官方推荐使用redisson。

Redisson原理图如下:

Redisson锁说明:


1、redission获取锁释放锁的使用和JDK里面的lock很相似,底层的实现采用了类似lock的处理方式
2、redisson 依赖redis,因此使用redisson 锁需要服务端安装redis,而且redisson 支持单机和集群两种模式下的锁的实现
3、redisson 在多线程或者说是分布式环境下实现机制,其实是通过设置key的方式进行实现,也就是说多个线程为了抢占同一个锁,其实就是争抢设置key。

Redisson原理:

1)加锁:


if (redis.call('exists', KEYS[1]) == 0) then 
        redis.call('hset', KEYS[1], ARGV[2], 1);
         redis.call('pexpire', KEYS[1], ARGV[1]); 
         return nil;
          end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
        redis.call('hincrby', KEYS[1], ARGV[2], 1);
        redis.call('pexpire', KEYS[1], ARGV[1]); 
        return nil;
        end;
return redis.call('pttl', KEYS[1]);

将业务封装在lua中发给redis,保障业务执行的原子性。

第1个if表示执行加锁,会先判断要加锁的key是否存在,不存在就加锁。

当第1个if执行,key存在的时候,会执行第2个if,第2个if会获取第1个if对应的key剩余的有效时间,然后会进入while循环,不停的尝试加锁。

2)释放锁:


if (redis.call('exists', KEYS[1]) == 0) then
       redis.call('publish', KEYS[2], ARGV[1]);
        return 1; 
        end;
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 
     return nil;
     end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); 
if (counter > 0) then
     redis.call('pexpire', KEYS[1], ARGV[2]); 
     return 0; 
else redis.call('del', KEYS[1]); 
     redis.call('publish', KEYS[2], ARGV[1]); 
     return 1;
     end;
return nil;

执行lock.unlock(),每次都对myLock数据结构中的那个加锁次数减1。如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:“del myLock”命令,从redis里删除这个key,另外的客户端2就可以尝试完成加锁了。

3)缺点:

Redisson存在一个问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁。这时系统在业务上一定会出现问题,导致脏数据的产生。

1.2.3 Redisson配置

1)引入依赖


<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.11.0</version>
</dependency>

2)锁操作方法实现

要想用到分布式锁,我们就必须要实现获取锁和释放锁,获取锁和释放锁可以编写一个DistributedLocker接口,代码如下:


public interface DistributedLocker {
    /***
     * lock(), 拿不到lock就不罢休,不然线程就一直block
     * @param lockKey
     * @return
     */
    RLock lock(String lockKey);
    /***
     * timeout为加锁时间,单位为秒
     * @param lockKey
     * @param timeout
     * @return
     */
    RLock lock(String lockKey, long timeout);
    /***
     * timeout为加锁时间,时间单位由unit确定
     * @param lockKey
     * @param unit
     * @param timeout
     * @return
     */
    RLock lock(String lockKey, TimeUnit unit, long timeout);
    /***
     * tryLock(),马上返回,拿到lock就返回true,不然返回false。
     * 带时间限制的tryLock(),拿不到lock,就等一段时间,超时返回false.
     * @param lockKey
     * @param unit
     * @param waitTime
     * @param leaseTime
     * @return
     */
    boolean tryLock(String lockKey, TimeUnit unit, long waitTime, long leaseTime);
    /***
     * 解锁
     * @param lockKey
     */
    void unlock(String lockKey);
    /***
     * 解锁
     * @param lock
     */
    void unlock(RLock lock);
}

实现上面接口中对应的锁管理方法,编写一个锁管理类RedissonDistributedLocker,代码如下:


@Component
public class RedissonDistributedLocker implements DistributedLocker {

    @Autowired
    private RedissonClient redissonClient;

    /***
     * lock(), 拿不到lock就不罢休,不然线程就一直block
     * @param lockKey
     * @return
     */
    @Override
    public RLock lock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock();
        return lock;
    }

    /***
     * timeout为加锁时间,单位为秒
     * @param lockKey
     * @param timeout
     * @return
     */
    @Override
    public RLock lock(String lockKey, long timeout) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(timeout, TimeUnit.SECONDS);
        return lock;
    }

    /***
     * timeout为加锁时间,时间单位由unit确定
     * @param lockKey
     * @param unit
     * @param timeout
     * @return
     */
    @Override
    public RLock lock(String lockKey, TimeUnit unit, long timeout) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(timeout, unit);
        return lock;
    }

    /***
     * tryLock(),马上返回,拿到lock就返回true,不然返回false。
     * 带时间限制的tryLock(),拿不到lock,就等一段时间,超时返回false.
     * @param lockKey
     * @param unit
     * @param waitTime
     * @param leaseTime
     * @return
     */
    @Override
    public boolean tryLock(String lockKey, TimeUnit unit, long waitTime, long leaseTime) {
        RLock lock = redissonClient.getLock(lockKey);
        try {
            return lock.tryLock(waitTime, leaseTime, unit);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    /***
     * 解锁
     * @param lockKey
     */
    @Override
    public void unlock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.unlock();
    }

    /***
     * 解锁
     * @param lock
     */
    @Override
    public void unlock(RLock lock) {
        lock.unlock();
    }
}

3)配置Redis链接

在resources下新建文件redisson.yml,主要用于配置redis集群节点链接配置,代码如下:


clusterServersConfig:
  # 连接空闲超时,单位:毫秒 默认10000
  idleConnectionTimeout: 10000
  pingTimeout: 1000
  # 同任何节点建立连接时的等待超时。时间单位是毫秒 默认10000
  connectTimeout: 10000
  # 等待节点回复命令的时间。该时间从命令发送成功时开始计时。默认3000
  timeout: 3000
  # 命令失败重试次数
  retryAttempts: 3
  # 命令重试发送时间间隔,单位:毫秒
  retryInterval: 1500
  # 重新连接时间间隔,单位:毫秒
  reconnectionTimeout: 3000
  # 执行失败最大次数
  failedAttempts: 3
  # 密码
  #password: test1234
  # 单个连接最大订阅数量
  subscriptionsPerConnection: 5
  clientName: null
  # loadBalancer 负载均衡算法类的选择
  loadBalancer: !<org.redisson.connection.balancer.RoundRobinLoadBalancer> {}
  #从节点发布和订阅连接的最小空闲连接数
  slaveSubscriptionConnectionMinimumIdleSize: 1
  #从节点发布和订阅连接池大小 默认值50
  slaveSubscriptionConnectionPoolSize: 50
  # 从节点最小空闲连接数 默认值32
  slaveConnectionMinimumIdleSize: 32
  # 从节点连接池大小 默认64
  slaveConnectionPoolSize: 64
  # 主节点最小空闲连接数 默认32
  masterConnectionMinimumIdleSize: 32
  # 主节点连接池大小 默认64
  masterConnectionPoolSize: 64
  # 订阅操作的负载均衡模式
  subscriptionMode: SLAVE
  # 只在从服务器读取
  readMode: SLAVE
  # 集群地址
  nodeAddresses:
    - "redis://192.168.211.137:7001"
    - "redis://192.168.211.137:7002"
    - "redis://192.168.211.137:7003"
    - "redis://192.168.211.137:7004"
    - "redis://192.168.211.137:7005"
    - "redis://192.168.211.137:7006"
  # 对Redis集群节点状态扫描的时间间隔。单位是毫秒。默认1000
  scanInterval: 1000
  #这个线程池数量被所有RTopic对象监听器,RRemoteService调用者和RExecutorService任务共同共享。默认2
threads: 0
#这个线程池数量是在一个Redisson实例内,被其创建的所有分布式数据类型和服务,以及底层客户端所一同共享的线程池里保存的线程数量。默认2
nettyThreads: 0
# 编码方式 默认org.redisson.codec.JsonJacksonCodec
codec: !<org.redisson.codec.JsonJacksonCodec> {}
#传输模式
transportMode: NIO
# 分布式锁自动过期时间,防止死锁,默认30000
lockWatchdogTimeout: 30000
# 通过该参数来修改是否按订阅发布消息的接收顺序出来消息,如果选否将对消息实行并行处理,该参数只适用于订阅发布消息的情况, 默认true
keepPubSubOrder: true
# 用来指定高性能引擎的行为。由于该变量值的选用与使用场景息息相关(NORMAL除外)我们建议对每个参数值都进行尝试。
#
#该参数仅限于Redisson PRO版本。
#performanceMode: HIGHER_THROUGHPUT

4)创建Redisson管理对象

​ Redisson管理对象有2个,分别为RedissonClientRedissonConnectionFactory,我们只用在项目的RedisConfig中配置一下这2个对象即可,在RedisConfig中添加的代码如下:


/****
 * Redisson客户端
 * @return
 * @throws IOException
 */
@Bean
public RedissonClient redisson() throws IOException {
    ClassPathResource resource = new ClassPathResource("redssion.yml");
    Config config = Config.fromYAML(resource.getInputStream());
    RedissonClient redisson = Redisson.create(config);
    return redisson;
}

/***
 * Redisson工厂对象
 * @param redisson
 * @return
 */
@Bean
public RedissonConnectionFactory redissonConnectionFactory(RedissonClient redisson) {
    return new RedissonConnectionFactory(redisson);
}

5)测试代码

测试Redisson分布式锁的代码如下:

测试结果如下:

1.3 Redisson分布式锁控制超卖

​ 我们把上面秒杀下单会出现超卖的部分代码用Redisson分布式锁来控制一下,代码如下:


/***
 * 秒杀下单
 * @param orderMap
 */
@Override
public void addHotOrder(Map<String, String> orderMap) {
    String id = orderMap.get("id");
    String username = orderMap.get("username");
    //key
    String key = "SKU_" + id;
    //分布式锁的key
    String lockkey = "LOCKSKU_" + id;
    //用户购买的key
    String userKey = "USER" + username + "ID" + id;

    //尝试获取锁,等待10秒,自己获得锁后一直不解锁则10秒后自动解锁
    boolean bo = distributedLocker.tryLock(lockkey, TimeUnit.SECONDS, 10L, 10L);
    if(bo){
        if (redisTemplate.hasKey(key)) {
            //数量
            Integer num = Integer.parseInt(redisTemplate.boundHashOps(key).get("num").toString());

            //拥有库存,执行递减操作
            if (num > 0) {
                //查询商品
                Result<Sku> result = skuFeign.findById(id);
                Sku sku = result.getData();
                Order order = new Order();
                order.setCreateTime(new Date());
                order.setUpdateTime(order.getCreateTime());
                order.setUsername(username);
                order.setSkuId(id);
                order.setName(sku.getName());
                order.setPrice(sku.getSeckillPrice());
                order.setId("No" + idWorker.nextId());
                order.setOrderStatus("0");
                order.setPayStatus("0");
                order.setConsignStatus("0");
                orderMapper.insertSelective(order);

                //库存递减
                num--;

                if (num == 0) {
                    //同步数据到数据库,秒杀数量归零
                    skuFeign.zero(id);
                }

                //更新数据
                Map<String, Object> dataMap = new HashMap<String, Object>();
                dataMap.put("num", num);
                dataMap.put(userKey, 0);

                //存数据
                redisTemplate.boundHashOps(key).putAll(dataMap);
            }

            //记录该商品用户24小时内无法再次购买,测试环境,我们只配置成1分钟
            redisTemplate.boundValueOps(userKey).set("");
            redisTemplate.expire(userKey, 1, TimeUnit.MINUTES);
        }
        //解锁
        distributedLocker.unlock(lockkey);
    }
}

2 分布式事务

2.1 分布式事务介绍

1)事务

​ 事务提供一种机制将一个业务涉及的所有操作纳入到一个不可分割的执行单元,组成事务的所有操作只有在所有操作均能正常执行的情况下方能提交,只要其中任一操作执行失败,都将导致整个事务的回滚。
​ 简单地说,事务提供一种“要么什么都不做,要么做全套(All or Nothing)”机制。

2)本地事务4大特性

CAID:


A:原子性(Atomicity),一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。
事务在执行过程中发生错误,会被回滚(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
就像你买东西要么交钱收货一起都执行,要么发不出货,就退钱。

C:一致性(Consistency),事务的一致性指的是在一个事务执行之前和执行之后数据库都必须处于一致性状态。
如果事务成功地完成,那么系统中所有变化将正确地应用,系统处于有效状态。
如果在事务中出现错误,那么系统中的所有变化将自动地回滚,系统返回到原始状态。

I:隔离性(Isolation),指的是在并发环境中,当不同的事务同时操纵相同的数据时,每个事务都有各自的完整数据空间。
由并发事务所做的修改必须与任何其他并发事务所做的修改隔离。事务查看数据更新时,数据所处的状态要么是另一事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看到中间状态的数据。

打个比方,你买东西这个事情,是不影响其他人的。
D:持久性(Durability),指的是只要事务成功结束,它对数据库所做的更新就必须***保存下来。
即使发生系统崩溃,重新启动数据库系统后,数据库还能恢复到事务成功结束时的状态。
打个比方,你买东西的时候需要记录在账本上,即使老板忘记了那也有据可查。

3)分布式事务

​ 分布式事务指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。

什么时候会产生分布式事务呢?

1.多个Service

2.多个Resource

如下订单支付业务流程:

4)CAP定理

CAP 定理,又被叫作布鲁尔定理。对于设计分布式系统(不仅仅是分布式事务)的架构师来说,CAP 就是你的入门理论。

C (一致性):对于数据分布在不同节点上的数据来说,如果在某个节点更新了数据,那么在其他节点如果都能读取到这个的数据,那么就称为强一致,如果有某个节点没有读取到,那就是分布式不一致。

A (可用性):非故障的节点在合理的时间内返回合理的响应(不是错误和超时的响应)。可用性的两个关键一个是合理的时间,一个是合理的响应。

合理的时间指的是请求不能被阻塞,应该在合理的时间给出返回。合理的响应指的是系统应该明确返回结果并且结果是正确的,这里的正确指的是比如应该返回 50,而不是返回 40。

P (分区容错性):当出现网络分区后,系统能够继续工作。打个比方,这里集群有多台机器,有台机器网络出现了问题,但是这个集群仍然可以正常工作。

熟悉 CAP 的人都知道,三者不能共有,如果感兴趣可以搜索 CAP 的证明,在分布式系统中,网络无法 100% 可靠,分区其实是一个必然现象。

如果我们选择了 CA 而放弃了 P,那么当发生分区现象时,为了保证一致性,这个时候必须拒绝请求,但是 A 又不允许,所以分布式系统理论上不可能选择 CA 架构,只能选择 CP 或者 AP 架构。

对于 CP 来说,放弃可用性,追求一致性和分区容错性,我们的 ZooKeeper 其实就是追求的强一致。

对于 AP 来说,放弃一致性(这里说的一致性是强一致性),追求分区容错性和可用性,这是很多分布式系统设计时的选择,后面的 BASE 也是根据 AP 来扩展。

顺便一提,CAP 理论中是忽略网络延迟,也就是当事务提交时,从节点 A 复制到节点 B 没有延迟,但是在现实中这个是明显不可能的,所以总会有一定的时间是不一致。

同时 CAP 中选择两个,比如你选择了 CP,并不是叫你放弃 A。因为 P 出现的概率实在是太小了,大部分的时间你仍然需要保证 CA。

就算分区出现了你也要为后来的 A 做准备,比如通过一些日志的手段,是其他机器回复至可用。

2.2 分布式事务解决方案

​ 有了上面的理论基础后,这里开始介绍几种常见的分布式事务的解决方案。

2.2.1 2PC

两阶段提交协议(Two Phase Commitment Protocol)中,涉及到两种角色

一个事务协调者(coordinator):负责协调多个参与者进行事务投票及提交(回滚)
多个事务参与者(participants):即本地事务执行者

总共处理步骤有两个
1)投票阶段(voting phase):协调者将通知事务参与者准备提交或取消事务,然后进入表决过程。参与者将告知协调者自己的决策:同意(事务参与者本地事务执行成功,但未提交)或取消(本地事务执行故障);
2)提交阶段(commit phase):收到参与者的通知后,协调者再向参与者发出通知,根据反馈情况决定各参与者是否要提交还是回滚;

如果所示 1-2为第一阶段,2-3为第二阶段

优点: 尽量保证了数据的强一致,适合对数据强一致要求很高的关键领域。

缺点: 牺牲了可用性,对性能影响较大,不适合高并发高性能场景,如果分布式系统跨接口调用,目前 .NET 界还没有实现方案。

2.2.2 TCC

TCC是一种比较成熟的分布式事务解决方案,可用于解决跨库操作的数据一致性问题;
TCC是服务化的两阶段编程模型,其Try、Confirm、Cancel 3个方法均由业务编码实现;
其中Try操作作为一阶段,负责资源的检查和预留,Confirm操作作为二阶段提交操作,执行真正的业务,Cancel是预留资源的取消;
如下图所示,业务实现TCC服务之后,该TCC服务将作为分布式事务的其中一个资源,参与到整个分布式事务中;事务管理器分2阶段协调TCC服务,在第一阶段调用所有TCC服务的Try方法,在第二阶段执行所有TCC服务的Confirm或者Cancel方法;

操作方法含义
Try预留业务资源/数据效验-尝试检查当前操作是否可执行
Confirm确认执行业务操作,实际提交数据,不做任何业务检查,try成功,confirm必定成功,需保证幂等
Cancel取消执行业务操作,实际回滚数据,需保证幂等

优点: 跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些

缺点: 缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。还存在非幂等问题。

3 Seata分布式事务

3.1 Seata介绍

​ Seata 是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。在 Seata 开源之前,Seata 对应的内部版本在阿里经济体内部一直扮演着分布式一致性中间件的角色,帮助经济体平稳的度过历年的双11,对各BU业务进行了有力的支撑。经过多年沉淀与积累,商业化产品先后在阿里云、金融云进行售卖。

学习文档:Seata 是什么? | Apache Seata

Seata特色功能

3.2 Seata模式讲解

​ Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

相关术语:


TC - 事务协调者
维护全局和分支事务的状态,驱动全局事务提交或回滚。

TM - 事务管理器
定义全局事务的范围:开始全局事务、提交或回滚全局事务。

RM - 资源管理器
管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
3.2.1 AT模式

前提:


1.基于支持本地 ACID 事务的关系型数据库。
2.Java 应用,通过 JDBC 访问数据库。

整体机制:

两阶段提交协议的演变:


一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。

二阶段:
	提交异步化,非常快速地完成。
	回滚通过一阶段的回滚日志进行反向补偿。

写隔离:


1.一阶段本地事务提交前,需要确保先拿到 全局锁 。
2.拿不到 全局锁 ,不能提交本地事务。
3.拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。

以一个示例来说明:

两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。

tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 。

tx1 二阶段全局提交,释放 全局锁 。tx2 拿到 全局锁 提交本地事务。

如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。

此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。

因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。

读隔离:

在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted) 。

如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。

SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。

出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。

工作机制:

以一个示例来说明整个 AT 分支的工作过程。

业务表:product

FieldTypeKey
idbigint(20)PRI
namevarchar(100)
sincevarchar(100)

AT 分支事务的业务逻辑:

update product set name = 'GTS' where name = 'TXC';

一阶段:

过程:

1.解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = ‘TXC’)等相关的信息。

2.查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。

select id, name, since from product where name = 'TXC';

得到前镜像:

idnamesince
1TXC2014

3.执行业务 SQL:更新这条记录的 name 为 ‘GTS’。

4.查询后镜像:根据前镜像的结果,通过 主键 定位数据。

select id, name, since from product where id = 1`;

得到后镜像:

idnamesince
1GTS2014

5.插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。


{
	"branchId": 641789253,
	"undoItems": [{
		"afterImage": {
			"rows": [{
				"fields": [{
					"name": "id",
					"type": 4,
					"value": 1
				}, {
					"name": "name",
					"type": 12,
					"value": "GTS"
				}, {
					"name": "since",
					"type": 12,
					"value": "2014"
				}]
			}],
			"tableName": "product"
		},
		"beforeImage": {
			"rows": [{
				"fields": [{
					"name": "id",
					"type": 4,
					"value": 1
				}, {
					"name": "name",
					"type": 12,
					"value": "TXC"
				}, {
					"name": "since",
					"type": 12,
					"value": "2014"
				}]
			}],
			"tableName": "product"
		},
		"sqlType": "UPDATE"
	}],
	"xid": "xid:xxx"
}

6.提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁 。

7.本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。

8.将本地事务提交的结果上报给 TC。

二阶段-回滚:

1.收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。

2.通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。

3.数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理,详细的说明在另外的文档中介绍。

4.根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句:

update product set name = 'TXC' where id = 1;

5.提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。

二阶段-提交:

1.收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。

2.异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。

回滚日志表:

UNDO_LOG Table:不同数据库在类型上会略有差别。

以 MySQL 为例:

FieldType
branch_idbigint PK
xidvarchar(100)
contextvarchar(128)
rollback_infolongblob
log_statustinyint
log_createddatetime
log_modifieddatetime

-- 注意此处0.7.0+ 增加字段 context
CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
3.2.2 TCC模式

回顾总览中的描述:一个分布式的全局事务,整体是 两阶段提交 的模型。全局事务是由若干分支事务组成的,分支事务要满足 两阶段提交 的模型要求,即需要每个分支事务都具备自己的:

  • 一阶段 prepare 行为
  • 二阶段 commit 或 rollback 行为

根据两阶段行为模式的不同,我们将分支事务划分为 Automatic (Branch) Transaction Mode 和 Manual (Branch) Transaction Mode.

AT 模式(参考链接 TBD)基于 支持本地 ACID 事务 的 关系型数据库

  • 一阶段 prepare 行为:在本地事务中,一并提交业务数据更新和相应回滚日志记录。
  • 二阶段 commit 行为:马上成功结束,**自动** 异步批量清理回滚日志。
  • 二阶段 rollback 行为:通过回滚日志,**自动** 生成补偿操作,完成数据回滚。

相应的,TCC 模式,不依赖于底层数据资源的事务支持:

  • 一阶段 prepare 行为:调用 自定义 的 prepare 逻辑。
  • 二阶段 commit 行为:调用 自定义 的 commit 逻辑。
  • 二阶段 rollback 行为:调用 自定义 的 rollback 逻辑。

所谓 TCC 模式,是指支持把 自定义 的分支事务纳入到全局事务的管理中。

3.2.3 Saga模式

Saga模式是Seata提供的长事务解决方案,在Saga模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现。

理论基础:Hector & Kenneth 发表论⽂ Sagas (1987)

使用场景:

  • 业务流程长、业务流程多
  • 参与者包含其它公司或遗留系统服务,无法提供 TCC 模式要求的三个接口

优势:

  • 一阶段提交本地事务,无锁,高性能
  • 事件驱动架构,参与者可异步执行,高吞吐
  • 补偿服务易于实现

缺点:

3.3 抢单分布式事务实现

​ 非热点商品抢单的时候,我们这里采用Seata分布式事务控制库存减少,当库存递减成功的时候,执行创建订单,当库存减少失败的时候,不执行创建订单,但有可能存在这么一种现象,库存减少成功了,但创建订单失败了,此时需要回滚库存,这时需要跨应用管理事务,因此可以使用Seata实现。

3.3.1 分布式事务实现

Seata分布式事务实现,分如下几个步骤:

1
2
3
4
5
1.在每个需要控制分布式事务的数据库中添加日志表undo_log
2.安装TC
3.配置数据源-代理数据源
4.配置微服务与Seata的TC交互信息以及注册地址
5.使用全局分布式事务的方法上添加注解@GlobalTransactional

1)日志表添加


CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;

2)安装TC

我们这里采用Docker安装,安装命令如下:

docker run -d --name seata-server -p 8191:8191 -e SEATA_PORT=8191 seataio/seata-server:latest

TC下载地址:Releases · apache/incubator-seata · GitHub

3)配置数据源

在需要执行分布式事务控制的工程pom.xml中添加如下依赖:


<!--Seata-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>

seckill-goodsseckill-order中添加配置类,代码如下:


@Configuration
public class DataSourcesProxyConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DataSource dataSource() {
        return new DruidDataSource();
    }

    @Bean
    public DataSourceProxy dataSourceProxy(DataSource dataSource) {
        return new DataSourceProxy(dataSource);
    }

    @Bean
    public SqlSessionFactory sqlSessionFactoryBean(DataSourceProxy dataSourceProxy) throws Exception {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSourceProxy);
        return sqlSessionFactoryBean.getObject();
    }
}

4)配置注册信息

seckill-goodsseckill-order中添加文件registry.conf,内容如下:


registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "file"

  file {
    name = "file.conf"
  }
}

config {
  # file、nacos 、apollo、zk、consul、etcd3
  type = "file"

  file {
    name = "file.conf"
  }
}

seckill-goodsseckill-order中添加文件file.conf,内容如下:


transport {
  # tcp udt unix-domain-socket
  type = "TCP"
  #NIO NATIVE
  server = "NIO"
  #enable heartbeat
  heartbeat = true
  #thread factory for netty
  thread-factory {
    boss-thread-prefix = "NettyBoss"
    worker-thread-prefix = "NettyServerNIOWorker"
    server-executor-thread-prefix = "NettyServerBizHandler"
    share-boss-worker = false
    client-selector-thread-prefix = "NettyClientSelector"
    client-selector-thread-size = 1
    client-worker-thread-prefix = "NettyClientWorkerThread"
    # netty boss thread size,will not be used for UDT
    boss-thread-size = 1
    #auto default pin or 8
    worker-thread-size = 8
  }
  shutdown {
    # when destroy server, wait seconds
    wait = 3
  }
  serialization = "seata"
  compressor = "none"
}
service {
  #vgroup->rgroup
  vgroup_mapping.seata_seckill_transaction = "default"
  #only support single node
  default.grouplist = "192.168.211.137:8191"
  #degrade current not support
  enableDegrade = false
  #disable
  disable = false
  #unit ms,s,m,h,d represents milliseconds, seconds, minutes, hours, days, default permanent
  max.commit.retry.timeout = "-1"
  max.rollback.retry.timeout = "-1"
}

client {
  async.commit.buffer.limit = 10000
  lock {
    retry.internal = 10
    retry.times = 30
  }
  report.retry.count = 5
}

## transaction log store
store {
  ## store mode: file、db
  mode = "file"

  ## file store
  file {
    dir = "sessionStore"

    # branch session size , if exceeded first try compress lockkey, still exceeded throws exceptions
    max-branch-session-size = 16384
    # globe session size , if exceeded throws exceptions
    max-global-session-size = 512
    # file buffer size , if exceeded allocate new buffer
    file-write-buffer-cache-size = 16384
    # when recover batch read size
    session.reload.read_size = 100
    # async, sync
    flush-disk-mode = async
  }
}
lock {
  ## the lock store mode: local、remote
  mode = "remote"
  remote {
    ## store locks in the seata's server
  }
}
recovery {
  committing-retry-delay = 30
  asyn-committing-retry-delay = 30
  rollbacking-retry-delay = 30
  timeout-retry-delay = 30
}

transaction {
  undo.data.validation = true
  undo.log.serialization = "jackson"
}

## metrics settings
metrics {
  enabled = false
  registry-type = "compact"
  # multi exporters use comma divided
  exporter-list = "prometheus"
  exporter-prometheus-port = 9898
}

5)配置事务分组信息

seckill-goodsseckill-order的bootstrap.yml中添如下代码:


spring:
  cloud:
    alibaba:
      seata:
        tx-service-group: seata_seckill_transaction

6)方法开启全局事务

seckill-ordercom.seckill.order.service.impl.OrderServiceImpl#add@GlobalTransactional全局事务注解,代码如下:

3.3.2 全局异常处理

​ 目前项目中所有的异常处理并没有做,直接不友好的把错误信息响应了,如下图:

我们此时需要创建一个全局异常处理器来处理该问题,我们可以在公共工程中创建该处理器,创建com.seckill.framework.BaseExceptionHandler,代码如下:


@ControllerAdvice   //所有请求路径,都将被该类处理->过滤器/(拦截器)
public class BaseExceptionHandler {

    private static Logger logger = LoggerFactory.getLogger(BaseExceptionHandler.class);

    /***
     * 异常处理
     * 当前请求发生了指定异常,则执行该方法处理异常
     */
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public Result error(Exception ex){
        StringWriter stringWriter = new StringWriter();
        PrintWriter writer = new PrintWriter(stringWriter);
        ex.printStackTrace(writer);
        ex.printStackTrace();
        logger.error(stringWriter.toString());
        return new Result(false, StatusCode.ERROR,ex.getMessage(),stringWriter.toString());
    }
}

此时异常信息如下:

小知识点:


如果Seata是低版本,此时事务会失效,事务失效的方式可以通过AOP方式解决,参考地址如下:
https://seata.io/zh-cn/blog/seata-spring-boot-aop-aspectj.html

4 WebSocket

​ 目前用户抢单操作我们已经完成,无论是非热点商品还是热点商品抢单,抢单完成后,我们应该要通知用户抢单状态,非热点商品可以直接响应抢单结果,但热点商品目前还没有实现通知响应,通知用户抢单状态用户可以通过定时向后台发出请求查询实现,但这种短连接方式效率低,会和服务器进行多次通信,这块我们可以使用长连接websocket实现。

4.1 WebSocket介绍

WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双向通讯的协议。

WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

现在,很多网站为了实现推送技术,所用的技术都是 Ajax 轮询。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽等资源。

HTML5 定义的 WebSocket 协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。

浏览器通过 JavaScript 向服务器发出建立 WebSocket 连接的请求,连接建立以后,客户端和服务器端就可以通过 TCP 连接直接交换数据。

当你获取 Web Socket 连接后,你可以通过 send() 方法来向服务器发送数据,并通过 onmessage 事件来接收服务器返回的数据。

4.2 Websocket API

创建 WebSocket 对象:

var socket = new WebSocket(url, [protocol] );

WebSocket属性:

属性描述
socket.readyState只读属性 readyState 表示连接状态,可以是以下值:0 - 表示连接尚未建立。1 - 表示连接已建立,可以进行通信。2 - 表示连接正在进行关闭。3 - 表示连接已经关闭或者连接不能打开。
socket.bufferedAmount只读属性 bufferedAmount 已被 send() 放入正在队列中等待传输,但是还没有发出的 UTF-8 文本字节数。

WebSocket事件:

事件事件处理程序描述
opensocket.onopen连接建立时触发
messagesocket.onmessage客户端接收服务端数据时触发
errorsocket.onerror通信发生错误时触发
closesocket.onclose连接关闭时触发

WebSocket方法:

方法描述
socket.send()使用连接发送数据
socket.close()关闭连接

4.3 WebSocket实例

4.3.1 客户端

​ WebSocket 协议本质上是一个基于 TCP 的协议。为了建立一个 WebSocket 连接,客户端浏览器首先要向服务器发起一个 HTTP 请求,这个请求和通常的 HTTP 请求不同,包含了一些附加头信息,其中附加头信息“Upgrade: WebSocket”表明这是一个申请协议升级的 HTTP 请求,服务器端解析这些附加的头信息然后产生应答信息返回给客户端,客户端和服务器端的 WebSocket 连接就建立起来了,双方就可以通过这个连接通道自由的传递信息,并且这个连接会持续存在直到客户端或者服务器端的某一方主动的关闭连接。

​ 我们按照上面的API实现客户端代码如下:


<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title>websocket</title>
		<script src="jquery-3.2.1.min.js"></script>
		<script>
			var socket;
			if(typeof(WebSocket) == "undefined") {
				console.log("您的浏览器不支持WebSocket");
			}else{
				console.log("您的浏览器支持WebSocket");

				//实现化WebSocket对象,指定要连接的服务器地址与端口  建立连接
				socket = new WebSocket("ws://localhost:18085/socket/zhangsan");
				//打开事件
				socket.onopen = function() {
					console.log("Socket 已打开");
				};
				//获得消息事件
				socket.onmessage = function(msg) {
					console.log(msg.data);
					//发现消息进入    调后台获取
					//getCallingList();
					$("#msg").append(msg.data+"</br>");
				};
				//关闭事件
				socket.onclose = function() {
					console.log("Socket已关闭");
				};
				//发生了错误事件
				socket.onerror = function() {
					alert("Socket发生了错误");
				}

				//关闭连接
				function closeWebSocket() {
					socket.close();
				}

				//发送消息
				function send() {
					var message = document.getElementById('text').value;
					socket.send(message);
				}
			}
		</script>
	</head>
	<body>
		<div id="msg"></div>
	</body>
</html>
4.3.2 服务端

1)引入依赖包

seckill-order引入如下依赖:


<!--websocket-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

2)websocket消息处理

​ 在seckill-order服务端,我们可以创建一个类com.seckill.order.websocket.WebSocketServer,并暴露websocket地址出去,代码如下:


@Slf4j
@ServerEndpoint(value = "/socket/{userid}")
@Component
public class WebSocketServer {

    //concurrent包的线程安全Set,用来存放每个客户端对应的WebSocketServer对象。
    private static CopyOnWriteArraySet<WebSocketServer> webSocketSet = new CopyOnWriteArraySet<WebSocketServer>();

    //与某个客户端的连接会话,需要通过它来给客户端发送数据
    private static Map<String,Session> sessions=new HashMap<String,Session>();

    //用户唯一标识符
    private String userid;

    /**
     * 连接建立成功调用的方法*/
    @OnOpen
    public void onOpen(@PathParam("userid") String userid, Session session) {
        sessions.put(userid,session);
        this.userid=userid;

        webSocketSet.add(this);     //加入set中
        try {
             sendMessage("连接成功");
        } catch (IOException e) {
            log.error("websocket IO异常");
        }
    }

    /**
     * 连接关闭调用的方法
     */
    @OnClose
    public void onClose() {
        webSocketSet.remove(this);  //从set中删除
        sessions.remove(userid);       //移除会话
    }

    /**
     * 收到客户端消息后调用的方法
     *
     * @param message 客户端发送过来的消息*/
    @OnMessage
    public void onMessage(String message) {
        log.info("来自客户端的消息:" + message);
        //群发消息
        for (WebSocketServer item : webSocketSet) {
            try {
                item.sendMessage(message);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 发生异常处理方法
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        error.printStackTrace();
    }

    /***
     * 群发
     * @param message
     * @throws IOException
     */
    public void sendMessage(String message) throws IOException {
        for (Map.Entry<String, Session> sessionEntry : sessions.entrySet()) {
            sessionEntry.getValue().getBasicRemote().sendText(message);
        }
    }

    /***
     * 给指定用户发送消息
     * @param message
     * @param userid
     * @throws IOException
     */
    public void sendMessage(String message,String userid) throws IOException {
            sessions.get(userid).getBasicRemote().sendText(message);
    }
    
    /**
     * 群发自定义消息
     * */
    public static void sendInfo(String message) throws IOException {
        for (WebSocketServer item : webSocketSet) {
            try {
                item.sendMessage(message);
            } catch (IOException e) {
                continue;
            }
        }
    }
}

我们编写一个测试类com.seckill.order.controller.WebSocketController,用于实现给指定用户发消息,代码如下:


@RestController
@RequestMapping(value = "/ws")
public class WebSocketController {

    @Autowired
    private WebSocketServer webSocketServer;

    /***
     * 模拟给指定用户发消息
     */
    @GetMapping(value = "/send/{userid}")
    public Result sendMessage(@PathVariable(value = "userid")String userid, String msg) throws IOException {
        webSocketServer.sendMessage(msg,userid);
        return new Result(true, StatusCode.OK,"发送消息成功@");
    }
}

测试效果如下:

5 Netty高并发网络编程

5.1 Netty介绍

​ Netty 是一个广泛使用的 Java 网络编程框架,它提供了一个易于使用的 API 客户端和服务器,它活跃和成长于用户社区,像大型公司 Facebook 以及流行 开源项目如 Infinispan, HornetQ, Vert.x, Apache Cassandra 和 Elasticsearch 等,都利用其强大的对于网络抽象的核心代码。

​ Netty受到大公司青睐的原因:


1.并发高
2.传输快
3.封装好

并发高:

​ Netty是一款基于NIO(Nonblocking I/O,非阻塞IO)开发的网络通信框架,对比于BIO(Blocking I/O,阻塞IO),他的并发性能得到了很大提高,两张图让你了解BIO和NIO的区别:

​ 从这两图可以看出,NIO的单线程能处理连接的数量比BIO要高出很多,而为什么单线程能处理更多的连接呢?原因就是图二中出现的Selector。
​ 当一个连接建立之后,他有两个步骤要做,第一步是接收完客户端发过来的全部数据,第二步是服务端处理完请求业务之后返回response给客户端。NIO和BIO的区别主要是在第一步。
​ 在BIO中,等待客户端发数据这个过程是阻塞的,这样就造成了一个线程只能处理一个请求的情况,而机器能支持的最大线程数是有限的,这就是为什么BIO不能支持高并发的原因。
​ 而NIO中,当一个Socket建立好之后,Thread并不会阻塞去接受这个Socket,而是将这个请求交给Selector,Selector会不断的去遍历所有的Socket,一旦有一个Socket建立完成,他会通知Thread,然后Thread处理完数据再返回给客户端——这个过程是不阻塞的,这样就能让一个Thread处理更多的请求了。

传输快:

​ Netty的传输快其实也是依赖了NIO的一个特性——*零拷贝*。我们知道,Java的内存有堆内存、栈内存和字符串常量池等等,其中堆内存是占用内存空间最大的一块,也是Java对象存放的地方,一般我们的数据如果需要从IO读取到堆内存,中间需要经过Socket缓冲区,也就是说一个数据会被拷贝两次才能到达他的的终点,如果数据量大,就会造成不必要的资源浪费。
​ Netty针对这种情况,使用了NIO中的另一大特性——零拷贝,当他需要接收数据的时候,他会在堆内存之外开辟一块内存,数据就直接从IO读到了那块内存中去,在netty里面通过ByteBuf可以直接对这些数据进行直接操作,从而加快了传输速度。

关于零拷贝理解可以参考:IBM Developer

封装好:

​ Netty对NIO进行了封装,代码简洁,远远优于传统Socket编程,我们来理解一下Netty的一些重要概念:

Channel:数据传输流,与channel相关的概念有以下四个,上一张图让你了解netty里面的Channel。


1.Channel,表示一个连接,可以理解为每一个请求,就是一个Channel。
2.ChannelHandler,核心处理业务就在这里,用于处理业务请求。
3.ChannelHandlerContext,用于传输业务数据。
4.ChannelPipeline,用于保存处理过程需要用到的ChannelHandler和ChannelHandlerContext。

ByteBuf:ByteBuf是一个存储字节的容器,最大特点就是使用方便,它既有自己的读索引和写索引,方便你对整段字节缓存进行读写,也支持get/set,方便你对其中每一个字节进行读写,他的数据结构如上图所示。

5.2 Netty+WebSocket

​ 我们来实现一个Netty+WebSocket集成案例,由于Netty+WebSocket集成代码比较麻烦,我们可以利用目前开源的项目netty-websocket-spring-boot-starter轻松实现Netty和WebSocket的集成。

我们搭建一个项目,项目叫seckill-message,用于处理通知用户抢单状态。

1)pom.xml


<dependencies>
    <!--db依赖-->
    <dependency>
        <groupId>com.seckill</groupId>
        <artifactId>seckill-db</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </dependency>

    <!--Netty Websocket-->
    <dependency>
        <groupId>org.yeauty</groupId>
        <artifactId>netty-websocket-spring-boot-starter</artifactId>
        <version>0.9.5</version>
    </dependency>
</dependencies>

2)bootstrap.yml


server:
  port: 18088
spring:
  application:
    name: seckill-message
  cloud:
    nacos:
      config:
        file-extension: yaml
        server-addr: nacos-server:8848
      discovery:
        #Nacos的注册地址
        server-addr: nacos-server:8848
  main:
    allow-bean-definition-overriding: true
  redis:
      cluster:
        nodes:
          - redis-server:7001
          - redis-server:7002
          - redis-server:7003
          - redis-server:7004
          - redis-server:7005
          - redis-server:7006
#websocket配置
ws:
  port: 28082
  host: 0.0.0.0

3)Redis配置

在项目中添加redis配置,这里主要用于存储websocket会话部分信息,代码如下:


@Configuration
public class RedisConfig {

    /***
     * 模板操作对象序列化设置
     * @param redissonConnectionFactory
     * @return
     */
    @Bean("redisTemplate")
    public RedisTemplate getRedisTemplate(RedisConnectionFactory redissonConnectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate();
        redisTemplate.setConnectionFactory(redissonConnectionFactory);
        redisTemplate.setValueSerializer(valueSerializer());
        redisTemplate.setKeySerializer(keySerializer());
        redisTemplate.setHashKeySerializer(keySerializer());
        redisTemplate.setHashValueSerializer(valueSerializer());
        redisTemplate.setEnableTransactionSupport(true);
        return redisTemplate;
    }

    /****
     * 序列化设置
     * @return
     */
    @Bean
    public StringRedisSerializer keySerializer() {
        return new StringRedisSerializer();
    }

    /****
     * 序列化设置
     * @return
     */
    @Bean
    public RedisSerializer valueSerializer() {
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
        return jackson2JsonRedisSerializer;
    }
}

4)WebSocket会话处理

WebSocket会话处理我们使用了netty-websocket-spring-boot-starter相关的注解,netty-websocket-spring-boot-starter相关的注解可以参考<https://gitee.com/Yeauty/netty-websocket-spring-boot-starter>,会话处理代码如下:


@Slf4j
@Component
@ServerEndpoint(path = "/ws/{userid}",port = "${ws.port}",host = "${ws.host}")
public class NettyWebSocketServer {

    /***
     * 存储用户WebSocket会话
     */
    private static Map<String,Session> sessionMaps = new HashMap<String,Session>();

    @Autowired
    private RedisTemplate redisTemplate;

    /***
     * 当有新的WebSocket连接完成时,对该方法进行回调 注入参数的类型:Session、HttpHeaders...
     */
    @OnOpen
    public void onOpen(Session session,
                       HttpHeaders headers,
                       @RequestParam String req,
                       @RequestParam MultiValueMap reqMap,
                       @PathVariable String arg,
                       @PathVariable Map pathMap){
        //获取用户ID
        String userId = pathMap.get("userid").toString();

        //将userId存入到Redis中,方便查找Session
        String id = session.channel().id().toString();
        redisTemplate.boundHashOps("WebSocketLogin").put(id,userId);

        //将会话存储起来
        sessionMaps.put(userId,session);
    }

    /***
     * 当有WebSocket连接关闭时,对该方法进行回调 注入参数的类型:Session
     */
    @OnClose
    public void onClose(Session session){
        log.info("会话关闭");
        String key = session.channel().id().toString();
        //获取用户ID
        String userId = redisTemplate.boundHashOps("WebSocketLogin").get(key).toString();
        //删除会话ID
        redisTemplate.boundHashOps("WebSocketLogin").delete(key);
        //移除Session
        sessionMaps.remove(userId);
    }

    /***
     * 当有WebSocket抛出异常时,对该方法进行回调 注入参数的类型:Session、Throwable
     */
    @OnError
    public void onError(Session session, Throwable throwable){
        throwable.printStackTrace();
    }

    /***
     * 当接收到字符串消息时,对该方法进行回调 注入参数的类型:Session、String
     */
    @OnMessage
    public void onMessage(Session session, String message){
        session.sendText("您的会话ID:"+session.channel().id()+",收到的消息:"+message);
    }

    /***
     * 当接收到二进制消息时,对该方法进行回调 注入参数的类型:Session、byte[]
     */
    @OnBinary
    public void onBinary(Session session, byte[] bytes){
        session.sendBinary(bytes);
    }

    /***
     * 当接收到Netty的事件时,对该方法进行回调 注入参数的类型:Session、Object
     */
    @OnEvent
    public void onEvent(Session session, Object evt){
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent idleStateEvent = (IdleStateEvent) evt;
            switch (idleStateEvent.state()) {
                case READER_IDLE:
                    System.out.println("read idle");
                    break;
                case WRITER_IDLE:
                    System.out.println("write idle");
                    break;
                case ALL_IDLE:
                    System.out.println("all idle");
                    break;
                default:
                    break;
            }
        }
    }

    /***
     * 给指定用户发送消息
     * @param userId
     * @param msg
     */
    public void sendMessage(String userId, String msg) {
        Session session = sessionMaps.get(userId);
        if(session!=null){
            session.sendText(msg);
        }
    }
}

并且我们需要注入ServerEndpointExporter对象,代码如下:


@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
        return new ServerEndpointExporter();
    }
}

5)测试

创建启动类:


@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class NettyWebSocketServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(NettyWebSocketServerApplication.class,args);
    }
}

主动向客户端发消息:


@RestController
@CrossOrigin
@RequestMapping(value = "/msg")
public class SendMessageController {

    @Autowired
    private NettyWebSocketServer nettyWebSocketServer;

    /***
     * 消息发送
     * @param userId
     * @param msg
     * @return
     */
    @GetMapping(value = "/send/{userId}")
    public String send(@PathVariable(value = "userId")String userId,String msg){
        nettyWebSocketServer.sendMessage(userId,msg);
        return "发送成功";
    }
}

我们这里编写了2个WebSocket页面:


<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title>websocket</title>
		<script>
			var socket;
			if(typeof(WebSocket) == "undefined") {
				console.log("您的浏览器不支持WebSocket");
			}else{
				console.log("您的浏览器支持WebSocket");

				//实现化WebSocket对象,指定要连接的服务器地址与端口  建立连接
				socket = new WebSocket("ws://localhost:28082/ws/wangwu");
				//打开事件
				socket.onopen = function() {
					console.log("Socket 已打开");
				};
				//获得消息事件
				socket.onmessage = function(msg) {
					console.log(msg.data+"</br>");
					//发现消息进入    调后台获取
					//getCallingList();
					document.getElementById('msg').innerHTML+=msg.data;
				};
				//关闭事件
				socket.onclose = function() {
					console.log("Socket已关闭");
				};
				//发生了错误事件
				socket.onerror = function() {
					alert("Socket发生了错误");
				}

				//关闭连接
				function closeWebSocket() {
					socket.close();
				}

				//发送消息
				function send() {
					var message = document.getElementById('text').value;
					socket.send(message);
				}
			}
		</script>
	</head>
	<body>
		<div id="msg"></div>
		
		<input id="text" /><button type="button" onclick="send()">发送消息</button>
	</body>
</html>

测试效果如下:

5.3 订单状态更新通知

我们为刚才编写的WebSocket编写一个Feign,并在热点抢单成功的地方调用通知用户抢单成功即可。

1)Feign编写

我们先把接收消息的方法改一下,接收一个Map消息,代码如下:


@RestController
@CrossOrigin
@RequestMapping(value = "/msg")
public class SendMessageController {

    @Autowired
    private NettyWebSocketServer nettyWebSocketServer;

    /***
     * 消息发送
     * @return
     */
    @PostMapping(value = "/send/{userId}")
    public String send(@PathVariable(value = "userId")String userId,@RequestParam Map<String,Object> dataMap){
        nettyWebSocketServer.sendMessage(userId, JSON.toJSONString(dataMap));
        return "发送成功";
    }
}

创建feign,代码如下:


@FeignClient(value = "seckill-message")
public interface MessageFeign {

    /****
     * 消息发送
     * @return
     */
    @PostMapping(value = "/msg/send/{userId}")
    String send(@PathVariable(value = "userId")String userId, @RequestParam Map<String,Object> dataMap);
}

2)抢单消息通知

修改热点商品下单,在这里根据用户名进行通知,代码如下:


/***
 * 秒杀下单
 * @param orderMap
 */
@Override
public void addHotOrder(Map<String, String> orderMap) {
    String id = orderMap.get("id");
    String username = orderMap.get("username");
    //key
    String key = "SKU_" + id;
    //分布式锁的key
    String lockkey = "LOCKSKU_" + id;
    //用户购买的key
    String userKey = "USER" + username + "ID" + id;

    //尝试获取锁,等待10秒,自己获得锁后一直不解锁则10秒后自动解锁
    boolean bo = distributedLocker.tryLock(lockkey, TimeUnit.SECONDS, 10L, 10L);
    if(bo){
        if (redisTemplate.hasKey(key)) {
            //...略
        }
        //解锁
        distributedLocker.unlock(lockkey);

        //通知用户抢单成功
        Map<String,Object> dataMap = new HashMap<String,Object>();
        dataMap.put("code",200);
        dataMap.put("message","抢单成功!");
        messageFeign.send(username,dataMap);
    }else{
        Map<String,Object> dataMap = new HashMap<String,Object>();
        dataMap.put("code",20001);
        dataMap.put("message","抢单失败!");
        messageFeign.send(username,dataMap);
    }
}

测试热点商品抢单的时候,返回数据如下:

{"code":"200","message":"抢单成功!"}
  • 21
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

纵然间

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值