nopCommerce 3.9 大波浪系列 之 使用Redis主从高可用缓存

Posted 大波浪 要上进

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了nopCommerce 3.9 大波浪系列 之 使用Redis主从高可用缓存相关的知识,希望对你有一定的参考价值。

一.概述

    nop支持Redis作为缓存,Redis出众的性能在企业中得到了广泛的应用。Redis支持主从复制,HA,集群。

    一般来说,只有一台Redis是不可行的,原因如下:

  1. 单台Redis服务器会发生单点故障,并且单服务器需要处理所有的请求会导致压力较大。
  2. 单台Redis服务器内存容量有限,不易扩展。

    第一个问题可以通过Redis主从模式实现单节点的高可用(HA)。

  • 从节点(slave)是主节点(master)副本,当主节点(master)宕机后,Redis 哨兵(Sentinel)会自动将从节点(slave)提升为主节点。
  • 由于一个master可以有多个slave,这样就可以实现读写分离,master负责写,slave负责读取。
  • slave同步master数据分为完整同步和部分重同步,首次启动或slave长时间离线后重启后会完整同步,当slave短时间离线会执行部分重同步,除非slave出现异常会执行完整同步。所以建议单台redis不要存储太大的数据,数据太大同步时间也会很长。

    第二个问题可以通过Redis-cluster集群解决。(本篇不介绍)

    本篇介绍的是第一个问题的解决方案,即主从模式及哨兵。

二.前期准备

测试环境

    使用Docker部署Redis环境。由于本机操作系统windows 10 专业版,所以使用 docker for windows(下载

  • windows 10 (部署 Web服务器)
  • docker for windows (部署 Redis)

架构

  • 1台 web服务器,部署nopCommerce
  • 1台 Master 主服务器
  • 1台 Slave 从服务器
  • 3台 Sentinel 哨兵服务器,用于检测master状态,负责主备切换。  

    架构图如下:

image_thumb10

软件版本

  • nopCommerce 3.9
  • redis 4.0

三.Docker for Windows  搭建Docker环境

    Docker是一个开源的引擎,可以轻松的为任何应用创建一个轻量级的、可移植的容器。开发者在笔记本上编译测试通过的容器可以批量地在生产环境中部署。这里就不多介绍了,总之很方便。

    由于大波浪操作系统是Win 10 所以使用Docker for Windows (下载

    Docker for Windows 安装需要满足以下条件

  • 64位Windows 10 Pro、Enterprise或者Education版本(Build 10586以上版本)
  • 系统启用Hyper-V。如果没有启用,Docker for Windows在安装过程中会自动启用Hyper-V(这个过程需要重启系统)
  • 如果不是使用的Windows 10,也没有关系,可以使用Docker Toolbox作为替代方案。

    安装成功后任务栏会出现image_thumb11小鲸鱼的图标,打开Hyper-V 管理器发现多出一个虚拟机,Docker就是在这个虚拟机中。

image_thumb13[1]

     右键点击小鲸鱼,Settings菜单用于配置Docker,Kitematic菜单用于打开Kitematic(一款可视化Docker交互容器)。

image_thumb14

     Settings配置

image_thumb16

     首先设置共享目录(Shared Drivers),为Docker容器与宿主机实现目录共享,Docker中叫做volume。

image_thumb18

    然后设置Docker Hub 镜像站,我用的是阿里云。如何配置参考我的另一篇博客

image_thumb20

     打开PowerShell输入docker version 出现下图信息恭喜你安装成功。

image_thumb23

    如果你安装了kitematic,打开后出现下图。

image_thumb26

  Docker的三个基本概念
  • 镜像(Image)

Docker 镜像(Image)就是一个只读的模板。
例如:一个镜像可以包含一个完整的 ubuntu 操作系统环境,里面仅安装了 Redis 或用户需要的其它应用程序。
镜像可以用来创建 Docker 容器。
Docker 提供了一个很简单的机制来创建镜像或者更新现有的镜像,用户甚至可以 直接从其他人那里下载一个已经做好的镜像来直接使用。

  • 容器 (Container)

Docker 利用容器(Container)来运行应用,比如 本篇Redis主从,哨兵都是部署在不同的容器中。
容器是从镜像创建的运行实例。它可以被启动、开始、停止、删除。每个容器都是 相互隔离的、保证安全的平台。
可以把容器看做是一个简易版的 linux 环境(包括root用户权限、进程空间、用户 空间和网络空间等)和运行在其中的应用程序。
*注:镜像是只读的,容器在启动的时候创建一层可写层作为最上层。

  • 仓库(Repository)

仓库(Repository)是集中存放镜像文件的场所。有时候会把仓库和仓库注册服务 器(Registry)混为一谈,并不严格区分。实际上,仓库注册服务器上往往存放着 多个仓库,每个仓库中又包含了多个镜像,每个镜像有不同的标签(tag)。
仓库分为公开仓库(Public)和私有仓库(Private)两种形式。
最大的公开仓库是 Docker Hub,存放了数量庞大的镜像供用户下载。
国内的公开仓库包括 时速云 、网易云 等,可以提供大陆用户更稳定快速的访问。
当然,用户也可以在本地网络内创建一个私有仓库(参考本文“私有仓库”部分)。
当用户创建了自己的镜像之后就可以使用 push 命令将它上传到公有或者私有仓 库,这样下次在另外一台机器上使用这个镜像时候,只需要从仓库上 pull 下来 就可以了。
*注:Docker 仓库的概念跟 Git 类似,注册服务器可以理解为 GitHub 这样的托管服 务。

四.Redis主从及哨兵配置

    Redis slave 从服务器配置

    只需要在从服务器的redis.conf文件中找到

    # slaveof <masterip> <masterport>,   masterip是主服务器ip,masterport为主服务器端口。

    试验中将slave的配置文件修改成 修改成  slaveof redis-master 6379

    Redis sentinel 哨兵服务器配置

  1 #常规配置:
  2 port 26379
  3 daemonize yes
  4 logfile "/var/log/redis/sentinel.log"
  5 
  6 sentinel monitor mymaster redis-master 6379 2       #配置master名、ip、port、需要多少个sentinel才能判断[客观下线]
  7 sentinel down-after-milliseconds mymaster 30000     #配置sentinel向master发出ping,最大响应时间、超过则认为主观下线
  8 sentinel parallel-syncs mymaster  1                 #配置在进行故障转移时,运行多少个slave进行数据备份同步(越少速度越快)
  9 sentinel failover-timeout mymaster  180000          #配置当出现failover时下一个sentinel与上一个sentinel对[同一个master监测的时间间隔](最后设置为客观下线)

 

五.Docker 中部署 Redis

    Docker可以通过run命令创建容器,可以通过Dockerfile文件创建自定义的image(镜像),也可以通过docker-compose通过模板快速部署多个容器。由于我们需要构建5个docker容器我们使用docker-compose方式。

    使用docker-compose需要一个docker-compose.yml(基于YUML语法)配置文件。首先先看下我们的目录结构。

image_thumb27

  • redis 为根目录
    • docker-compose.yml 部署配置文件
    • sentinel  文件夹用于放置 sentinel镜像相关文件
      • Dockerfile  sentinel镜像文件
      • sentinel-entrypoint.sh    sentinel容器启动时执行的命令
      • sentinel.conf    redis-sentinel的配置文件,用于配置哨兵服务。

dos2unix.exe  用于将windows 脚本 转换成 unix 下的脚本。主要转换sentinel-entrypoint.sh文件用的。

    首先看下docker-compose.yml 文件

  1 master:
  2   image: redis:4
  3   ports:
  4      - 7010:6379
  5 slave:
  6   image: redis:4
  7   command: redis-server --slaveof redis-master 6379
  8   ports:
  9      - 7011:6379
 10   links:
 11     - master:redis-master
 12 sentinel:
 13   build: sentinel
 14   environment:
 15     - SENTINEL_QUORUM =2
 16     - SENTINEL_DOWN_AFTER=5000
 17     - SENTINEL_FAILOVER=5000
 18   links:
 19     - master:redis-master
 20     - slave

image_thumb29

  1. 服务名称这里配置了 master,slave ,sentinel三个服务(后续 docker-compose scale 命令可以用到)。
  2. image  指定为镜像名称或镜像 ID,这里使用  redis 4.0版本
  3. ports  设置端口映射 7010:6379 代表外部7010端口映射到容器内部6379端口
  4. command 覆盖容器启动后默认执行的命令,这里是当slave 容器启动时关联主服务器 redis-master
  5. links  链接到其它服务中的容器。使用服务名称(同时作为别名)或服务名称:服务别名 (SERVICE:ALIAS) 格式都可以。这里关联master服务并且使用别名redis-master。
  6. build  指定 Dockerfile 所在文件夹的路径。 Compose 将会利用它自动构建这个镜像,然后使用这个镜像。这里指定当前目录下的sentinel文件夹
  7. environment  设置环境变量。你可以使用数组或字典两种格式。只给定名称的变量会自动获取它在 Compose 主机上的值,可以用来防止泄露不必要的数据。这里环境变量用于配置sentinel.conf的参数。

docker-compose.yml 文件定义了三个服务,master 为主服务,slave为从服务,sentinel为哨兵服务,都是基于redis 4.0镜像,

master:redis 主服务,对外公开的端口为7010 如果你在外部使用 Redis Dasktop这种工具通过7010端口就可以连接了。

slave:redis 从服务,对外公开端口为7011.同时通过links链接到 master容器实现容器间的通信。

sentinel:哨兵服务,使用Dockfile方式构建镜像,同时链接 master 和 slave容器。

    接下来我们看下sentinel文件夹下的Dockefile文件,该文件用于构建哨兵镜像

  1 FROM redis:4
  2 
  3 MAINTAINER dabolang
  4 
  5 EXPOSE 26379
  6 ADD sentinel.conf /etc/redis/sentinel.conf
  7 RUN chown redis:redis /etc/redis/sentinel.conf
  8 ENV SENTINEL_QUORUM 2
  9 ENV SENTINEL_DOWN_AFTER 30000
 10 ENV SENTINEL_FAILOVER 180000
 11 
 12 COPY sentinel-entrypoint.sh /usr/local/bin/
 13 RUN chmod +x /usr/local/bin/sentinel-entrypoint.sh
 14 
 15 ENTRYPOINT ["sentinel-entrypoint.sh"]
 16 
DockerFile

image_thumb33

  1. FROM 定义基于redis 4.0 镜像
  2. MAINTAINER  镜像创建者
  3. EXPOSE  容器内部服务开启的端口,哨兵服务默认端口是 26379.
  4. ADD  复制本地sentinel.conf 哨兵配置文件 拷贝到 容器的 /etc/redis/sentinel.conf
  5. RUN 容器中运行命令 chown redis:redis /etc/redis/sentinel.conf
  6. ENV  创建的时候给容器中加上个需要的环境变量。指定一个值,为后续的RUN指令服务。
  7. COPY 复制本地sentinel-entrypoint.sh文件到容器中/usr/local/bin/目录下
  8. RUN  为7中复制的脚本文件赋予执行权限。
  9. ENTRYPOINT  配置容器启动后执行的命令,并且不可被docker run 提供的参数覆盖,本例中启动后执行 7中复制的脚本。

    sentinel.conf文件为哨兵文件。

  1 # Example sentinel.conf can be downloaded from http://download.redis.io/redis-stable/sentinel.conf
  2 # $SENTINEL_QUORUM 2   判断Sentinel失效的数量,超过failover才会执行
  3 # $SENTINEL_DOWN_AFTER  5000 指定了Sentinel认为Redis实例已经失效所需的毫秒数
  4 # $SENTINEL_FAILOVER  5000  如果在该时间(ms)内未能完成failover操作,则认为该failover失败
  5 
  6 #=======================================================================================
  7 # 监听端口号
  8 #=======================================================================================
  9 port 26379
 10 
 11 #=======================================================================================
 12 # Sentinel服务运行时使用的临时文件夹
 13 #=======================================================================================
 14 dir /tmp
 15 
 16 #=======================================================================================
 17 # Sentinel去监视一个名为mymaster的主redis实例,
 18 # 这个主实例的为Docker,redis-master容器(也可以是IP),端口号为6379,
 19 # 主实例判断为失效至少需要$SENTINEL_QUORUM个Sentinel进程的同意,只要同意Sentinel的数量不达标,自动failover就不会执行
 20 # sentinel monitor mymaster redis-master 6379 2
 21 #=======================================================================================
 22 sentinel monitor mymaster redis-master 6379 $SENTINEL_QUORUM
 23 
 24 #=======================================================================================
 25 # 指定了Sentinel认为Redis实例已经失效所需的毫秒数$SENTINEL_DOWN_AFTER,默认是30000毫秒。
 26 # 当实例超过该时间没有返回PING,或者直接返回错误,那么Sentinel将这个实例标记为主观下线。
 27 # 只有一个Sentinel进程将实例标记为主观下线并不一定会引起实例的自动故障迁移:只有在足够数量的Sentinel都将一个实例标记为主观下线之后,实例才会被标记为客观下线,这时自动故障迁移才会执行
 28 # sentinel down-after-milliseconds mymaster 30000
 29 #=======================================================================================
 30 sentinel down-after-milliseconds mymaster $SENTINEL_DOWN_AFTER
 31 
 32 #=======================================================================================
 33 # 指定了在执行故障转移时,最多可以有多少个从Redis实例在同步新的主实例,
 34 # 在从Redis实例较多的情况下这个数字越小,同步的时间越长,完成故障转移所需的时间就越长
 35 #=======================================================================================
 36 sentinel parallel-syncs mymaster 1
 37 
 38 #=======================================================================================
 39 # 如果在该时间(ms)内未能完成failover操作,则认为该failover失败
 40 # sentinel failover-timeout mymaster 180000
 41 #=======================================================================================
 42 sentinel failover-timeout mymaster $SENTINEL_FAILOVER
 43 
 44 #指定sentinel检测到该监控的redis实例指向的实例异常时,调用的报警脚本。该配置项可选,但是很常用
 45 # Example:
 46 #
 47 # sentinel notification-script mymaster /var/redis/notify.sh
sentinel.conf

    sentinel-entrypoint.sh 脚本通过配置的ENV环境变量替换sentinel.conf中的参数,最后通过exec 命令启动哨兵服务。

  1 #!/bin/sh
  2 
  3 sed -i "s/\\$SENTINEL_QUORUM/$SENTINEL_QUORUM/g" /etc/redis/sentinel.conf
  4 sed -i "s/\\$SENTINEL_DOWN_AFTER/$SENTINEL_DOWN_AFTER/g" /etc/redis/sentinel.conf
  5 sed -i "s/\\$SENTINEL_FAILOVER/$SENTINEL_FAILOVER/g" /etc/redis/sentinel.conf
  6 
  7 exec docker-entrypoint.sh redis-server /etc/redis/sentinel.conf --sentinel

    通过以上配置完整的docker-compose.yml 模板就制作完成了,别急还差最后一步。

    PowerShell 中进入放置docker-compose.yml的目录redis,输入build命令 重建镜像。

   docker-compose build

image_thumb35

    最后输入up命令生成容器。

   docker-compose up -d

image_thumb37

    打开kitematic 或者 输入 docker ps –a 看到我们生成的容器了吧。

image_thumb39

image_thumb43

     点击redis_master_1查看主服务状态,对外的接口也是7010.

image_thumb48

image_thumb45

    我们通过Redis Manager 就可以链接了。

image_thumb51

   我们再看下我们的redis_slave_1从服务器。我们发现不仅对外暴露了7011端口,同时容器内部也关联了主服务。

image_thumb54

    接下来我们测试下主从是否成功。链接主服务器,输入 set master ok 命令插入一条记录。

image_thumb57

    从服务器 输入 get master 获取值为ok,主从复制成功。

image_thumb60

主从配置成功了,我们发现只创建了一个redis_sentinel_1 哨兵容器,我们至少需要3台才能完成切换。

    我们通过 docker-compose scale sentinel=3 命令创建 3个sentinel容器。

image_thumb63

   添加成功后我们发现3个哨兵服务器,并且哨兵容器也记录了其他的哨兵信息。

image_thumb66

    我们在从服务器中输入info Replication 命令查看到 role:slave 并且master主机ip为 172.17.0.2(docker内部ip)

image_thumb70

   现在模拟master主服务器宕机,选中redis_master_1点击STOP按钮,日志中我们看到 在08:53:15时刻关闭主服务器

image_thumb76

    点击哨兵服务器,我们发现08:53:20时发现master服务器宕机,间隔是5秒,为什么是5秒?因为哨兵服务器配置中我们设置的5秒钟,通过另外两台哨兵服务器投票超过了2票最终确认master服务器宕机。然后把slave主机上升为master主机,这里需要注意如果刚才宕机的服务器又正常启动了,这时候该服务器会变为slave服务器,并不会恢复原来的master身份。

image_thumb79

image_thumb82

     我们在salve中输入info Replication命令,查看下该服务是不是上升为master服务了.

     role:master,同时connected_saves:0 说明有0个从服务器。

image_thumb87

   接下来我们把刚才停掉的容器恢复,我们发现connected_saves:1说明宕机的主机恢复后会降为slave服务器。

image_thumb90

  好了,这样我们就完成了主从复制,和哨兵的配置了,是不是使用docker配置环境很简单。

如果部署中遇到问题,又修改了,记得先用docker-compose build 命令 再使用docker-compose up命令

六.nopCommerce 使用Redis作为缓存服务

     Redis环境已经搭建好了,接下来我们修改nopCommerce中的代码来使用Redis.

  • nop使用StackExchange.Redis开源项目作为Redis 客户端   
  • 使用RedLock为 Redis 分布式锁

     首先修改Web.config文件 将 RedisCaching 节点 Enabled设置为True。

     ConnectionString设置我们上边的master(localhost:7010)和slave(localhost:7011)两台服务器。

<RedisCaching Enabled="true" ConnectionString="localhost:7010,localhost:7011" />

image_thumb92

    在Nop.Core项目Caching下拷贝RedisCacheManager.cs 和 RedisConnectionWrapper.cs,

并重命名为RedisMSCacheManager.cs,RedisMSConnectionWrapper.cs。

image_thumb1

   RedisMSCacheManager类中修改RemoveByPattern 和 Clear 方法,添加  if (server.IsConnected == false || server.IsSlave == true) continue;代码判断服务器是否连接是否是从服务。

image_thumb3

  1 using System;
  2 using System.Text;
  3 using Newtonsoft.Json;
  4 using Nop.Core.Configuration;
  5 using Nop.Core.Infrastructure;
  6 using StackExchange.Redis;
  7 using System.Linq;
  8 
  9 namespace Nop.Core.Caching
 10 {
 11     /// <summary>
 12     /// 命名空间:Nop.Core.Caching
 13     /// 名    称:RedisMSCacheManager
 14     /// 功    能:
 15     /// 详    细:
 16     /// 版    本:1.0.0.0
 17     /// 文件名称:RedisMSCacheManager.cs
 18     /// 创建时间:2017-08-29 03:15
 19     /// 修改时间:2017-08-30 05:28
 20     /// 作    者:大波浪
 21     /// 联系方式:http://www.cnblogs.com/yaoshangjin
 22     /// 说    明:
 23     /// </summary>
 24     public partial class RedisMSCacheManager : ICacheManager
 25     {
 26         #region Fields
 27         private readonly IRedisConnectionWrapper _connectionWrapper;
 28         private readonly IDatabase _db;
 29         private readonly ICacheManager _perRequestCacheManager;
 30 
 31         #endregion
 32 
 33         #region Ctor
 34 
 35         public RedisMSCacheManager(NopConfig config, IRedisConnectionWrapper connectionWrapper)
 36         {
 37             if (String.IsNullOrEmpty(config.RedisCachingConnectionString))
 38                 throw new Exception("Redis connection string is empty");
 39 
 40             // ConnectionMultiplexer.Connect should only be called once and shared between callers
 41             this._connectionWrapper = connectionWrapper;
 42 
 43             this._db = _connectionWrapper.GetDatabase();
 44             this._perRequestCacheManager = EngineContext.Current.Resolve<ICacheManager>();
 45         }
 46 
 47         #endregion
 48 
 49         #region Utilities
 50 
 51         protected virtual byte[] Serialize(object item)
 52         {
 53             var jsonString = JsonConvert.SerializeObject(item);
 54             return Encoding.UTF8.GetBytes(jsonString);
 55         }
 56         protected virtual T Deserialize<T>(byte[] serializedObject)
 57         {
 58             if (serializedObject == null)
 59                 return default(T);
 60 
 61             var jsonString = Encoding.UTF8.GetString(serializedObject);
 62             return JsonConvert.DeserializeObject<T>(jsonString);
 63         }
 64 
 65         #endregion
 66 
 67         #region Methods
 68 
 69         /// <summary>
 70         /// Gets or sets the value associated with the specified key.
 71         /// </summary>
 72         /// <typeparam name="T">Type</typeparam>
 73         /// <param name="key">The key of the value to get.</param>
 74         /// <returns>The value associated with the specified key.</returns>
 75         public virtual T Get<T>(string key)
 76         {
 77             //little performance workaround here:
 78             //we use "PerRequestCacheManager" to cache a loaded object in memory for the current HTTP request.
 79             //this way we won\'t connect to Redis server 500 times per HTTP request (e.g. each time to load a locale or setting)
 80             if (_perRequestCacheManager.IsSet(key))
 81                 return _perRequestCacheManager.Get<T>(key);
 82 
 83             var rValue = _db.StringGet(key);
 84             if (!rValue.HasValue)
 85                 return default(T);
 86             var result = Deserialize<T>(rValue);
 87 
 88             _perRequestCacheManager.Set(key, result, 0);
 89             return result;
 90         }
 91 
 92         /// <summary>
 93         /// Adds the specified key and object to the cache.
 94         /// </summary>
 95         /// <param name="key">key</param>
 96         /// <param name="data">Data</param>
 97         /// <param name="cacheTime">Cache time</param>
 98         public virtual void Set(string key, object data, int cacheTime)
 99         {
100             if (data == null)
101                 return;
102 
103             var entryBytes = Serialize(data);
104             var expiresIn = TimeSpan.FromMinutes(cacheTime);
105             _db.StringSet(key, entryBytes, expiresIn);
106         }
107 
108         /// <summary>
109         /// Gets a value indicating whether the value associated with the specified key is cached
110         /// </summary>
111         /// <param name="key">key</param>
112         /// <returns>Result</returns>
113         public virtual bool IsSet(string key)
114         {
115             //little performance workaround here:
116             //we use "PerRequestCacheManager" to cache a loaded object in memory for the current HTTP request.
117             //this way we won\'t connect to Redis server 500 times per HTTP request (e.g. each time to load a locale or setting)
118             if (_perRequestCacheManager.IsSet(key))
119                 return true;
120 
121             return _db.KeyExists(key);
122         }
123 
124         /// <summary>
125         /// Removes the value with the specified key from the cache
126         /// </summary>
127         /// <param name="key">/key</param>
128         public virtual void Remove(string key)
129         {
130             _db.KeyDelete(key);
131             _perRequestCacheManager.Remove(key);
132         }
133 
134         /// <summary>
135         /// Removes items by pattern
136         /// </summary>
137         /// <param name="pattern">pattern</param>
138         public virtual void RemoveByPattern(string pattern)
139         {
140             foreach (var ep in _connectionWrapper.GetEndPoints())
141             {
142                 var server = _connectionWrapper.GetServer(ep);
143                 if (server.IsConnected == false || server.IsSlave == true) continue;
144                 var keys = server.Keys(database: _db.Database, pattern: "*" + pattern + "*");
145                 foreach (var key in keys)
146                     Remove(key);
147             }
148         }
149 
150         /// <summary>
151         /// Clear all cache data
152         /// </summary>
153         public virtual void Clear()
154         {
155             foreach (var ep in _connectionWrapper.GetEndPoints())
156             {
157                 var server = _connectionWrapper.GetServer(ep);
158                 if (server.IsConnected == false|| server.IsSlave==true) continue;
159                 //we can use the code below (commented)
160                 //but it requires administration permission - ",allowAdmin=true"
161                 //server.FlushDatabase();
162 
163                 //that\'s why we simply interate through all elements now
164                 var keys = server.Keys(database: _db.Database);
165                 foreach (var key in keys)
166                     Remove(key);
167             }
168         }
169 
170         /// <summary>
171         /// Dispose
172         /// </summary>
173         public virtual void Dispose()
174         {
175             //if (_connectionWrapper != null)
176             nopCommerce 3.9 大波浪系列 之 汉化-Roxy Fileman

nopCommerce 3.9 大波浪系列 之 global.asax

nopCommerce 3.9 大波浪系列 之 路由扩展 [多语言Seo的实现]

nopCommerce 3.9 大波浪系列 之 路由扩展 [多语言Seo的实现]

nopCommerce 3.9 大波浪系列 之 开发支持多店的插件

nopCommerce 3.9 大波浪系列 之 可退款的支付宝插件(上)